Status contexts

This commit is contained in:
Justin Mazzocchi 2020-08-19 15:16:03 -07:00
parent b5017d4805
commit 7863866f68
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
12 changed files with 304 additions and 9 deletions

View file

@ -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": []
}
"""
}
}
}

View file

@ -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 = "<group>"; };
D019E6EC24DF7BF300697C7D /* IdentityDatabase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IdentityDatabase.swift; sourceTree = "<group>"; };
D019E6EF24DF7C2F00697C7D /* DatabaseError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DatabaseError.swift; sourceTree = "<group>"; };
D020F50A24EC9F1D005AB084 /* ContextService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextService.swift; sourceTree = "<group>"; };
D020F50D24ECA25F005AB084 /* ContextEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextEndpoint.swift; sourceTree = "<group>"; };
D020F51024ECA309005AB084 /* MastodonContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonContext.swift; sourceTree = "<group>"; };
D020F51324ECBA60005AB084 /* LazyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LazyView.swift; sourceTree = "<group>"; };
D03658D024EDD80900AC17EC /* ContextEndpoint+Stubbing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContextEndpoint+Stubbing.swift"; sourceTree = "<group>"; };
D03DF45A24E62A68007A8CD5 /* DeletionEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletionEndpoint.swift; sourceTree = "<group>"; };
D047FA8524C3E21000AF17C5 /* MetatextApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetatextApp.swift; sourceTree = "<group>"; };
D047FA8724C3E21200AF17C5 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
@ -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 */,

View file

@ -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 {

View file

@ -0,0 +1,8 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
struct MastodonContext: Codable, Hashable {
let ancestors: [Status]
let descendants: [Status]
}

View file

@ -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)
}
}

View file

@ -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 }
}

View file

@ -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, Never>(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<Void, Error> {
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)
}
}

View file

@ -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<Void, Error>
func contextService(status: Status) -> ContextService
}
extension StatusListService {
var contextParent: Status? { nil }
}

View file

@ -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<Void, Error> {
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)
}
}

View file

@ -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<String, Never>
private let statusListService: StatusListService
private let scrollToStatusIDInput = PassthroughSubject<String, Never>()
private var hasScrolledToParentAfterContextLoad = false
private var cancellables = Set<AnyCancellable>()
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))
}
}

View file

@ -0,0 +1,17 @@
// Copyright © 2020 Metabolist. All rights reserved.
import SwiftUI
struct LazyView<V: View>: View {
typealias RenderClosure = () -> V
let render: RenderClosure
init(_ render: @autoclosure @escaping RenderClosure) {
self.render = render
}
var body: V {
render()
}
}

View file

@ -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 {