From 7863866f68219a0d69bccd5775af87c0756fd7d4 Mon Sep 17 00:00:00 2001 From: Justin Mazzocchi <2831158+jzzocc@users.noreply.github.com> Date: Wed, 19 Aug 2020 15:16:03 -0700 Subject: [PATCH] Status contexts --- .../ContextEndpoint+Stubbing.swift | 17 +++++ Metatext.xcodeproj/project.pbxproj | 30 +++++++++ Shared/Databases/ContentDatabase.swift | 20 ++++++ Shared/Model/MastodonContext.swift | 8 +++ Shared/Model/Status.swift | 66 +++++++++++++++++++ .../Endpoints/ContextEndpoint.swift | 24 +++++++ .../Status List Services/ContextService.swift | 48 ++++++++++++++ .../StatusListService.swift | 6 ++ .../TimelineService.swift | 8 ++- Shared/View Models/StatusesViewModel.swift | 30 ++++++++- Shared/Views/LazyView.swift | 17 +++++ Shared/Views/StatusesView.swift | 39 +++++++++-- 12 files changed, 304 insertions(+), 9 deletions(-) create mode 100644 Development Assets/Mastodon API Stubs/ContextEndpoint+Stubbing.swift create mode 100644 Shared/Model/MastodonContext.swift create mode 100644 Shared/Networking/Mastodon API/Endpoints/ContextEndpoint.swift create mode 100644 Shared/Services/Status List Services/ContextService.swift create mode 100644 Shared/Views/LazyView.swift diff --git a/Development Assets/Mastodon API Stubs/ContextEndpoint+Stubbing.swift b/Development Assets/Mastodon API Stubs/ContextEndpoint+Stubbing.swift new file mode 100644 index 0000000..ff0a60a --- /dev/null +++ b/Development Assets/Mastodon API Stubs/ContextEndpoint+Stubbing.swift @@ -0,0 +1,17 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation + +extension ContextEndpoint: Stubbing { + func dataString(url: URL) -> String? { + switch self { + case .context: + return """ + { + "ancestors": [], + "descendants": [] + } + """ + } + } +} diff --git a/Metatext.xcodeproj/project.pbxproj b/Metatext.xcodeproj/project.pbxproj index 281dccb..9803118 100644 --- a/Metatext.xcodeproj/project.pbxproj +++ b/Metatext.xcodeproj/project.pbxproj @@ -44,6 +44,16 @@ D019E6EE24DF7BF300697C7D /* IdentityDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = D019E6EC24DF7BF300697C7D /* IdentityDatabase.swift */; }; D019E6F024DF7C2F00697C7D /* DatabaseError.swift in Sources */ = {isa = PBXBuildFile; fileRef = D019E6EF24DF7C2F00697C7D /* DatabaseError.swift */; }; D019E6F124DF7C2F00697C7D /* DatabaseError.swift in Sources */ = {isa = PBXBuildFile; fileRef = D019E6EF24DF7C2F00697C7D /* DatabaseError.swift */; }; + D020F50B24EC9F1D005AB084 /* ContextService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D020F50A24EC9F1D005AB084 /* ContextService.swift */; }; + D020F50C24EC9F1D005AB084 /* ContextService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D020F50A24EC9F1D005AB084 /* ContextService.swift */; }; + D020F50E24ECA25F005AB084 /* ContextEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = D020F50D24ECA25F005AB084 /* ContextEndpoint.swift */; }; + D020F50F24ECA25F005AB084 /* ContextEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = D020F50D24ECA25F005AB084 /* ContextEndpoint.swift */; }; + D020F51124ECA309005AB084 /* MastodonContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = D020F51024ECA309005AB084 /* MastodonContext.swift */; }; + D020F51224ECA309005AB084 /* MastodonContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = D020F51024ECA309005AB084 /* MastodonContext.swift */; }; + D020F51424ECBA60005AB084 /* LazyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D020F51324ECBA60005AB084 /* LazyView.swift */; }; + D020F51524ECBA60005AB084 /* LazyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D020F51324ECBA60005AB084 /* LazyView.swift */; }; + D03658D124EDD80900AC17EC /* ContextEndpoint+Stubbing.swift in Sources */ = {isa = PBXBuildFile; fileRef = D03658D024EDD80900AC17EC /* ContextEndpoint+Stubbing.swift */; }; + D03658D224EDD80900AC17EC /* ContextEndpoint+Stubbing.swift in Sources */ = {isa = PBXBuildFile; fileRef = D03658D024EDD80900AC17EC /* ContextEndpoint+Stubbing.swift */; }; D03DF45B24E62A68007A8CD5 /* DeletionEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = D03DF45A24E62A68007A8CD5 /* DeletionEndpoint.swift */; }; D03DF45C24E62A68007A8CD5 /* DeletionEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = D03DF45A24E62A68007A8CD5 /* DeletionEndpoint.swift */; }; D047FAAE24C3E21200AF17C5 /* MetatextApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D047FA8524C3E21000AF17C5 /* MetatextApp.swift */; }; @@ -272,6 +282,11 @@ D019E6E024DF72E700697C7D /* InstanceEndpoint.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InstanceEndpoint.swift; sourceTree = ""; }; D019E6EC24DF7BF300697C7D /* IdentityDatabase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IdentityDatabase.swift; sourceTree = ""; }; D019E6EF24DF7C2F00697C7D /* DatabaseError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DatabaseError.swift; sourceTree = ""; }; + D020F50A24EC9F1D005AB084 /* ContextService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextService.swift; sourceTree = ""; }; + D020F50D24ECA25F005AB084 /* ContextEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextEndpoint.swift; sourceTree = ""; }; + D020F51024ECA309005AB084 /* MastodonContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonContext.swift; sourceTree = ""; }; + D020F51324ECBA60005AB084 /* LazyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LazyView.swift; sourceTree = ""; }; + D03658D024EDD80900AC17EC /* ContextEndpoint+Stubbing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContextEndpoint+Stubbing.swift"; sourceTree = ""; }; D03DF45A24E62A68007A8CD5 /* DeletionEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletionEndpoint.swift; sourceTree = ""; }; D047FA8524C3E21000AF17C5 /* MetatextApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetatextApp.swift; sourceTree = ""; }; D047FA8724C3E21200AF17C5 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -447,6 +462,7 @@ D019E6DF24DF72E700697C7D /* AccessTokenEndpoint.swift */, D019E6DE24DF72E700697C7D /* AccountEndpoint.swift */, D019E6DC24DF72E700697C7D /* AppAuthorizationEndpoint.swift */, + D020F50D24ECA25F005AB084 /* ContextEndpoint.swift */, D03DF45A24E62A68007A8CD5 /* DeletionEndpoint.swift */, D019E6E024DF72E700697C7D /* InstanceEndpoint.swift */, D019E6DD24DF72E700697C7D /* PreferencesEndpoint.swift */, @@ -548,6 +564,7 @@ D054951024EB101F008B00A5 /* Status List Services */ = { isa = PBXGroup; children = ( + D020F50A24EC9F1D005AB084 /* ContextService.swift */, D054951124EB1041008B00A5 /* StatusListService.swift */, D054951424EB1053008B00A5 /* TimelineService.swift */, ); @@ -575,6 +592,7 @@ D0ED1BD624CF94B200B4899C /* Application.swift */, D05494E924EA3F54008B00A5 /* Attachment.swift */, D05494EF24EA3FE5008B00A5 /* Card.swift */, + D020F51024ECA309005AB084 /* MastodonContext.swift */, D0666A5324C6C3E500F3F04B /* Emoji.swift */, D0666A4A24C6C37700F3F04B /* Identity.swift */, D0666A4D24C6C39600F3F04B /* Instance.swift */, @@ -613,6 +631,7 @@ isa = PBXGroup; children = ( D0DB6EF324C5228A00D965FE /* AddIdentityView.swift */, + D020F51324ECBA60005AB084 /* LazyView.swift */, D075817B24E6659A0081F6A3 /* NotificationTypesPreferencesView.swift */, D0091B6724DC10B30040E8D2 /* PostingReadingPreferencesView.swift */, D0091B6D24DD68090040E8D2 /* PreferencesView.swift */, @@ -666,6 +685,7 @@ D0DC175424D00F0A00A75C65 /* AccessTokenEndpoint+Stubbing.swift */, D04FD73824D4A7B4007D572D /* AccountEndpoint+Stubbing.swift */, D0DC174924CFF15F00A75C65 /* AppAuthorizationEndpoint+Stubbing.swift */, + D03658D024EDD80900AC17EC /* ContextEndpoint+Stubbing.swift */, D04FD73B24D4A83A007D572D /* InstanceEndpoint+Stubbing.swift */, D0DC175124D008E300A75C65 /* MastodonTarget+Stubbing.swift */, D0A652AC24DE3EB6002EA33F /* PreferencesEndpoint+Stubbing.swift */, @@ -963,17 +983,20 @@ D075817924E6657B0081F6A3 /* NotificationTypesPreferencesViewModel.swift in Sources */, D0ED1BD724CF94B200B4899C /* Application.swift in Sources */, D047FAAE24C3E21200AF17C5 /* MetatextApp.swift in Sources */, + D03658D124EDD80900AC17EC /* ContextEndpoint+Stubbing.swift in Sources */, D0BEC94724CA22C400E864C4 /* StatusesViewModel.swift in Sources */, D0666A4E24C6C39600F3F04B /* Instance.swift in Sources */, D057426724E9FE1D00839EBA /* ContentDatabase.swift in Sources */, D054951524EB1053008B00A5 /* TimelineService.swift in Sources */, D019E6E924DF72E700697C7D /* InstanceEndpoint.swift in Sources */, D0ED1BE324CFA84400B4899C /* MastodonError.swift in Sources */, + D020F50E24ECA25F005AB084 /* ContextEndpoint.swift in Sources */, D0EC8DE824E21FEC00A08489 /* Data+Extensions.swift in Sources */, D0666A6324C6DC6C00F3F04B /* AppAuthorization.swift in Sources */, D05494F024EA3FE5008B00A5 /* Card.swift in Sources */, D019E6E524DF72E700697C7D /* AccountEndpoint.swift in Sources */, D075817C24E6659A0081F6A3 /* NotificationTypesPreferencesView.swift in Sources */, + D020F51424ECBA60005AB084 /* LazyView.swift in Sources */, D065F53B24D3B33A00741304 /* View+Extensions.swift in Sources */, D0DC174A24CFF15F00A75C65 /* AppAuthorizationEndpoint+Stubbing.swift in Sources */, D05494E424EA3EF7008B00A5 /* Tag.swift in Sources */, @@ -1004,6 +1027,7 @@ D0BEC93B24C96FD500E864C4 /* RootView.swift in Sources */, D04FD74224D4AA34007D572D /* DevelopmentModels.swift in Sources */, D0EC8DC524DF842700A08489 /* KeychainService.swift in Sources */, + D020F50B24EC9F1D005AB084 /* ContextService.swift in Sources */, D0DC175824D0130800A75C65 /* HTTPStubs.swift in Sources */, D0DC177724D0CF2600A75C65 /* MockKeychainService.swift in Sources */, D0EC8DC224DF7D9C00A08489 /* IdentityService.swift in Sources */, @@ -1042,6 +1066,7 @@ D0EC8DCE24DFB64200A08489 /* AuthenticationService.swift in Sources */, D04FD73C24D4A83A007D572D /* InstanceEndpoint+Stubbing.swift in Sources */, D0DC175B24D0154F00A75C65 /* MastodonAPI.swift in Sources */, + D020F51124ECA309005AB084 /* MastodonContext.swift in Sources */, D0ED1BD124CF779B00B4899C /* MastodonTarget.swift in Sources */, D0CD847C24DBEA9F00CF380C /* Unknowable.swift in Sources */, D0666A6F24C6DFB300F3F04B /* AccessToken.swift in Sources */, @@ -1108,6 +1133,7 @@ D054951624EB1053008B00A5 /* TimelineService.swift in Sources */, D019E6D824DF728400697C7D /* MastodonEncoder.swift in Sources */, D054951C24EB2825008B00A5 /* TransientStatusCollection.swift in Sources */, + D03658D224EDD80900AC17EC /* ContextEndpoint+Stubbing.swift in Sources */, D0DC174E24CFF1F100A75C65 /* Stubbing.swift in Sources */, D0091B6C24DC10CE0040E8D2 /* PostingReadingPreferencesViewModel.swift in Sources */, D0091B6F24DD68090040E8D2 /* PreferencesView.swift in Sources */, @@ -1121,6 +1147,7 @@ D019E6F124DF7C2F00697C7D /* DatabaseError.swift in Sources */, D074577824D29006004758DB /* MockWebAuthSession.swift in Sources */, D057426E24EA339300839EBA /* ListTimeline.swift in Sources */, + D020F51224ECA309005AB084 /* MastodonContext.swift in Sources */, D0ED1BCF24CF768200B4899C /* MastodonEndpoint.swift in Sources */, D074577B24D29366004758DB /* URLSessionConfiguration+Extensions.swift in Sources */, D0ED1BB824CE47F400B4899C /* WebAuthSession.swift in Sources */, @@ -1133,8 +1160,11 @@ D019E6E824DF72E700697C7D /* AccessTokenEndpoint.swift in Sources */, D04FD73D24D4A83A007D572D /* InstanceEndpoint+Stubbing.swift in Sources */, D0DC175C24D0154F00A75C65 /* MastodonAPI.swift in Sources */, + D020F51524ECBA60005AB084 /* LazyView.swift in Sources */, D0ED1BD224CF779B00B4899C /* MastodonTarget.swift in Sources */, D0EC8DC624DF842700A08489 /* KeychainService.swift in Sources */, + D020F50C24EC9F1D005AB084 /* ContextService.swift in Sources */, + D020F50F24ECA25F005AB084 /* ContextEndpoint.swift in Sources */, D0CD847D24DBEA9F00CF380C /* Unknowable.swift in Sources */, D0666A7024C6DFB300F3F04B /* AccessToken.swift in Sources */, D0ED1BCC24CF744200B4899C /* MastodonClient.swift in Sources */, diff --git a/Shared/Databases/ContentDatabase.swift b/Shared/Databases/ContentDatabase.swift index 209d872..1f89795 100644 --- a/Shared/Databases/ContentDatabase.swift +++ b/Shared/Databases/ContentDatabase.swift @@ -56,6 +56,26 @@ extension ContentDatabase { .map { $0.map(Status.init(statusResult:)) } .eraseToAnyPublisher() } + + func statusesObservation(collection: TransientStatusCollection) -> AnyPublisher<[Status], Error> { + ValueObservation.tracking { + try StatusResult.fetchAll( + $0, + StoredStatus.filter( + try collection + .elements + .fetchAll($0) + .map(\.statusId) + .contains(Column("id"))) + .including(required: StoredStatus.account) + .including(optional: StoredStatus.reblogAccount) + .including(optional: StoredStatus.reblog)) + } + .removeDuplicates() + .publisher(in: databaseQueue) + .map { $0.map(Status.init(statusResult:)) } + .eraseToAnyPublisher() + } } private extension ContentDatabase { diff --git a/Shared/Model/MastodonContext.swift b/Shared/Model/MastodonContext.swift new file mode 100644 index 0000000..a7b9228 --- /dev/null +++ b/Shared/Model/MastodonContext.swift @@ -0,0 +1,8 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation + +struct MastodonContext: Codable, Hashable { + let ancestors: [Status] + let descendants: [Status] +} diff --git a/Shared/Model/Status.swift b/Shared/Model/Status.swift index c703103..092af65 100644 --- a/Shared/Model/Status.swift +++ b/Shared/Model/Status.swift @@ -105,3 +105,69 @@ class Status: Codable, Identifiable { self.pinned = pinned } } + +extension Status: Hashable { + static func == (lhs: Status, rhs: Status) -> Bool { + lhs.id == rhs.id + && lhs.uri == rhs.uri + && lhs.createdAt == rhs.createdAt + && lhs.account == rhs.account + && lhs.content == rhs.content + && lhs.visibility == rhs.visibility + && lhs.sensitive == rhs.sensitive + && lhs.spoilerText == rhs.spoilerText + && lhs.mediaAttachments == rhs.mediaAttachments + && lhs.mentions == rhs.mentions + && lhs.tags == rhs.tags + && lhs.emojis == rhs.emojis + && lhs.reblogsCount == rhs.reblogsCount + && lhs.favouritesCount == rhs.favouritesCount + && lhs.repliesCount == rhs.repliesCount + && lhs.application == rhs.application + && lhs.url == rhs.url + && lhs.inReplyToId == rhs.inReplyToId + && lhs.inReplyToAccountId == rhs.inReplyToAccountId + && lhs.reblog == rhs.reblog + && lhs.poll == rhs.poll + && lhs.card == rhs.card + && lhs.language == rhs.language + && lhs.text == rhs.text + && lhs.favourited == rhs.favourited + && lhs.reblogged == rhs.reblogged + && lhs.muted == rhs.muted + && lhs.bookmarked == rhs.bookmarked + && lhs.pinned == rhs.pinned + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + hasher.combine(uri) + hasher.combine(createdAt) + hasher.combine(account) + hasher.combine(content) + hasher.combine(visibility) + hasher.combine(sensitive) + hasher.combine(spoilerText) + hasher.combine(mediaAttachments) + hasher.combine(mentions) + hasher.combine(tags) + hasher.combine(emojis) + hasher.combine(reblogsCount) + hasher.combine(favouritesCount) + hasher.combine(repliesCount) + hasher.combine(application) + hasher.combine(url) + hasher.combine(inReplyToId) + hasher.combine(inReplyToAccountId) + hasher.combine(reblog) + hasher.combine(poll) + hasher.combine(card) + hasher.combine(language) + hasher.combine(text) + hasher.combine(favourited) + hasher.combine(reblogged) + hasher.combine(muted) + hasher.combine(bookmarked) + hasher.combine(pinned) + } +} diff --git a/Shared/Networking/Mastodon API/Endpoints/ContextEndpoint.swift b/Shared/Networking/Mastodon API/Endpoints/ContextEndpoint.swift new file mode 100644 index 0000000..8f91063 --- /dev/null +++ b/Shared/Networking/Mastodon API/Endpoints/ContextEndpoint.swift @@ -0,0 +1,24 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation + +enum ContextEndpoint { + case context(id: String) +} + +extension ContextEndpoint: MastodonEndpoint { + typealias ResultType = MastodonContext + + var context: [String] { + defaultContext + ["statuses"] + } + + var pathComponentsInContext: [String] { + switch self { + case let .context(id): + return [id, "context"] + } + } + + var method: HTTPMethod { .get } +} diff --git a/Shared/Services/Status List Services/ContextService.swift b/Shared/Services/Status List Services/ContextService.swift new file mode 100644 index 0000000..e3c8290 --- /dev/null +++ b/Shared/Services/Status List Services/ContextService.swift @@ -0,0 +1,48 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import Combine + +struct ContextService { + let statusSections: AnyPublisher<[[Status]], Error> + + private var status: Status + private let context = CurrentValueSubject(MastodonContext(ancestors: [], descendants: [])) + private let networkClient: MastodonClient + private let contentDatabase: ContentDatabase + private let collection: TransientStatusCollection + + init(status: Status, networkClient: MastodonClient, contentDatabase: ContentDatabase) { + self.status = status + self.networkClient = networkClient + self.contentDatabase = contentDatabase + collection = TransientStatusCollection(id: "context-\(status.id)") + statusSections = contentDatabase.statusesObservation(collection: collection) + .combineLatest(context.setFailureType(to: Error.self)) + .map { statuses, context in + [ + context.ancestors.map { a in statuses.first { $0.id == a.id } ?? a }, + [statuses.first { $0.id == status.id } ?? status], + context.descendants.map { d in statuses.first { $0.id == d.id } ?? d } + ] + } + .removeDuplicates() + .eraseToAnyPublisher() + } +} + +extension ContextService: StatusListService { + var contextParent: Status? { status } + + func request(maxID: String?, minID: String?) -> AnyPublisher { + networkClient.request(ContextEndpoint.context(id: status.id)) + .handleEvents(receiveOutput: context.send) + .map { ($0.ancestors + $0.descendants, collection) } + .flatMap(contentDatabase.insert(statuses:collection:)) + .eraseToAnyPublisher() + } + + func contextService(status: Status) -> ContextService { + ContextService(status: status, networkClient: networkClient, contentDatabase: contentDatabase) + } +} diff --git a/Shared/Services/Status List Services/StatusListService.swift b/Shared/Services/Status List Services/StatusListService.swift index 2f27667..14d1a47 100644 --- a/Shared/Services/Status List Services/StatusListService.swift +++ b/Shared/Services/Status List Services/StatusListService.swift @@ -5,5 +5,11 @@ import Combine protocol StatusListService { var statusSections: AnyPublisher<[[Status]], Error> { get } + var contextParent: Status? { get } func request(maxID: String?, minID: String?) -> AnyPublisher + func contextService(status: Status) -> ContextService +} + +extension StatusListService { + var contextParent: Status? { nil } } diff --git a/Shared/Services/Status List Services/TimelineService.swift b/Shared/Services/Status List Services/TimelineService.swift index e0c9f6f..9adfa61 100644 --- a/Shared/Services/Status List Services/TimelineService.swift +++ b/Shared/Services/Status List Services/TimelineService.swift @@ -3,7 +3,7 @@ import Foundation import Combine -struct TimelineService: StatusListService { +struct TimelineService { let statusSections: AnyPublisher<[[Status]], Error> private let timeline: Timeline @@ -18,11 +18,17 @@ struct TimelineService: StatusListService { .map { [$0] } .eraseToAnyPublisher() } +} +extension TimelineService: StatusListService { func request(maxID: String?, minID: String?) -> AnyPublisher { return networkClient.request(timeline.endpoint) .map { ($0, timeline) } .flatMap(contentDatabase.insert(statuses:collection:)) .eraseToAnyPublisher() } + + func contextService(status: Status) -> ContextService { + ContextService(status: status, networkClient: networkClient, contentDatabase: contentDatabase) + } } diff --git a/Shared/View Models/StatusesViewModel.swift b/Shared/View Models/StatusesViewModel.swift index b6683a7..4fe3f0c 100644 --- a/Shared/View Models/StatusesViewModel.swift +++ b/Shared/View Models/StatusesViewModel.swift @@ -4,25 +4,53 @@ import Foundation import Combine class StatusesViewModel: ObservableObject { - @Published var statusSections = [[Status]]() + @Published private(set) var statusSections = [[Status]]() @Published var alertItem: AlertItem? + @Published private(set) var loading = false + let scrollToStatusID: AnyPublisher private let statusListService: StatusListService + private let scrollToStatusIDInput = PassthroughSubject() + private var hasScrolledToParentAfterContextLoad = false private var cancellables = Set() init(statusListService: StatusListService) { self.statusListService = statusListService + scrollToStatusID = scrollToStatusIDInput.eraseToAnyPublisher() statusListService.statusSections .assignErrorsToAlertItem(to: \.alertItem, on: self) .assign(to: &$statusSections) + + $statusSections + .sink { [weak self] in + guard let self = self else { return } + + if + let contextParent = self.contextParent, + !($0.first ?? []).isEmpty || !(($0.last ?? []).isEmpty), + !self.hasScrolledToParentAfterContextLoad { + self.hasScrolledToParentAfterContextLoad = true + self.scrollToStatusIDInput.send(contextParent.id) + } + } + .store(in: &cancellables) } } extension StatusesViewModel { + var contextParent: Status? { statusListService.contextParent } + func request(maxID: String? = nil, minID: String? = nil) { statusListService.request(maxID: maxID, minID: minID) .assignErrorsToAlertItem(to: \.alertItem, on: self) + .handleEvents( + receiveSubscription: { [weak self] _ in self?.loading = true }, + receiveCompletion: { [weak self] _ in self?.loading = false }) .sink {} .store(in: &cancellables) } + + func contextViewModel(status: Status) -> StatusesViewModel { + StatusesViewModel(statusListService: statusListService.contextService(status: status)) + } } diff --git a/Shared/Views/LazyView.swift b/Shared/Views/LazyView.swift new file mode 100644 index 0000000..c48c8d5 --- /dev/null +++ b/Shared/Views/LazyView.swift @@ -0,0 +1,17 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import SwiftUI + +struct LazyView: View { + typealias RenderClosure = () -> V + + let render: RenderClosure + + init(_ render: @autoclosure @escaping RenderClosure) { + self.render = render + } + + var body: V { + render() + } +} diff --git a/Shared/Views/StatusesView.swift b/Shared/Views/StatusesView.swift index 92c7077..c799292 100644 --- a/Shared/Views/StatusesView.swift +++ b/Shared/Views/StatusesView.swift @@ -6,14 +6,33 @@ struct StatusesView: View { @StateObject var viewModel: StatusesViewModel var body: some View { - ScrollView { - LazyVStack { - ForEach(Array(zip(viewModel.statusSections.indices, viewModel.statusSections)), - id: \.0) { _, statuses in - ForEach(statuses) { status in - Text(status.content) - Divider() + ScrollViewReader { scrollViewProxy in + ScrollView { + LazyVStack { + ForEach(Array(zip(viewModel.statusSections.indices, viewModel.statusSections)), + id: \.0) { _, statuses in + ForEach(statuses) { status in + if status == viewModel.contextParent { + statusView(status: status) + } else { + NavigationLink(destination: + LazyView(StatusesView(viewModel: + viewModel.contextViewModel(status: status)))) { + statusView(status: status) + } + .buttonStyle(PlainButtonStyle()) + } + Divider() + } } + if viewModel.loading { + ProgressView() + } + } + } + .onReceive(viewModel.scrollToStatusID.receive(on: DispatchQueue.main)) { id in + withAnimation { + scrollViewProxy.scrollTo(id) } } } @@ -22,6 +41,12 @@ struct StatusesView: View { } } +private extension StatusesView { + func statusView(status: Status) -> some View { + Text(status.content) + } +} + #if DEBUG struct StatusesView_Previews: PreviewProvider { static var previews: some View {