Basic status fetching and rendering

This commit is contained in:
Justin Mazzocchi 2020-08-17 22:13:37 -07:00
parent f6568abad9
commit b5017d4805
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
30 changed files with 2544 additions and 40 deletions

View file

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,13 @@
{
"data" : [
{
"filename" : "timeline.json",
"idiom" : "universal",
"universal-type-identifier" : "public.json"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

File diff suppressed because it is too large Load diff

View file

@ -47,7 +47,9 @@ extension AppEnvironment {
static let development = AppEnvironment(
session: Session(configuration: .stubbing),
webAuthSessionType: SuccessfulMockWebAuthSession.self,
keychainServiceType: MockKeychainService.self)
keychainServiceType: MockKeychainService.self,
userDefaults: MockUserDefaults(),
inMemoryContent: true)
}
extension IdentitiesService {
@ -110,4 +112,8 @@ extension NotificationTypesPreferencesViewModel {
static let development = NotificationTypesPreferencesViewModel(identityService: .development)
}
extension StatusesViewModel {
static let development = StatusesViewModel(statusListService: IdentityService.development.service(timeline: .home))
}
// swiftlint:enable force_try

View file

@ -0,0 +1,14 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif
extension TimelinesEndpoint: Stubbing {
func data(url: URL) -> Data? {
NSDataAsset(name: "TimelineJSON")!.data
}
}

View file

@ -30,5 +30,7 @@ extension Stubbing {
dataString(url: url)?.data(using: .utf8)
}
func dataString(url: URL) -> String? { nil }
func statusCode(url: URL) -> Int? { 200 }
}

View file

@ -61,6 +61,34 @@
D052BBCB24D74C9300A80A7A /* MockUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = D052BBC824D74B6400A80A7A /* MockUserDefaults.swift */; };
D052BBD124D750CA00A80A7A /* AppEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D052BBCC24D750A100A80A7A /* AppEnvironment.swift */; };
D052BBD224D750CB00A80A7A /* AppEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D052BBCC24D750A100A80A7A /* AppEnvironment.swift */; };
D05494E424EA3EF7008B00A5 /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05494E324EA3EF7008B00A5 /* Tag.swift */; };
D05494E524EA3EF7008B00A5 /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05494E324EA3EF7008B00A5 /* Tag.swift */; };
D05494E724EA3F1A008B00A5 /* Mention.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05494E624EA3F1A008B00A5 /* Mention.swift */; };
D05494E824EA3F1A008B00A5 /* Mention.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05494E624EA3F1A008B00A5 /* Mention.swift */; };
D05494EA24EA3F54008B00A5 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05494E924EA3F54008B00A5 /* Attachment.swift */; };
D05494EB24EA3F54008B00A5 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05494E924EA3F54008B00A5 /* Attachment.swift */; };
D05494ED24EA3FA9008B00A5 /* Poll.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05494EC24EA3FA9008B00A5 /* Poll.swift */; };
D05494EE24EA3FA9008B00A5 /* Poll.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05494EC24EA3FA9008B00A5 /* Poll.swift */; };
D05494F024EA3FE5008B00A5 /* Card.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05494EF24EA3FE5008B00A5 /* Card.swift */; };
D05494F124EA3FE5008B00A5 /* Card.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05494EF24EA3FE5008B00A5 /* Card.swift */; };
D05494F724EA49F7008B00A5 /* TimelinesEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05494F624EA49F7008B00A5 /* TimelinesEndpoint.swift */; };
D05494F824EA49F7008B00A5 /* TimelinesEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05494F624EA49F7008B00A5 /* TimelinesEndpoint.swift */; };
D05494FA24EA4E5E008B00A5 /* TimelinesEndpoint+Stubbing.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05494F924EA4E5E008B00A5 /* TimelinesEndpoint+Stubbing.swift */; };
D05494FB24EA4E5E008B00A5 /* TimelinesEndpoint+Stubbing.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05494F924EA4E5E008B00A5 /* TimelinesEndpoint+Stubbing.swift */; };
D054950124EA4FFE008B00A5 /* DevelopmentAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D054950024EA4FFE008B00A5 /* DevelopmentAssets.xcassets */; };
D054950224EA4FFE008B00A5 /* DevelopmentAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D054950024EA4FFE008B00A5 /* DevelopmentAssets.xcassets */; };
D054951224EB1041008B00A5 /* StatusListService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D054951124EB1041008B00A5 /* StatusListService.swift */; };
D054951324EB1041008B00A5 /* StatusListService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D054951124EB1041008B00A5 /* StatusListService.swift */; };
D054951524EB1053008B00A5 /* TimelineService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D054951424EB1053008B00A5 /* TimelineService.swift */; };
D054951624EB1053008B00A5 /* TimelineService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D054951424EB1053008B00A5 /* TimelineService.swift */; };
D054951B24EB2825008B00A5 /* TransientStatusCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = D054951A24EB2825008B00A5 /* TransientStatusCollection.swift */; };
D054951C24EB2825008B00A5 /* TransientStatusCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = D054951A24EB2825008B00A5 /* TransientStatusCollection.swift */; };
D057426724E9FE1D00839EBA /* ContentDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = D057426624E9FE1D00839EBA /* ContentDatabase.swift */; };
D057426824E9FE1D00839EBA /* ContentDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = D057426624E9FE1D00839EBA /* ContentDatabase.swift */; };
D057426A24EA32AC00839EBA /* Timeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = D057426924EA32AC00839EBA /* Timeline.swift */; };
D057426B24EA32AC00839EBA /* Timeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = D057426924EA32AC00839EBA /* Timeline.swift */; };
D057426D24EA339300839EBA /* ListTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = D057426C24EA339300839EBA /* ListTimeline.swift */; };
D057426E24EA339300839EBA /* ListTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = D057426C24EA339300839EBA /* ListTimeline.swift */; };
D065F53924D37E5100741304 /* CombineExpectations in Frameworks */ = {isa = PBXBuildFile; productRef = D065F53824D37E5100741304 /* CombineExpectations */; };
D065F53B24D3B33A00741304 /* View+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D065F53A24D3B33A00741304 /* View+Extensions.swift */; };
D065F53C24D3B33A00741304 /* View+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D065F53A24D3B33A00741304 /* View+Extensions.swift */; };
@ -102,10 +130,10 @@
D0BEC93924C9632800E864C4 /* RootViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEC93724C9632800E864C4 /* RootViewModel.swift */; };
D0BEC93B24C96FD500E864C4 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEC93A24C96FD500E864C4 /* RootView.swift */; };
D0BEC93C24C96FD500E864C4 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEC93A24C96FD500E864C4 /* RootView.swift */; };
D0BEC94724CA22C400E864C4 /* TimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEC94624CA22C400E864C4 /* TimelineViewModel.swift */; };
D0BEC94824CA22C400E864C4 /* TimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEC94624CA22C400E864C4 /* TimelineViewModel.swift */; };
D0BEC94A24CA231200E864C4 /* TimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEC94924CA231200E864C4 /* TimelineView.swift */; };
D0BEC94B24CA231200E864C4 /* TimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEC94924CA231200E864C4 /* TimelineView.swift */; };
D0BEC94724CA22C400E864C4 /* StatusesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEC94624CA22C400E864C4 /* StatusesViewModel.swift */; };
D0BEC94824CA22C400E864C4 /* StatusesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEC94624CA22C400E864C4 /* StatusesViewModel.swift */; };
D0BEC94A24CA231200E864C4 /* StatusesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEC94924CA231200E864C4 /* StatusesView.swift */; };
D0BEC94B24CA231200E864C4 /* StatusesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEC94924CA231200E864C4 /* StatusesView.swift */; };
D0C963FB24CC359D003BD330 /* AlertItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C963FA24CC359D003BD330 /* AlertItem.swift */; };
D0C963FC24CC359D003BD330 /* AlertItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C963FA24CC359D003BD330 /* AlertItem.swift */; };
D0C963FE24CC3812003BD330 /* Publisher+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C963FD24CC3812003BD330 /* Publisher+Extensions.swift */; };
@ -258,6 +286,20 @@
D052BBC624D749C800A80A7A /* RootViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootViewModelTests.swift; sourceTree = "<group>"; };
D052BBC824D74B6400A80A7A /* MockUserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockUserDefaults.swift; sourceTree = "<group>"; };
D052BBCC24D750A100A80A7A /* AppEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppEnvironment.swift; sourceTree = "<group>"; };
D05494E324EA3EF7008B00A5 /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = "<group>"; };
D05494E624EA3F1A008B00A5 /* Mention.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mention.swift; sourceTree = "<group>"; };
D05494E924EA3F54008B00A5 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = "<group>"; };
D05494EC24EA3FA9008B00A5 /* Poll.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Poll.swift; sourceTree = "<group>"; };
D05494EF24EA3FE5008B00A5 /* Card.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Card.swift; sourceTree = "<group>"; };
D05494F624EA49F7008B00A5 /* TimelinesEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinesEndpoint.swift; sourceTree = "<group>"; };
D05494F924EA4E5E008B00A5 /* TimelinesEndpoint+Stubbing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TimelinesEndpoint+Stubbing.swift"; sourceTree = "<group>"; };
D054950024EA4FFE008B00A5 /* DevelopmentAssets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = DevelopmentAssets.xcassets; sourceTree = "<group>"; };
D054951124EB1041008B00A5 /* StatusListService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusListService.swift; sourceTree = "<group>"; };
D054951424EB1053008B00A5 /* TimelineService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineService.swift; sourceTree = "<group>"; };
D054951A24EB2825008B00A5 /* TransientStatusCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransientStatusCollection.swift; sourceTree = "<group>"; };
D057426624E9FE1D00839EBA /* ContentDatabase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentDatabase.swift; sourceTree = "<group>"; };
D057426924EA32AC00839EBA /* Timeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Timeline.swift; sourceTree = "<group>"; };
D057426C24EA339300839EBA /* ListTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListTimeline.swift; sourceTree = "<group>"; };
D065F53A24D3B33A00741304 /* View+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Extensions.swift"; sourceTree = "<group>"; };
D0666A2124C677B400F3F04B /* Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = Tests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
D0666A2524C677B400F3F04B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
@ -278,8 +320,8 @@
D0B23F0C24D210E90066F411 /* NSError+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSError+Extensions.swift"; sourceTree = "<group>"; };
D0BEC93724C9632800E864C4 /* RootViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootViewModel.swift; sourceTree = "<group>"; };
D0BEC93A24C96FD500E864C4 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; };
D0BEC94624CA22C400E864C4 /* TimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineViewModel.swift; sourceTree = "<group>"; };
D0BEC94924CA231200E864C4 /* TimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineView.swift; sourceTree = "<group>"; };
D0BEC94624CA22C400E864C4 /* StatusesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusesViewModel.swift; sourceTree = "<group>"; };
D0BEC94924CA231200E864C4 /* StatusesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusesView.swift; sourceTree = "<group>"; };
D0C963FA24CC359D003BD330 /* AlertItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertItem.swift; sourceTree = "<group>"; };
D0C963FD24CC3812003BD330 /* Publisher+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Publisher+Extensions.swift"; sourceTree = "<group>"; };
D0CD847224DBDEC700CF380C /* MastodonPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPreferences.swift; sourceTree = "<group>"; };
@ -409,6 +451,7 @@
D019E6E024DF72E700697C7D /* InstanceEndpoint.swift */,
D019E6DD24DF72E700697C7D /* PreferencesEndpoint.swift */,
D0EC8DED24E2704D00A08489 /* PushSubscriptionEndpoint.swift */,
D05494F624EA49F7008B00A5 /* TimelinesEndpoint.swift */,
);
path = Endpoints;
sourceTree = "<group>";
@ -418,6 +461,7 @@
children = (
D019E6EF24DF7C2F00697C7D /* DatabaseError.swift */,
D019E6EC24DF7BF300697C7D /* IdentityDatabase.swift */,
D057426624E9FE1D00839EBA /* ContentDatabase.swift */,
);
path = Databases;
sourceTree = "<group>";
@ -425,12 +469,13 @@
D019E6F224DF7C9E00697C7D /* Services */ = {
isa = PBXGroup;
children = (
D054951024EB101F008B00A5 /* Status List Services */,
D0EC8DCD24DFB64200A08489 /* AuthenticationService.swift */,
D0EC8DCA24DFA06700A08489 /* IdentitiesService.swift */,
D0EC8DC124DF7D9C00A08489 /* IdentityService.swift */,
D0EC8DC424DF842700A08489 /* KeychainService.swift */,
D0EC8DD724E096C900A08489 /* UserNotificationService.swift */,
D0EC8DC724DF8B3C00A08489 /* SecretsService.swift */,
D0EC8DD724E096C900A08489 /* UserNotificationService.swift */,
);
path = Services;
sourceTree = "<group>";
@ -500,6 +545,15 @@
path = macOS;
sourceTree = "<group>";
};
D054951024EB101F008B00A5 /* Status List Services */ = {
isa = PBXGroup;
children = (
D054951124EB1041008B00A5 /* StatusListService.swift */,
D054951424EB1053008B00A5 /* TimelineService.swift */,
);
path = "Status List Services";
sourceTree = "<group>";
};
D0666A2224C677B400F3F04B /* Tests */ = {
isa = PBXGroup;
children = (
@ -519,14 +573,22 @@
D0666A6224C6DC6C00F3F04B /* AppAuthorization.swift */,
D052BBCC24D750A100A80A7A /* AppEnvironment.swift */,
D0ED1BD624CF94B200B4899C /* Application.swift */,
D05494E924EA3F54008B00A5 /* Attachment.swift */,
D05494EF24EA3FE5008B00A5 /* Card.swift */,
D0666A5324C6C3E500F3F04B /* Emoji.swift */,
D0666A4A24C6C37700F3F04B /* Identity.swift */,
D0666A4D24C6C39600F3F04B /* Instance.swift */,
D057426C24EA339300839EBA /* ListTimeline.swift */,
D0ED1BE224CFA84400B4899C /* MastodonError.swift */,
D0CD847224DBDEC700CF380C /* MastodonPreferences.swift */,
D05494E624EA3F1A008B00A5 /* Mention.swift */,
D05494EC24EA3FA9008B00A5 /* Poll.swift */,
D0E5362F24E5436C00FB1CE1 /* PushNotification.swift */,
D0EC8DEA24E26F1100A08489 /* PushSubscription.swift */,
D0CD847524DBDF3C00CF380C /* Status.swift */,
D05494E324EA3EF7008B00A5 /* Tag.swift */,
D057426924EA32AC00839EBA /* Timeline.swift */,
D054951A24EB2825008B00A5 /* TransientStatusCollection.swift */,
D0CD847B24DBEA9F00CF380C /* Unknowable.swift */,
);
path = Model;
@ -555,7 +617,7 @@
D0091B6724DC10B30040E8D2 /* PostingReadingPreferencesView.swift */,
D0091B6D24DD68090040E8D2 /* PreferencesView.swift */,
D0BEC93A24C96FD500E864C4 /* RootView.swift */,
D0BEC94924CA231200E864C4 /* TimelineView.swift */,
D0BEC94924CA231200E864C4 /* StatusesView.swift */,
);
path = Views;
sourceTree = "<group>";
@ -579,7 +641,7 @@
D0091B6A24DC10CE0040E8D2 /* PostingReadingPreferencesViewModel.swift */,
D0091B7024DD68220040E8D2 /* PreferencesViewModel.swift */,
D0BEC93724C9632800E864C4 /* RootViewModel.swift */,
D0BEC94624CA22C400E864C4 /* TimelineViewModel.swift */,
D0BEC94624CA22C400E864C4 /* StatusesViewModel.swift */,
);
path = "View Models";
sourceTree = "<group>";
@ -607,6 +669,7 @@
D04FD73B24D4A83A007D572D /* InstanceEndpoint+Stubbing.swift */,
D0DC175124D008E300A75C65 /* MastodonTarget+Stubbing.swift */,
D0A652AC24DE3EB6002EA33F /* PreferencesEndpoint+Stubbing.swift */,
D05494F924EA4E5E008B00A5 /* TimelinesEndpoint+Stubbing.swift */,
);
path = "Mastodon API Stubs";
sourceTree = "<group>";
@ -641,6 +704,7 @@
D0ED1BB224CE3A1600B4899C /* Development Assets */ = {
isa = PBXGroup;
children = (
D054950024EA4FFE008B00A5 /* DevelopmentAssets.xcassets */,
D04FD74124D4AA34007D572D /* DevelopmentModels.swift */,
D0DC175724D0130800A75C65 /* HTTPStubs.swift */,
D0DC174824CFF13700A75C65 /* Mastodon API Stubs */,
@ -817,6 +881,7 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
D054950124EA4FFE008B00A5 /* DevelopmentAssets.xcassets in Resources */,
D047FAB224C3E21200AF17C5 /* Assets.xcassets in Resources */,
D06B491F24D3F7FE00642749 /* Localizable.strings in Resources */,
);
@ -826,6 +891,7 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
D054950224EA4FFE008B00A5 /* DevelopmentAssets.xcassets in Resources */,
D047FAB324C3E21200AF17C5 /* Assets.xcassets in Resources */,
D06B492024D3FB8000642749 /* Localizable.strings in Resources */,
);
@ -890,21 +956,27 @@
buildActionMask = 2147483647;
files = (
D04FD73924D4A7B4007D572D /* AccountEndpoint+Stubbing.swift in Sources */,
D05494F724EA49F7008B00A5 /* TimelinesEndpoint.swift in Sources */,
D054951B24EB2825008B00A5 /* TransientStatusCollection.swift in Sources */,
D0DB6F0924C65AC000D965FE /* AddIdentityViewModel.swift in Sources */,
D0CD847324DBDEC700CF380C /* MastodonPreferences.swift in Sources */,
D075817924E6657B0081F6A3 /* NotificationTypesPreferencesViewModel.swift in Sources */,
D0ED1BD724CF94B200B4899C /* Application.swift in Sources */,
D047FAAE24C3E21200AF17C5 /* MetatextApp.swift in Sources */,
D0BEC94724CA22C400E864C4 /* TimelineViewModel.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 */,
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 */,
D065F53B24D3B33A00741304 /* View+Extensions.swift in Sources */,
D0DC174A24CFF15F00A75C65 /* AppAuthorizationEndpoint+Stubbing.swift in Sources */,
D05494E424EA3EF7008B00A5 /* Tag.swift in Sources */,
D0159F8A24DE742F00E78478 /* IdentitiesViewModel.swift in Sources */,
D0666A5124C6C3BC00F3F04B /* Account.swift in Sources */,
D019E6E124DF72E700697C7D /* AppAuthorizationEndpoint.swift in Sources */,
@ -920,13 +992,15 @@
D019E6E324DF72E700697C7D /* PreferencesEndpoint.swift in Sources */,
D0666A4B24C6C37700F3F04B /* Identity.swift in Sources */,
D0666A5424C6C3E500F3F04B /* Emoji.swift in Sources */,
D05494FA24EA4E5E008B00A5 /* TimelinesEndpoint+Stubbing.swift in Sources */,
D0A652AD24DE3EB6002EA33F /* PreferencesEndpoint+Stubbing.swift in Sources */,
D0DC175524D00F0A00A75C65 /* AccessTokenEndpoint+Stubbing.swift in Sources */,
D0B23F0D24D210E90066F411 /* NSError+Extensions.swift in Sources */,
D05494ED24EA3FA9008B00A5 /* Poll.swift in Sources */,
D019E6D924DF728400697C7D /* MastodonDecoder.swift in Sources */,
D052BBCA24D74C9200A80A7A /* MockUserDefaults.swift in Sources */,
D0DC175224D008E300A75C65 /* MastodonTarget+Stubbing.swift in Sources */,
D0BEC94A24CA231200E864C4 /* TimelineView.swift in Sources */,
D0BEC94A24CA231200E864C4 /* StatusesView.swift in Sources */,
D0BEC93B24C96FD500E864C4 /* RootView.swift in Sources */,
D04FD74224D4AA34007D572D /* DevelopmentModels.swift in Sources */,
D0EC8DC524DF842700A08489 /* KeychainService.swift in Sources */,
@ -941,6 +1015,7 @@
D0EC8DE524E0B44500A08489 /* UserNotificationService.swift in Sources */,
D0EC8DCB24DFA06700A08489 /* IdentitiesService.swift in Sources */,
D0091B7124DD68220040E8D2 /* PreferencesViewModel.swift in Sources */,
D057426A24EA32AC00839EBA /* Timeline.swift in Sources */,
D0DC174D24CFF1F100A75C65 /* Stubbing.swift in Sources */,
D0091B6B24DC10CE0040E8D2 /* PostingReadingPreferencesViewModel.swift in Sources */,
D0159F8624DE742F00E78478 /* TabNavigationViewModel.swift in Sources */,
@ -952,13 +1027,16 @@
D0DB6EF424C5228A00D965FE /* AddIdentityView.swift in Sources */,
D074577724D29006004758DB /* MockWebAuthSession.swift in Sources */,
D0159FA524DE989700E78478 /* NSMutableAttributedString+Extensions.swift in Sources */,
D057426D24EA339300839EBA /* ListTimeline.swift in Sources */,
D0ED1BCE24CF768200B4899C /* MastodonEndpoint.swift in Sources */,
D074577A24D29366004758DB /* URLSessionConfiguration+Extensions.swift in Sources */,
D0ED1BB724CE47F400B4899C /* WebAuthSession.swift in Sources */,
D0A1CA7424DAC2F1003063E9 /* KingfisherOptionsInfo+Extensions.swift in Sources */,
D054951224EB1041008B00A5 /* StatusListService.swift in Sources */,
D0159F9124DE743700E78478 /* TabNavigationView.swift in Sources */,
D0ED1BC424CED54D00B4899C /* HTTPTarget.swift in Sources */,
D0EC8DC824DF8B3C00A08489 /* SecretsService.swift in Sources */,
D05494E724EA3F1A008B00A5 /* Mention.swift in Sources */,
D0159FA324DE955900E78478 /* CustomEmojiText.swift in Sources */,
D0C963FE24CC3812003BD330 /* Publisher+Extensions.swift in Sources */,
D0EC8DCE24DFB64200A08489 /* AuthenticationService.swift in Sources */,
@ -969,6 +1047,7 @@
D0666A6F24C6DFB300F3F04B /* AccessToken.swift in Sources */,
D0ED1BCB24CF744200B4899C /* MastodonClient.swift in Sources */,
D0091B6824DC10B30040E8D2 /* PostingReadingPreferencesView.swift in Sources */,
D05494EA24EA3F54008B00A5 /* Attachment.swift in Sources */,
D0CD847624DBDF3C00CF380C /* Status.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -983,7 +1062,7 @@
D0CD847424DBDEC700CF380C /* MastodonPreferences.swift in Sources */,
D0ED1BD824CF94B200B4899C /* Application.swift in Sources */,
D047FAAF24C3E21200AF17C5 /* MetatextApp.swift in Sources */,
D0BEC94824CA22C400E864C4 /* TimelineViewModel.swift in Sources */,
D0BEC94824CA22C400E864C4 /* StatusesViewModel.swift in Sources */,
D0159FA624DE98F600E78478 /* NSMutableAttributedString+Extensions.swift in Sources */,
D0EC8DC324DF7D9C00A08489 /* IdentityService.swift in Sources */,
D0666A4F24C6C39600F3F04B /* Instance.swift in Sources */,
@ -992,6 +1071,8 @@
D065F53C24D3B33A00741304 /* View+Extensions.swift in Sources */,
D0DC174B24CFF15F00A75C65 /* AppAuthorizationEndpoint+Stubbing.swift in Sources */,
D0666A5224C6C3BC00F3F04B /* Account.swift in Sources */,
D05494EE24EA3FA9008B00A5 /* Poll.swift in Sources */,
D05494FB24EA4E5E008B00A5 /* TimelinesEndpoint+Stubbing.swift in Sources */,
D052BBD124D750CA00A80A7A /* AppEnvironment.swift in Sources */,
D081A40624D0F1A8001B016E /* String+Extensions.swift in Sources */,
D0BEC93924C9632800E864C4 /* RootViewModel.swift in Sources */,
@ -1008,18 +1089,25 @@
D052BBCB24D74C9300A80A7A /* MockUserDefaults.swift in Sources */,
D0DC175324D008E300A75C65 /* MastodonTarget+Stubbing.swift in Sources */,
D0EC8DE924E21FEC00A08489 /* Data+Extensions.swift in Sources */,
D0BEC94B24CA231200E864C4 /* TimelineView.swift in Sources */,
D0BEC94B24CA231200E864C4 /* StatusesView.swift in Sources */,
D05494E524EA3EF7008B00A5 /* Tag.swift in Sources */,
D0BEC93C24C96FD500E864C4 /* RootView.swift in Sources */,
D0159F9B24DE748900E78478 /* SidebarNavigationViewModel.swift in Sources */,
D04FD74324D4AA34007D572D /* DevelopmentModels.swift in Sources */,
D0DC175924D0130800A75C65 /* HTTPStubs.swift in Sources */,
D05494E824EA3F1A008B00A5 /* Mention.swift in Sources */,
D057426B24EA32AC00839EBA /* Timeline.swift in Sources */,
D019E6DA24DF728400697C7D /* MastodonDecoder.swift in Sources */,
D05494EB24EA3F54008B00A5 /* Attachment.swift in Sources */,
D0DC177824D0CF2600A75C65 /* MockKeychainService.swift in Sources */,
D0C963FC24CC359D003BD330 /* AlertItem.swift in Sources */,
D019E6E224DF72E700697C7D /* AppAuthorizationEndpoint.swift in Sources */,
D0DC174724CFEC2000A75C65 /* StubbingURLProtocol.swift in Sources */,
D05494F824EA49F7008B00A5 /* TimelinesEndpoint.swift in Sources */,
D0091B7224DD68220040E8D2 /* PreferencesViewModel.swift in Sources */,
D054951624EB1053008B00A5 /* TimelineService.swift in Sources */,
D019E6D824DF728400697C7D /* MastodonEncoder.swift in Sources */,
D054951C24EB2825008B00A5 /* TransientStatusCollection.swift in Sources */,
D0DC174E24CFF1F100A75C65 /* Stubbing.swift in Sources */,
D0091B6C24DC10CE0040E8D2 /* PostingReadingPreferencesViewModel.swift in Sources */,
D0091B6F24DD68090040E8D2 /* PreferencesView.swift in Sources */,
@ -1028,9 +1116,11 @@
D0DB6EF524C5233E00D965FE /* AddIdentityView.swift in Sources */,
D019E6EA24DF72E700697C7D /* InstanceEndpoint.swift in Sources */,
D0EC8DCF24DFB64200A08489 /* AuthenticationService.swift in Sources */,
D054951324EB1041008B00A5 /* StatusListService.swift in Sources */,
D0159F9C24DE748C00E78478 /* SidebarNavigationView.swift in Sources */,
D019E6F124DF7C2F00697C7D /* DatabaseError.swift in Sources */,
D074577824D29006004758DB /* MockWebAuthSession.swift in Sources */,
D057426E24EA339300839EBA /* ListTimeline.swift in Sources */,
D0ED1BCF24CF768200B4899C /* MastodonEndpoint.swift in Sources */,
D074577B24D29366004758DB /* URLSessionConfiguration+Extensions.swift in Sources */,
D0ED1BB824CE47F400B4899C /* WebAuthSession.swift in Sources */,
@ -1050,6 +1140,8 @@
D0ED1BCC24CF744200B4899C /* MastodonClient.swift in Sources */,
D0091B6924DC10B30040E8D2 /* PostingReadingPreferencesView.swift in Sources */,
D075817A24E6657B0081F6A3 /* NotificationTypesPreferencesViewModel.swift in Sources */,
D05494F124EA3FE5008B00A5 /* Card.swift in Sources */,
D057426824E9FE1D00839EBA /* ContentDatabase.swift in Sources */,
D019E6E624DF72E700697C7D /* AccountEndpoint.swift in Sources */,
D0CD847724DBDF3C00CF380C /* Status.swift in Sources */,
D0EC8DEF24E2704D00A08489 /* PushSubscriptionEndpoint.swift in Sources */,

View file

@ -0,0 +1,410 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
import Combine
import GRDB
// swiftlint:disable file_length
struct ContentDatabase {
private let databaseQueue: DatabaseQueue
init(identityID: UUID, inMemory: Bool = false) throws {
guard
let documentsDirectory = NSSearchPathForDirectoriesInDomains(
.documentDirectory,
.userDomainMask, true)
.first
else { throw DatabaseError.documentsDirectoryNotFound }
if inMemory {
databaseQueue = DatabaseQueue()
} else {
databaseQueue = try DatabaseQueue(path: "\(documentsDirectory)/\(identityID.uuidString).sqlite3")
}
try Self.migrate(databaseQueue)
try Self.createTemporaryTables(databaseQueue)
}
}
extension ContentDatabase {
func insert(statuses: [Status], collection: StatusCollection? = nil) -> AnyPublisher<Void, Error> {
databaseQueue.writePublisher {
try collection?.save($0)
for status in statuses {
for component in status.storedComponents() {
try component.save($0)
}
try collection?.joinRecord(status: status).save($0)
}
}
.eraseToAnyPublisher()
}
func statusesObservation(timeline: Timeline) -> AnyPublisher<[Status], Error> {
ValueObservation
.tracking(timeline.statuses
.including(required: StoredStatus.account)
.including(optional: StoredStatus.reblogAccount)
.including(optional: StoredStatus.reblog)
.asRequest(of: StatusResult.self)
.fetchAll)
.removeDuplicates()
.publisher(in: databaseQueue)
.map { $0.map(Status.init(statusResult:)) }
.eraseToAnyPublisher()
}
}
private extension ContentDatabase {
// swiftlint:disable function_body_length
static func migrate(_ writer: DatabaseWriter) throws {
var migrator = DatabaseMigrator()
migrator.registerMigration("createStatuses") { db in
try db.create(table: "account", ifNotExists: true) { t in
t.column("id", .text).notNull().primaryKey(onConflict: .replace)
t.column("username", .text).notNull()
t.column("acct", .text).notNull()
t.column("displayName", .text).notNull()
t.column("locked", .boolean).notNull()
t.column("createdAt", .date).notNull()
t.column("followersCount", .integer).notNull()
t.column("followingCount", .integer).notNull()
t.column("statusesCount", .integer).notNull()
t.column("note", .text).notNull()
t.column("url", .text).notNull()
t.column("avatar", .text).notNull()
t.column("avatarStatic", .text).notNull()
t.column("header", .text).notNull()
t.column("headerStatic", .text).notNull()
t.column("fields", .blob).notNull()
t.column("emojis", .blob).notNull()
t.column("bot", .boolean).notNull()
t.column("moved", .boolean)
t.column("discoverable", .boolean)
}
try db.create(table: "storedStatus", ifNotExists: true) { t in
t.column("id", .text).notNull().primaryKey(onConflict: .replace)
t.column("uri", .text).notNull()
t.column("createdAt", .datetime).notNull()
t.column("accountId", .text).indexed().notNull().references("account", column: "id")
t.column("content", .text).notNull()
t.column("visibility", .text).notNull()
t.column("sensitive", .boolean).notNull()
t.column("spoilerText", .text).notNull()
t.column("mediaAttachments", .blob).notNull()
t.column("mentions", .blob).notNull()
t.column("tags", .blob).notNull()
t.column("emojis", .blob).notNull()
t.column("reblogsCount", .integer).notNull()
t.column("favouritesCount", .integer).notNull()
t.column("repliesCount", .integer).notNull()
t.column("application", .blob)
t.column("url", .text)
t.column("inReplyToId", .text)
t.column("inReplyToAccountId", .text)
t.column("reblogId", .text).indexed().references("storedStatus", column: "id")
t.column("poll", .blob)
t.column("card", .blob)
t.column("language", .text)
t.column("text", .text)
t.column("favourited", .boolean)
t.column("reblogged", .boolean)
t.column("muted", .boolean)
t.column("bookmarked", .boolean)
t.column("pinned", .boolean)
}
try db.create(table: "timeline", ifNotExists: true) { t in
t.column("id", .text).notNull().primaryKey(onConflict: .replace)
t.column("listTitle", .text)
}
try db.create(table: "timelineStatusJoin", ifNotExists: true) { t in
t.column("timelineId", .text)
.indexed()
.notNull()
.references("timeline", column: "id", onDelete: .cascade, onUpdate: .cascade)
t.column("statusId", .text)
.indexed()
.notNull()
.references("storedStatus", column: "id", onDelete: .cascade, onUpdate: .cascade)
t.primaryKey(["timelineId", "statusId"], onConflict: .replace)
}
}
try migrator.migrate(writer)
}
// swiftlint:enable function_body_length
static func createTemporaryTables(_ writer: DatabaseWriter) throws {
try writer.write { database in
try database.create(table: "transientStatusCollection", temporary: true, ifNotExists: true) { t in
t.column("id", .text).notNull().primaryKey(onConflict: .replace)
}
try database.create(table: "transientStatusCollectionElement", temporary: true, ifNotExists: true) { t in
t.column("transientStatusCollectionId", .text)
.notNull()
.references("transientStatusCollection", column: "id", onDelete: .cascade, onUpdate: .cascade)
t.column("statusId", .text).notNull()
t.primaryKey(["transientStatusCollectionId", "statusId"], onConflict: .replace)
}
}
}
}
extension Account: TableRecord, FetchableRecord, PersistableRecord {
static func databaseJSONDecoder(for column: String) -> JSONDecoder {
MastodonDecoder()
}
static func databaseJSONEncoder(for column: String) -> JSONEncoder {
MastodonEncoder()
}
}
protocol StatusCollection: FetchableRecord, PersistableRecord {
var id: String { get }
func joinRecord(status: Status) -> PersistableRecord
}
private struct TimelineStatusJoin: Codable, TableRecord, FetchableRecord, PersistableRecord {
let timelineId: String
let statusId: String
static let status = belongsTo(StoredStatus.self)
}
extension Timeline: StatusCollection {
enum Columns: String, ColumnExpression {
case id, listTitle
}
init(row: Row) {
switch row[Columns.id] as String {
case Timeline.home.id:
self = .home
case Timeline.local.id:
self = .local
case Timeline.federated.id:
self = .federated
default:
self = .list(MastodonList(id: row[Columns.id], title: row[Columns.listTitle]))
}
}
func encode(to container: inout PersistenceContainer) {
container[Columns.id] = id
if case let .list(list) = self {
container[Columns.listTitle] = list.title
}
}
func joinRecord(status: Status) -> PersistableRecord {
TimelineStatusJoin(timelineId: id, statusId: status.id)
}
}
private extension Timeline {
static let statusJoins = hasMany(TimelineStatusJoin.self)
static let statuses = hasMany(StoredStatus.self,
through: statusJoins,
using: TimelineStatusJoin.status).order(Column("createdAt").desc)
var statusJoins: QueryInterfaceRequest<TimelineStatusJoin> {
request(for: Self.statusJoins)
}
var statuses: QueryInterfaceRequest<StoredStatus> {
request(for: Self.statuses)
}
}
private struct TransientStatusCollectionElement: Codable, TableRecord, FetchableRecord, PersistableRecord {
let transientStatusCollectionId: String
let statusId: String
static let status = belongsTo(StoredStatus.self, key: "statusId")
}
extension TransientStatusCollection: StatusCollection {
func joinRecord(status: Status) -> PersistableRecord {
TransientStatusCollectionElement(transientStatusCollectionId: id, statusId: status.id)
}
}
private extension TransientStatusCollection {
static let elements = hasMany(TransientStatusCollectionElement.self)
var elements: QueryInterfaceRequest<TransientStatusCollectionElement> {
request(for: Self.elements)
}
}
private struct StoredStatus: Codable, Hashable {
let id: String
let uri: String
let createdAt: Date
let accountId: String
let content: String
let visibility: Status.Visibility
let sensitive: Bool
let spoilerText: String
let mediaAttachments: [Attachment]
let mentions: [Mention]
let tags: [Tag]
let emojis: [Emoji]
let reblogsCount: Int
let favouritesCount: Int
let repliesCount: Int
let application: Application?
let url: URL?
let inReplyToId: String?
let inReplyToAccountId: String?
let reblogId: String?
let poll: Poll?
let card: Card?
let language: String?
let text: String?
let favourited: Bool?
let reblogged: Bool?
let muted: Bool?
let bookmarked: Bool?
let pinned: Bool?
}
private extension StoredStatus {
static let account = belongsTo(Account.self, key: "account")
static let reblogAccount = hasOne(Account.self, through: Self.reblog, using: Self.account, key: "reblogAccount")
static let reblog = belongsTo(StoredStatus.self, key: "reblog")
var account: QueryInterfaceRequest<Account> {
request(for: Self.account)
}
var reblogAccount: QueryInterfaceRequest<Account> {
request(for: Self.reblogAccount)
}
var reblog: QueryInterfaceRequest<StoredStatus> {
request(for: Self.reblog)
}
init(status: Status) {
id = status.id
uri = status.uri
createdAt = status.createdAt
accountId = status.account.id
content = status.content
visibility = status.visibility
sensitive = status.sensitive
spoilerText = status.spoilerText
mediaAttachments = status.mediaAttachments
mentions = status.mentions
tags = status.tags
emojis = status.emojis
reblogsCount = status.reblogsCount
favouritesCount = status.favouritesCount
repliesCount = status.repliesCount
application = status.application
url = status.url
inReplyToId = status.inReplyToId
inReplyToAccountId = status.inReplyToAccountId
reblogId = status.reblog?.id
poll = status.poll
card = status.card
language = status.language
text = status.text
favourited = status.favourited
reblogged = status.reblogged
muted = status.muted
bookmarked = status.bookmarked
pinned = status.pinned
}
}
extension StoredStatus: TableRecord, FetchableRecord, PersistableRecord {
static func databaseJSONDecoder(for column: String) -> JSONDecoder {
MastodonDecoder()
}
static func databaseJSONEncoder(for column: String) -> JSONEncoder {
MastodonEncoder()
}
}
private struct StatusResult: Codable, Hashable, FetchableRecord {
let account: Account
let status: StoredStatus
let reblogAccount: Account?
let reblog: StoredStatus?
}
private extension Status {
func storedComponents() -> [PersistableRecord] {
var components: [PersistableRecord] = [account]
if let reblog = reblog {
components.append(reblog.account)
components.append(StoredStatus(status: reblog))
}
components.append(StoredStatus(status: self))
return components
}
convenience init(statusResult: StatusResult) {
var reblog: Status?
if let reblogResult = statusResult.reblog, let reblogAccount = statusResult.reblogAccount {
reblog = Status(storedStatus: reblogResult, account: reblogAccount, reblog: nil)
}
self.init(storedStatus: statusResult.status, account: statusResult.account, reblog: reblog)
}
convenience init(storedStatus: StoredStatus, account: Account, reblog: Status?) {
self.init(
id: storedStatus.id,
uri: storedStatus.uri,
createdAt: storedStatus.createdAt,
account: account,
content: storedStatus.content,
visibility: storedStatus.visibility,
sensitive: storedStatus.sensitive,
spoilerText: storedStatus.spoilerText,
mediaAttachments: storedStatus.mediaAttachments,
mentions: storedStatus.mentions,
tags: storedStatus.tags,
emojis: storedStatus.emojis,
reblogsCount: storedStatus.reblogsCount,
favouritesCount: storedStatus.favouritesCount,
repliesCount: storedStatus.repliesCount,
application: storedStatus.application,
url: storedStatus.url,
inReplyToId: storedStatus.inReplyToId,
inReplyToAccountId: storedStatus.inReplyToAccountId,
reblog: reblog,
poll: storedStatus.poll,
card: storedStatus.card,
language: storedStatus.language,
text: storedStatus.text,
favourited: storedStatus.favourited,
reblogged: storedStatus.reblogged,
muted: storedStatus.muted,
bookmarked: storedStatus.bookmarked,
pinned: storedStatus.pinned)
}
}
// swiftlint:enable file_length

View file

@ -6,12 +6,15 @@ struct AppEnvironment {
let session: Session
let webAuthSessionType: WebAuthSession.Type
let keychainServiceType: KeychainService.Type
let userDefaults: UserDefaults = .standard
let userDefaults: UserDefaults
let inMemoryContent: Bool
}
extension AppEnvironment {
static let live: Self = Self(
session: Session(configuration: .default),
webAuthSessionType: LiveWebAuthSession.self,
keychainServiceType: LiveKeychainService.self)
keychainServiceType: LiveKeychainService.self,
userDefaults: .standard,
inMemoryContent: false)
}

View file

@ -2,7 +2,7 @@
import Foundation
struct Application: Codable {
struct Application: Codable, Hashable {
let name: String
let website: String
let website: String?
}

View file

@ -0,0 +1,43 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
struct Attachment: Codable, Hashable {
enum AttachmentType: String, Codable, Hashable, Unknowable {
case image, video, gifv, audio, unknown
static var unknownCase: Self { .unknown }
}
// swiftlint:disable nesting
struct Meta: Codable, Hashable {
struct Info: Codable, Hashable {
let width: Int?
let height: Int?
let size: String?
let aspect: Double?
let frameRate: String?
let duration: Double?
let bitrate: Int?
}
struct Focus: Codable, Hashable {
let x: Double
let y: Double
}
let original: Info?
let small: Info?
let focus: Focus?
}
// swiftlint:enable nesting
let id: String
let type: AttachmentType
let url: URL
let remoteUrl: URL?
let previewUrl: URL
let textUrl: URL?
let meta: Meta?
let description: String?
}

25
Shared/Model/Card.swift Normal file
View file

@ -0,0 +1,25 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
struct Card: Codable, Hashable {
enum CardType: String, Codable, Hashable, Unknowable {
case link, photo, video, rich, unknown
static var unknownCase: Self { .unknown }
}
let url: URL
let title: String
let description: String
let type: CardType
let authorName: String?
let authorUrl: String?
let providerName: String?
let providerUrl: String?
let html: String?
let width: Int?
let height: Int?
let image: URL?
let embedUrl: String?
}

View file

@ -0,0 +1,8 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
struct MastodonList: Codable, Hashable, Identifiable {
let id: String
let title: String
}

View file

@ -0,0 +1,10 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
struct Mention: Codable, Hashable {
let url: URL
let username: String
let acct: String
let id: String
}

21
Shared/Model/Poll.swift Normal file
View file

@ -0,0 +1,21 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
struct Poll: Codable, Hashable {
struct Option: Codable, Hashable {
var title: String
var votesCount: Int
}
let id: String
let expiresAt: Date
let expired: Bool
let multiple: Bool
let votesCount: Int
let votersCount: Int?
let voted: Bool?
let ownVotes: [Int]?
let options: [Option]
let emojis: [Emoji]
}

View file

@ -2,7 +2,7 @@
import Foundation
struct Status {
class Status: Codable, Identifiable {
enum Visibility: String, Codable, Unknowable {
case `public`
case unlisted
@ -12,4 +12,96 @@ struct Status {
static var unknownCase: Self { .unknown }
}
let id: String
let uri: String
let createdAt: Date
let account: Account
let content: String
let visibility: Visibility
let sensitive: Bool
let spoilerText: String
let mediaAttachments: [Attachment]
let mentions: [Mention]
let tags: [Tag]
let emojis: [Emoji]
let reblogsCount: Int
let favouritesCount: Int
let repliesCount: Int
let application: Application?
let url: URL?
let inReplyToId: String?
let inReplyToAccountId: String?
let reblog: Status?
let poll: Poll?
let card: Card?
let language: String?
let text: String?
let favourited: Bool?
let reblogged: Bool?
let muted: Bool?
let bookmarked: Bool?
let pinned: Bool?
// Xcode-generated memberwise initializer
init(
id: String,
uri: String,
createdAt: Date,
account: Account,
content: String,
visibility: Status.Visibility,
sensitive: Bool,
spoilerText: String,
mediaAttachments: [Attachment],
mentions: [Mention],
tags: [Tag],
emojis: [Emoji],
reblogsCount: Int,
favouritesCount: Int,
repliesCount: Int,
application: Application?,
url: URL?,
inReplyToId: String?,
inReplyToAccountId: String?,
reblog: Status?,
poll: Poll?,
card: Card?,
language: String?,
text: String?,
favourited: Bool?,
reblogged: Bool?,
muted: Bool?,
bookmarked: Bool?,
pinned: Bool?) {
self.id = id
self.uri = uri
self.createdAt = createdAt
self.account = account
self.content = content
self.visibility = visibility
self.sensitive = sensitive
self.spoilerText = spoilerText
self.mediaAttachments = mediaAttachments
self.mentions = mentions
self.tags = tags
self.emojis = emojis
self.reblogsCount = reblogsCount
self.favouritesCount = favouritesCount
self.repliesCount = repliesCount
self.application = application
self.url = url
self.inReplyToId = inReplyToId
self.inReplyToAccountId = inReplyToAccountId
self.reblog = reblog
self.poll = poll
self.card = card
self.language = language
self.text = text
self.favourited = favourited
self.reblogged = reblogged
self.muted = muted
self.bookmarked = bookmarked
self.pinned = pinned
}
}

8
Shared/Model/Tag.swift Normal file
View file

@ -0,0 +1,8 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
struct Tag: Codable, Hashable {
let name: String
let url: URL
}

View file

@ -0,0 +1,38 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
enum Timeline {
case home
case local
case federated
case list(MastodonList)
}
extension Timeline {
var id: String {
switch self {
case .home:
return "home"
case .local:
return "local"
case .federated:
return "federated"
case let .list(list):
return list.id
}
}
var endpoint: TimelinesEndpoint {
switch self {
case .home:
return .home
case .local:
return .public(local: true)
case .federated:
return .public(local: false)
case let .list(list):
return .list(id: list.id)
}
}
}

View file

@ -2,6 +2,6 @@
import Foundation
class TimelineViewModel: ObservableObject {
struct TransientStatusCollection: Codable {
let id: String
}

View file

@ -0,0 +1,42 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
enum TimelinesEndpoint {
case `public`(local: Bool)
case tag(String)
case home
case list(id: String)
}
extension TimelinesEndpoint: MastodonEndpoint {
typealias ResultType = [Status]
var context: [String] {
defaultContext + ["timelines"]
}
var pathComponentsInContext: [String] {
switch self {
case .public:
return ["public"]
case let .tag(tag):
return ["tag", tag]
case .home:
return ["home"]
case let .list(id):
return ["list", id]
}
}
var parameters: [String: Any]? {
switch self {
case let .public(local):
return ["local": local]
default:
return nil
}
}
var method: HTTPMethod { .get }
}

View file

@ -8,6 +8,7 @@ class IdentityService {
let observationErrors: AnyPublisher<Error, Never>
private let identityDatabase: IdentityDatabase
private let contentDatabase: ContentDatabase
private let environment: AppEnvironment
private let networkClient: MastodonClient
private let secretsService: SecretsService
@ -37,6 +38,8 @@ class IdentityService {
networkClient.instanceURL = identity.url
networkClient.accessToken = try? secretsService.item(.accessToken)
contentDatabase = try ContentDatabase(identityID: identityID, inMemory: environment.inMemoryContent)
observation.catch { [weak self] error -> Empty<Identity, Never> in
self?.observationErrorsInput.send(error)
@ -127,6 +130,10 @@ extension IdentityService {
.flatMap(identityDatabase.updatePushSubscription(alerts:deviceToken:forIdentityID:))
.eraseToAnyPublisher()
}
func service(timeline: Timeline) -> StatusListService {
TimelineService(timeline: timeline, networkClient: networkClient, contentDatabase: contentDatabase)
}
}
private extension IdentityService {

View file

@ -0,0 +1,9 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
import Combine
protocol StatusListService {
var statusSections: AnyPublisher<[[Status]], Error> { get }
func request(maxID: String?, minID: String?) -> AnyPublisher<Void, Error>
}

View file

@ -0,0 +1,28 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
import Combine
struct TimelineService: StatusListService {
let statusSections: AnyPublisher<[[Status]], Error>
private let timeline: Timeline
private let networkClient: MastodonClient
private let contentDatabase: ContentDatabase
init(timeline: Timeline, networkClient: MastodonClient, contentDatabase: ContentDatabase) {
self.timeline = timeline
self.networkClient = networkClient
self.contentDatabase = contentDatabase
statusSections = contentDatabase.statusesObservation(timeline: timeline)
.map { [$0] }
.eraseToAnyPublisher()
}
func request(maxID: String?, minID: String?) -> AnyPublisher<Void, Error> {
return networkClient.request(timeline.endpoint)
.map { ($0, timeline) }
.flatMap(contentDatabase.insert(statuses:collection:))
.eraseToAnyPublisher()
}
}

View file

@ -0,0 +1,28 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
import Combine
class StatusesViewModel: ObservableObject {
@Published var statusSections = [[Status]]()
@Published var alertItem: AlertItem?
private let statusListService: StatusListService
private var cancellables = Set<AnyCancellable>()
init(statusListService: StatusListService) {
self.statusListService = statusListService
statusListService.statusSections
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.assign(to: &$statusSections)
}
}
extension StatusesViewModel {
func request(maxID: String? = nil, minID: String? = nil) {
statusListService.request(maxID: maxID, minID: minID)
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.sink {}
.store(in: &cancellables)
}
}

View file

@ -0,0 +1,31 @@
// Copyright © 2020 Metabolist. All rights reserved.
import SwiftUI
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()
}
}
}
}
.onAppear { viewModel.request() }
.alertItem($viewModel.alertItem)
}
}
#if DEBUG
struct StatusesView_Previews: PreviewProvider {
static var previews: some View {
StatusesView(viewModel: .development)
}
}
#endif

View file

@ -1,17 +0,0 @@
// Copyright © 2020 Metabolist. All rights reserved.
import SwiftUI
struct TimelineView: View {
var body: some View {
Text("Time of my life")
}
}
#if DEBUG
struct TimelineView_Previews: PreviewProvider {
static var previews: some View {
TimelineView()
}
}
#endif

View file

@ -6,6 +6,7 @@ import Combine
class TabNavigationViewModel: ObservableObject {
@Published private(set) var identity: Identity
@Published private(set) var recentIdentities = [Identity]()
@Published private(set) var timelineViewModel: StatusesViewModel
@Published var presentingSecondaryNavigation = false
@Published var alertItem: AlertItem?
var selectedTab: Tab? = .timelines
@ -16,6 +17,7 @@ class TabNavigationViewModel: ObservableObject {
init(identityService: IdentityService) {
self.identityService = identityService
identity = identityService.identity
timelineViewModel = StatusesViewModel(statusListService: identityService.service(timeline: .home))
identityService.$identity.dropFirst().assign(to: &$identity)
identityService.recentIdentitiesObservation()

View file

@ -40,7 +40,7 @@ private extension TabNavigationView {
func view(tab: TabNavigationViewModel.Tab) -> some View {
switch tab {
case .timelines:
TimelineView()
StatusesView(viewModel: viewModel.timelineViewModel)
.navigationBarTitle(viewModel.identity.handle, displayMode: .inline)
.navigationBarItems(
leading: Button {

View file

@ -5,6 +5,7 @@ import Combine
class SidebarNavigationViewModel: ObservableObject {
@Published private(set) var identity: Identity
@Published private(set) var timelineViewModel: StatusesViewModel
@Published var alertItem: AlertItem?
var selectedTab: Tab? = .timelines
@ -14,6 +15,7 @@ class SidebarNavigationViewModel: ObservableObject {
init(identityService: IdentityService) {
self.identityService = identityService
identity = identityService.identity
timelineViewModel = StatusesViewModel(statusListService: identityService.service(timeline: .home))
identityService.$identity.dropFirst().assign(to: &$identity)
}
}

View file

@ -39,7 +39,7 @@ private extension SidebarNavigationView {
Group {
switch topLevelNavigation {
case .timelines:
TimelineView()
StatusesView(viewModel: viewModel.timelineViewModel)
default: Text(topLevelNavigation.title)
}
}