This commit is contained in:
Justin Mazzocchi 2020-08-28 20:50:58 -07:00
parent e5d7b0a12b
commit b80fd9146a
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
16 changed files with 306 additions and 32 deletions

View file

@ -45,6 +45,30 @@ extension ContentDatabase {
.eraseToAnyPublisher()
}
func updateLists(_ lists: [MastodonList]) -> AnyPublisher<Never, Error> {
databaseQueue.writePublisher {
for list in lists {
try Timeline.list(list).save($0)
}
try Timeline.filter(!(Timeline.nonLists.map(\.id) + lists.map(\.id)).contains(Column("id"))).deleteAll($0)
}
.ignoreOutput()
.eraseToAnyPublisher()
}
func createList(_ list: MastodonList) -> AnyPublisher<Never, Error> {
databaseQueue.writePublisher(updates: Timeline.list(list).save)
.ignoreOutput()
.eraseToAnyPublisher()
}
func deleteList(id: String) -> AnyPublisher<Never, Error> {
databaseQueue.writePublisher(updates: Timeline.filter(Column("id") == id).deleteAll)
.ignoreOutput()
.eraseToAnyPublisher()
}
func statusesObservation(timeline: Timeline) -> AnyPublisher<[Status], Error> {
ValueObservation
.tracking(timeline.statuses
@ -78,6 +102,15 @@ extension ContentDatabase {
.map { $0.map(Status.init(statusResult:)) }
.eraseToAnyPublisher()
}
func listsObservation() -> AnyPublisher<[Timeline], Error> {
ValueObservation.tracking(Timeline.filter(!Timeline.nonLists.map(\.id).contains(Column("id")))
.order(Column("listTitle").collating(.localizedCaseInsensitiveCompare).asc)
.fetchAll)
.removeDuplicates()
.publisher(in: databaseQueue)
.eraseToAnyPublisher()
}
}
private extension ContentDatabase {

View file

@ -98,6 +98,10 @@ extension IdentitiesViewModel {
static let development = IdentitiesViewModel(identityService: .development)
}
extension ListsViewModel {
static let development = ListsViewModel(identityService: .development)
}
extension PreferencesViewModel {
static let development = PreferencesViewModel(identityService: .development)
}

View file

@ -1,13 +1,15 @@
// Copyright © 2020 Metabolist. All rights reserved.
"add" = "Add";
"apns-default-message" = "New notification";
"add-identity.instance-url" = "Instance URL";
"add-identity.log-in" = "Log in";
"add-identity.browse-anonymously" = "Browse anonymously";
"oauth.error.code-not-found" = "OAuth error: code not found";
"secondary-navigation.manage-accounts" = "Manage Accounts";
"secondary-navigation.lists" = "Lists";
"secondary-navigation.preferences" = "Preferences";
"identities.add" = "Add";
"lists.new-list-title" = "New List Title";
"preferences" = "Preferences";
"preferences.posting-reading" = "Posting and Reading";
"preferences.posting" = "Posting";

View file

@ -29,6 +29,10 @@
D0BEB1F324F8EE8C001B0F04 /* AttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEB1F224F8EE8C001B0F04 /* AttachmentView.swift */; };
D0BEB1F524F9A216001B0F04 /* Paged.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEB1F424F9A216001B0F04 /* Paged.swift */; };
D0BEB1F724F9A84B001B0F04 /* LoadingTableFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEB1F624F9A84B001B0F04 /* LoadingTableFooterView.swift */; };
D0BEB1F924F9D627001B0F04 /* ListsEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEB1F824F9D627001B0F04 /* ListsEndpoint.swift */; };
D0BEB1FD24F9E4E5001B0F04 /* ListsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEB1FC24F9E4E5001B0F04 /* ListsViewModel.swift */; };
D0BEB1FF24F9E5BB001B0F04 /* ListsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEB1FE24F9E5BB001B0F04 /* ListsView.swift */; };
D0BEB20124FA0220001B0F04 /* ListEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEB20024FA0220001B0F04 /* ListEndpoint.swift */; };
D0C7D49724F7616A001EBDBB /* IdentitiesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42224F76169001EBDBB /* IdentitiesView.swift */; };
D0C7D49824F7616A001EBDBB /* CustomEmojiText.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42324F76169001EBDBB /* CustomEmojiText.swift */; };
D0C7D49924F7616A001EBDBB /* AddIdentityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42424F76169001EBDBB /* AddIdentityView.swift */; };
@ -198,6 +202,10 @@
D0BEB1F224F8EE8C001B0F04 /* AttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentView.swift; sourceTree = "<group>"; };
D0BEB1F424F9A216001B0F04 /* Paged.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Paged.swift; sourceTree = "<group>"; };
D0BEB1F624F9A84B001B0F04 /* LoadingTableFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingTableFooterView.swift; sourceTree = "<group>"; };
D0BEB1F824F9D627001B0F04 /* ListsEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListsEndpoint.swift; sourceTree = "<group>"; };
D0BEB1FC24F9E4E5001B0F04 /* ListsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListsViewModel.swift; sourceTree = "<group>"; };
D0BEB1FE24F9E5BB001B0F04 /* ListsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListsView.swift; sourceTree = "<group>"; };
D0BEB20024FA0220001B0F04 /* ListEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListEndpoint.swift; sourceTree = "<group>"; };
D0C7D41E24F76169001EBDBB /* Metatext.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Metatext.entitlements; sourceTree = "<group>"; };
D0C7D41F24F76169001EBDBB /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
D0C7D42224F76169001EBDBB /* IdentitiesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IdentitiesView.swift; sourceTree = "<group>"; };
@ -424,6 +432,7 @@
D01F41E024F8885900D55A2D /* Attachments */,
D0C7D42324F76169001EBDBB /* CustomEmojiText.swift */,
D0C7D42224F76169001EBDBB /* IdentitiesView.swift */,
D0BEB1FE24F9E5BB001B0F04 /* ListsView.swift */,
D0BEB1F624F9A84B001B0F04 /* LoadingTableFooterView.swift */,
D0C7D42D24F76169001EBDBB /* NotificationTypesPreferencesView.swift */,
D0C7D42824F76169001EBDBB /* PostingReadingPreferencesView.swift */,
@ -509,6 +518,7 @@
D0C7D46024F76169001EBDBB /* AddIdentityViewModel.swift */,
D01F41DE24F8868800D55A2D /* AttachmentViewModel.swift */,
D0C7D45F24F76169001EBDBB /* IdentitiesViewModel.swift */,
D0BEB1FC24F9E4E5001B0F04 /* ListsViewModel.swift */,
D0C7D45D24F76169001EBDBB /* NotificationTypesPreferencesViewModel.swift */,
D0C7D45A24F76169001EBDBB /* PostingReadingPreferencesViewModel.swift */,
D0C7D46124F76169001EBDBB /* PreferencesViewModel.swift */,
@ -582,6 +592,8 @@
D0C7D48324F76169001EBDBB /* ContextEndpoint.swift */,
D0C7D48224F76169001EBDBB /* DeletionEndpoint.swift */,
D0C7D47D24F76169001EBDBB /* InstanceEndpoint.swift */,
D0BEB20024FA0220001B0F04 /* ListEndpoint.swift */,
D0BEB1F824F9D627001B0F04 /* ListsEndpoint.swift */,
D0BEB1F424F9A216001B0F04 /* Paged.swift */,
D0C7D47C24F76169001EBDBB /* PreferencesEndpoint.swift */,
D0C7D47B24F76169001EBDBB /* PushSubscriptionEndpoint.swift */,
@ -908,6 +920,7 @@
D0C7D4B324F7616A001EBDBB /* MastodonError.swift in Sources */,
D0C7D4E924F7616A001EBDBB /* AccessTokenEndpoint.swift in Sources */,
D0C7D4D524F7616A001EBDBB /* String+Extensions.swift in Sources */,
D0BEB20124FA0220001B0F04 /* ListEndpoint.swift in Sources */,
D0C7D4BA24F7616A001EBDBB /* AppAuthorization.swift in Sources */,
D0C7D4AB24F7616A001EBDBB /* Identity.swift in Sources */,
D0C7D4C024F7616A001EBDBB /* AlertItem.swift in Sources */,
@ -925,6 +938,7 @@
D0C7D4DC24F7616A001EBDBB /* Data+Extensions.swift in Sources */,
D0DC177724D0CF2600A75C65 /* MockKeychainService.swift in Sources */,
D0DC174624CFEC2000A75C65 /* StubbingURLProtocol.swift in Sources */,
D0BEB1FF24F9E5BB001B0F04 /* ListsView.swift in Sources */,
D0C7D4F824F7616A001EBDBB /* SecretsService.swift in Sources */,
D0C7D4DE24F7616A001EBDBB /* HTTPTarget.swift in Sources */,
D0C7D4F624F7616A001EBDBB /* KeychainService.swift in Sources */,
@ -946,6 +960,7 @@
D074577A24D29366004758DB /* URLSessionConfiguration+Extensions.swift in Sources */,
D0C7D4E424F7616A001EBDBB /* PreferencesEndpoint.swift in Sources */,
D0C7D49924F7616A001EBDBB /* AddIdentityView.swift in Sources */,
D0BEB1FD24F9E4E5001B0F04 /* ListsViewModel.swift in Sources */,
D0C7D4C324F7616A001EBDBB /* MetatextApp.swift in Sources */,
D0C7D4B124F7616A001EBDBB /* Card.swift in Sources */,
D0C7D4F324F7616A001EBDBB /* ContextService.swift in Sources */,
@ -959,6 +974,7 @@
D0C7D4EC24F7616A001EBDBB /* StatusEndpoint.swift in Sources */,
D0C7D4D724F7616A001EBDBB /* UIColor+Extensions.swift in Sources */,
D0C7D4BB24F7616A001EBDBB /* Emoji.swift in Sources */,
D0BEB1F924F9D627001B0F04 /* ListsEndpoint.swift in Sources */,
D0C7D4AD24F7616A001EBDBB /* AccessToken.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;

View file

@ -2,7 +2,7 @@
import Foundation
enum Timeline: Identifiable {
enum Timeline: Hashable {
case home
case local
case federated
@ -10,18 +10,7 @@ enum Timeline: Identifiable {
}
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
}
}
static let nonLists: [Timeline] = [.home, .local, .federated]
var endpoint: TimelinesEndpoint {
switch self {
@ -36,3 +25,18 @@ extension Timeline {
}
}
}
extension Timeline: Identifiable {
var id: String {
switch self {
case .home:
return "home"
case .local:
return "local"
case .federated:
return "federated"
case let .list(list):
return list.id
}
}
}

View file

@ -4,6 +4,7 @@ import Foundation
enum DeletionEndpoint {
case oauthRevoke(token: String, clientID: String, clientSecret: String)
case list(id: String)
}
extension DeletionEndpoint: MastodonEndpoint {
@ -12,14 +13,18 @@ extension DeletionEndpoint: MastodonEndpoint {
var context: [String] {
switch self {
case .oauthRevoke:
return []
return ["oauth"]
case .list:
return defaultContext + ["lists"]
}
}
var pathComponentsInContext: [String] {
switch self {
case .oauthRevoke:
return ["oauth", "revoke"]
return ["revoke"]
case let .list(id):
return [id]
}
}
@ -27,6 +32,8 @@ extension DeletionEndpoint: MastodonEndpoint {
switch self {
case .oauthRevoke:
return .post
case .list:
return .delete
}
}
@ -34,6 +41,8 @@ extension DeletionEndpoint: MastodonEndpoint {
switch self {
case let .oauthRevoke(token, clientID, clientSecret):
return ["token": token, "client_id": clientID, "client_secret": clientSecret]
case .list:
return nil
}
}
}

View file

@ -0,0 +1,36 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
enum ListEndpoint {
case create(title: String)
}
extension ListEndpoint: MastodonEndpoint {
typealias ResultType = MastodonList
var context: [String] {
defaultContext + ["lists"]
}
var pathComponentsInContext: [String] {
switch self {
case .create:
return []
}
}
var parameters: [String : Any]? {
switch self {
case let .create(title):
return ["title": title]
}
}
var method: HTTPMethod {
switch self {
case .create:
return .post
}
}
}

View file

@ -0,0 +1,19 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
enum ListsEndpoint {
case lists
}
extension ListsEndpoint: MastodonEndpoint {
typealias ResultType = [MastodonList]
var pathComponentsInContext: [String] {
["lists"]
}
var method: HTTPMethod {
.get
}
}

View file

@ -86,6 +86,29 @@ extension IdentityService {
identityDatabase.recentIdentitiesObservation(excluding: identity.id)
}
func refreshLists() -> AnyPublisher<Never, Error> {
networkClient.request(ListsEndpoint.lists)
.flatMap(contentDatabase.updateLists(_:))
.eraseToAnyPublisher()
}
func listsObservation() -> AnyPublisher<[Timeline], Error> {
contentDatabase.listsObservation()
}
func createList(title: String) -> AnyPublisher<Never, Error> {
networkClient.request(ListEndpoint.create(title: title))
.flatMap(contentDatabase.createList(_:))
.eraseToAnyPublisher()
}
func deleteList(id: String) -> AnyPublisher<Never, Error> {
networkClient.request(DeletionEndpoint.list(id: id))
.map { _ in id }
.flatMap(contentDatabase.deleteList(id:))
.eraseToAnyPublisher()
}
func updatePreferences(_ preferences: Identity.Preferences) -> AnyPublisher<Never, Error> {
identityDatabase.updatePreferences(preferences, forIdentityID: identity.id)
.zip(Just(self).first().setFailureType(to: Error.self))

View file

@ -0,0 +1,54 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
import Combine
class ListsViewModel: ObservableObject {
@Published private(set) var lists = [MastodonList]()
@Published private(set) var creatingList = false
@Published var alertItem: AlertItem?
private let identityService: IdentityService
private var cancellables = Set<AnyCancellable>()
init(identityService: IdentityService) {
self.identityService = identityService
identityService.listsObservation()
.map {
$0.compactMap {
guard case let .list(list) = $0 else { return nil }
return list
}
}
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.assign(to: &$lists)
}
}
extension ListsViewModel {
func refreshLists() {
identityService.refreshLists()
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.sink { _ in }
.store(in: &cancellables)
}
func createList(title: String) {
identityService.createList(title: title)
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.handleEvents(
receiveSubscription: { [weak self] _ in self?.creatingList = true },
receiveCompletion: { [weak self] _ in self?.creatingList = false })
.sink { _ in }
.store(in: &cancellables)
}
func delete(list: MastodonList) {
identityService.deleteList(id: list.id)
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.sink { _ in }
.store(in: &cancellables)
}
}

View file

@ -18,6 +18,10 @@ extension SecondaryNavigationViewModel {
IdentitiesViewModel(identityService: identityService)
}
func listsViewModel() -> ListsViewModel {
ListsViewModel(identityService: identityService)
}
func preferencesViewModel() -> PreferencesViewModel {
PreferencesViewModel(identityService: identityService)
}

View file

@ -6,8 +6,8 @@ import Combine
class TabNavigationViewModel: ObservableObject {
@Published private(set) var identity: Identity
@Published private(set) var recentIdentities = [Identity]()
@Published private(set) var timeline = Timeline.home
@Published private(set) var timelinesAndLists = TabNavigationViewModel.timelines
@Published var timeline = Timeline.home
@Published private(set) var timelinesAndLists = Timeline.nonLists
@Published var presentingSecondaryNavigation = false
@Published var alertItem: AlertItem?
var selectedTab: Tab? = .timelines
@ -23,6 +23,11 @@ class TabNavigationViewModel: ObservableObject {
identityService.recentIdentitiesObservation()
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.assign(to: &$recentIdentities)
identityService.listsObservation()
.map { Timeline.nonLists + $0 }
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.assign(to: &$timelinesAndLists)
}
}
@ -65,6 +70,11 @@ extension TabNavigationViewModel {
.sink { _ in }
.store(in: &cancellables)
identityService.refreshLists()
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.sink { _ in }
.store(in: &cancellables)
if identity.preferences.useServerPostingReadingPreferences {
identityService.refreshServerPreferences()
.assignErrorsToAlertItem(to: \.alertItem, on: self)
@ -86,14 +96,6 @@ extension TabNavigationViewModel {
func viewModel(timeline: Timeline) -> StatusListViewModel {
StatusListViewModel(statusListService: identityService.service(timeline: timeline))
}
func select(timeline: Timeline) {
self.timeline = timeline
}
}
private extension TabNavigationViewModel {
static let timelines: [Timeline] = [.home, .local, .federated]
}
extension TabNavigationViewModel {

View file

@ -14,7 +14,7 @@ struct IdentitiesView: View {
NavigationLink(
destination: AddIdentityView(viewModel: rootViewModel.addIdentityViewModel()),
label: {
Label("identities.add", systemImage: "plus.circle")
Label("add", systemImage: "plus.circle")
})
}
Section {

64
Views/ListsView.swift Normal file
View file

@ -0,0 +1,64 @@
// Copyright © 2020 Metabolist. All rights reserved.
import SwiftUI
struct ListsView: View {
@StateObject var viewModel: ListsViewModel
@EnvironmentObject var tabNavigationViewModel: TabNavigationViewModel
@State private var newListTitle = ""
var body: some View {
Form {
Section {
TextField("lists.new-list-title", text: $newListTitle)
.disabled(viewModel.creatingList)
if viewModel.creatingList {
ProgressView()
.frame(maxWidth: .infinity, alignment: .center)
} else {
Button {
viewModel.createList(title: newListTitle)
} label: {
Label("add", systemImage: "plus.circle")
}
.disabled(newListTitle == "")
}
}
Section {
ForEach(viewModel.lists) { list in
Button(list.title) {
tabNavigationViewModel.timeline = .list(list)
tabNavigationViewModel.presentingSecondaryNavigation = false
}
}
.onDelete {
guard let index = $0.first else { return }
viewModel.delete(list: viewModel.lists[index])
}
}
}
.navigationTitle(Text("secondary-navigation.lists"))
.toolbar {
ToolbarItem(placement: ToolbarItemPlacement.navigationBarTrailing) {
EditButton()
}
}
.alertItem($viewModel.alertItem)
.onAppear(perform: viewModel.refreshLists)
.onReceive(viewModel.$creatingList) {
if !$0 {
newListTitle = ""
}
}
}
}
#if DEBUG
struct ListsView_Previews: PreviewProvider {
static var previews: some View {
ListsView(viewModel: .development)
.environmentObject(TabNavigationViewModel.development)
}
}
#endif

View file

@ -5,7 +5,6 @@ import KingfisherSwiftUI
struct SecondaryNavigationView: View {
@StateObject var viewModel: SecondaryNavigationViewModel
@EnvironmentObject var rootViewModel: RootViewModel
@Environment(\.presentationMode) var presentationMode
@Environment(\.displayScale) var displayScale: CGFloat
@ -14,8 +13,7 @@ struct SecondaryNavigationView: View {
Form {
Section {
NavigationLink(
destination: IdentitiesView(viewModel: viewModel.identitiesViewModel())
.environmentObject(rootViewModel),
destination: IdentitiesView(viewModel: viewModel.identitiesViewModel()),
label: {
HStack {
KFImage(viewModel.identity.image,
@ -40,6 +38,11 @@ struct SecondaryNavigationView: View {
}
})
}
Section {
NavigationLink(destination: ListsView(viewModel: viewModel.listsViewModel())) {
Label("secondary-navigation.lists", systemImage: "scroll")
}
}
Section {
NavigationLink(
"secondary-navigation.preferences",
@ -67,6 +70,7 @@ struct SecondaryNavigationView_Previews: PreviewProvider {
static var previews: some View {
SecondaryNavigationView(viewModel: .development)
.environmentObject(RootViewModel.development)
.environmentObject(TabNavigationViewModel.development)
}
}
#endif

View file

@ -24,7 +24,7 @@ struct TabNavigationView: View {
}
.sheet(isPresented: $viewModel.presentingSecondaryNavigation) {
SecondaryNavigationView(viewModel: viewModel.secondaryNavigationViewModel())
.environmentObject(rootViewModel)
.environmentObject(viewModel)
}
.alertItem($viewModel.alertItem)
.onAppear(perform: viewModel.refreshIdentity)
@ -60,7 +60,7 @@ private extension TabNavigationView {
trailing: Menu {
ForEach(viewModel.timelinesAndLists) { timeline in
Button {
viewModel.select(timeline: timeline)
viewModel.timeline = timeline
} label: {
Label(viewModel.title(timeline: timeline),
systemImage: viewModel.systemImageName(timeline: timeline))