From e0253fb4390187ec28eaddd145394bd36e2d6021 Mon Sep 17 00:00:00 2001 From: Thomas Ricouard Date: Mon, 2 Jan 2023 19:23:44 +0100 Subject: [PATCH] Added lists support + bunch of bug fixes --- IceCubesApp.xcodeproj/project.pbxproj | 9 ++ IceCubesApp/App/AppRouteur.swift | 7 ++ IceCubesApp/App/Tabs/AccountTab.swift | 3 + IceCubesApp/App/Tabs/ExploreTab.swift | 3 + IceCubesApp/App/Tabs/NotificationTab.swift | 3 + IceCubesApp/App/Tabs/TimelineTab.swift | 21 +++++ .../Sources/Account/AccountDetailView.swift | 75 ++++++++++++++++- .../Account/AccountDetailViewModel.swift | 17 ++-- .../Views/StatusEditorToolbarItem.swift | 16 ++++ Packages/Env/Sources/Env/CurrentAccount.swift | 29 +++++++ Packages/Env/Sources/Env/Routeur.swift | 7 ++ Packages/Lists/.gitignore | 9 ++ Packages/Lists/Package.swift | 33 ++++++++ Packages/Lists/README.md | 3 + .../AddAccounts/ListAddAccountView.swift | 82 +++++++++++++++++++ .../AddAccounts/ListAddAccountViewModel.swift | 46 +++++++++++ .../Sources/Lists/Edit/ListEditView.swift | 70 ++++++++++++++++ .../Lists/Edit/ListEditViewModel.swift | 38 +++++++++ Packages/Models/Sources/Models/List.swift | 7 ++ Packages/Network/Sources/Network/Client.swift | 7 ++ .../Sources/Network/Endpoint/Accounts.swift | 3 + .../Sources/Network/Endpoint/Lists.swift | 39 +++++++++ .../Sources/Network/Endpoint/Timelines.swift | 5 ++ .../Notifications/NotificationRowView.swift | 2 +- .../Sources/Status/Poll/StatusPollView.swift | 3 + .../Sources/Timeline/TimelineFilter.swift | 8 +- .../Sources/Timeline/TimelineView.swift | 17 ++++ README.md | 6 +- 28 files changed, 556 insertions(+), 12 deletions(-) create mode 100644 Packages/Lists/.gitignore create mode 100644 Packages/Lists/Package.swift create mode 100644 Packages/Lists/README.md create mode 100644 Packages/Lists/Sources/Lists/AddAccounts/ListAddAccountView.swift create mode 100644 Packages/Lists/Sources/Lists/AddAccounts/ListAddAccountViewModel.swift create mode 100644 Packages/Lists/Sources/Lists/Edit/ListEditView.swift create mode 100644 Packages/Lists/Sources/Lists/Edit/ListEditViewModel.swift create mode 100644 Packages/Models/Sources/Models/List.swift create mode 100644 Packages/Network/Sources/Network/Endpoint/Lists.swift diff --git a/IceCubesApp.xcodeproj/project.pbxproj b/IceCubesApp.xcodeproj/project.pbxproj index d10a331f..6647d3bb 100644 --- a/IceCubesApp.xcodeproj/project.pbxproj +++ b/IceCubesApp.xcodeproj/project.pbxproj @@ -32,6 +32,7 @@ 9FBFE63D292A715500C250E9 /* IceCubesApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBFE63C292A715500C250E9 /* IceCubesApp.swift */; }; 9FBFE64E292A72BD00C250E9 /* Network in Frameworks */ = {isa = PBXBuildFile; productRef = 9FBFE64D292A72BD00C250E9 /* Network */; }; 9FD34823293D06E800DB0EE9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9FD34822293D06E800DB0EE9 /* Assets.xcassets */; }; + 9FD542E72962D2FF0045321A /* Lists in Frameworks */ = {isa = PBXBuildFile; productRef = 9FD542E62962D2FF0045321A /* Lists */; }; 9FE151A6293C90F900E9683D /* IconSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FE151A5293C90F900E9683D /* IconSelectorView.swift */; }; /* End PBXBuildFile section */ @@ -64,6 +65,7 @@ 9FBFE63C292A715500C250E9 /* IceCubesApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IceCubesApp.swift; sourceTree = ""; }; 9FBFE642292A715600C250E9 /* IceCubesApp.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = IceCubesApp.entitlements; sourceTree = ""; }; 9FD34822293D06E800DB0EE9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 9FD542E52962D2CE0045321A /* Lists */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Lists; path = Packages/Lists; sourceTree = ""; }; 9FE151A5293C90F900E9683D /* IconSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconSelectorView.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -76,6 +78,7 @@ 9FAE4ACE29379A5A00772766 /* KeychainSwift in Frameworks */, 9F398AA92935FFDB00A889F2 /* Account in Frameworks */, 9FBFE64E292A72BD00C250E9 /* Network in Frameworks */, + 9FD542E72962D2FF0045321A /* Lists in Frameworks */, 9F398AAB2935FFDB00A889F2 /* Models in Frameworks */, 9F5E581929545BE700A53960 /* Env in Frameworks */, 9F35DB44294F9A7D00B3281A /* Status in Frameworks */, @@ -142,6 +145,7 @@ 9F5E581729545B5500A53960 /* Env */, 9F398AA32935F90100A889F2 /* Models */, 9F29553D292B67B600E0E81B /* Network */, + 9FD542E52962D2CE0045321A /* Lists */, 9F35DB4829506F7F00B3281A /* Notifications */, 9F29553E292B6AF600E0E81B /* Timeline */, 9F35DB42294F9A2900B3281A /* Status */, @@ -212,6 +216,7 @@ 9F35DB4929506FA100B3281A /* Notifications */, 9F5E581829545BE700A53960 /* Env */, 9F55C68F2955993C00F94077 /* Explore */, + 9FD542E62962D2FF0045321A /* Lists */, ); productName = IceCubesApp; productReference = 9FBFE639292A715500C250E9 /* IceCubesApp.app */; @@ -566,6 +571,10 @@ isa = XCSwiftPackageProductDependency; productName = Network; }; + 9FD542E62962D2FF0045321A /* Lists */ = { + isa = XCSwiftPackageProductDependency; + productName = Lists; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 9FBFE631292A715500C250E9 /* Project object */; diff --git a/IceCubesApp/App/AppRouteur.swift b/IceCubesApp/App/AppRouteur.swift index 707a0156..fa301a6f 100644 --- a/IceCubesApp/App/AppRouteur.swift +++ b/IceCubesApp/App/AppRouteur.swift @@ -4,6 +4,7 @@ import Account import Env import Status import DesignSystem +import Lists extension View { func withAppRouteur() -> some View { @@ -17,6 +18,8 @@ extension View { StatusDetailView(statusId: id) case let .hashTag(tag, accountId): TimelineView(timeline: .constant(.hashtag(tag: tag, accountId: accountId)), scrollToTopSignal: .constant(0)) + case let .list(list): + TimelineView(timeline: .constant(.list(list: list)), scrollToTopSignal: .constant(0)) case let .following(id): AccountsListView(mode: .following(accountId: id)) case let .followers(id): @@ -40,6 +43,10 @@ extension View { StatusEditorView(mode: .edit(status: status)) case let .quoteStatusEditor(status): StatusEditorView(mode: .quote(status: status)) + case let .listEdit(list): + ListEditView(list: list) + case let .listAddAccount(account): + ListAddAccountView(account: account) } } } diff --git a/IceCubesApp/App/Tabs/AccountTab.swift b/IceCubesApp/App/Tabs/AccountTab.swift index 642b9b83..729229c6 100644 --- a/IceCubesApp/App/Tabs/AccountTab.swift +++ b/IceCubesApp/App/Tabs/AccountTab.swift @@ -33,6 +33,9 @@ struct AccountTab: View { routeurPath.path = [] } } + .onChange(of: currentAccount.account?.id) { _ in + routeurPath.path = [] + } .onAppear { routeurPath.client = client } diff --git a/IceCubesApp/App/Tabs/ExploreTab.swift b/IceCubesApp/App/Tabs/ExploreTab.swift index 0a443722..a127d204 100644 --- a/IceCubesApp/App/Tabs/ExploreTab.swift +++ b/IceCubesApp/App/Tabs/ExploreTab.swift @@ -27,6 +27,9 @@ struct ExploreTab: View { routeurPath.path = [] } } + .onChange(of: currentAccount.account?.id) { _ in + routeurPath.path = [] + } .onAppear { routeurPath.client = client } diff --git a/IceCubesApp/App/Tabs/NotificationTab.swift b/IceCubesApp/App/Tabs/NotificationTab.swift index de2b272f..1459adae 100644 --- a/IceCubesApp/App/Tabs/NotificationTab.swift +++ b/IceCubesApp/App/Tabs/NotificationTab.swift @@ -31,5 +31,8 @@ struct NotificationsTab: View { routeurPath.path = [] } } + .onChange(of: currentAccount.account?.id) { _ in + routeurPath.path = [] + } } } diff --git a/IceCubesApp/App/Tabs/TimelineTab.swift b/IceCubesApp/App/Tabs/TimelineTab.swift index 894c1453..e2759cfc 100644 --- a/IceCubesApp/App/Tabs/TimelineTab.swift +++ b/IceCubesApp/App/Tabs/TimelineTab.swift @@ -4,6 +4,7 @@ import Env import Network import Combine import DesignSystem +import Models struct TimelineTab: View { @EnvironmentObject private var appAccounts: AppAccountsManager @@ -12,6 +13,7 @@ struct TimelineTab: View { @EnvironmentObject private var client: Client @StateObject private var routeurPath = RouterPath() @Binding var popToRootTab: Tab + @State private var timeline: TimelineFilter = .home @State private var scrollToTopSignal: Int = 0 @State private var isAddAccountSheetDisplayed = false @@ -45,6 +47,9 @@ struct TimelineTab: View { .onAppear { routeurPath.client = client timeline = client.isAuth ? .home : .pub + Task { + await currentAccount.fetchLists() + } } .environmentObject(routeurPath) .onChange(of: $popToRootTab.wrappedValue) { popToRootTab in @@ -56,9 +61,13 @@ struct TimelineTab: View { } } } + .onChange(of: currentAccount.account?.id) { _ in + routeurPath.path = [] + } } + @ViewBuilder private var timelineFilterButton: some View { ForEach(TimelineFilter.availableTimeline(client: client), id: \.self) { timeline in Button { @@ -67,6 +76,17 @@ struct TimelineTab: View { Label(timeline.title(), systemImage: timeline.iconName() ?? "") } } + if !currentAccount.lists.isEmpty { + Menu("Lists") { + ForEach(currentAccount.lists) { list in + Button { + timeline = .list(list: list) + } label: { + Label(list.title, systemImage: "list.bullet") + } + } + } + } } private var accountButton: some View { @@ -95,6 +115,7 @@ struct TimelineTab: View { ForEach(accountsViewModel, id: \.appAccount.id) { viewModel in Button { appAccounts.currentAccount = viewModel.appAccount + timeline = .home } label: { HStack { if viewModel.account?.id == currentAccount.account?.id { diff --git a/Packages/Account/Sources/Account/AccountDetailView.swift b/Packages/Account/Sources/Account/AccountDetailView.swift index b537a473..6927a089 100644 --- a/Packages/Account/Sources/Account/AccountDetailView.swift +++ b/Packages/Account/Sources/Account/AccountDetailView.swift @@ -18,6 +18,8 @@ public struct AccountDetailView: View { @State private var scrollOffset: CGFloat = 0 @State private var isFieldsSheetDisplayed: Bool = false @State private var isCurrentUser: Bool = false + @State private var isCreateListAlertPresented: Bool = false + @State private var createListTitle: String = "" /// When coming from a URL like a mention tap in a status. public init(accountId: String) { @@ -58,11 +60,28 @@ public struct AccountDetailView: View { StatusesListView(fetcher: viewModel) case let .followedTags(tags): makeTagsListView(tags: tags) + case .lists: + listsListView } } } .scrollContentBackground(.hidden) .background(theme.primaryBackgroundColor) + .toolbar { + if viewModel.relationship?.following == true, let account = viewModel.account { + ToolbarItem { + Menu { + Button { + routeurPath.presentedSheet = .listAddAccount(account: account) + } label: { + Label("Add/Remove from lists", systemImage: "list.bullet") + } + } label: { + Image(systemName: "ellipsis") + } + } + } + } } .onAppear { Task { @@ -220,12 +239,62 @@ public struct AccountDetailView: View { private func makeTagsListView(tags: [Tag]) -> some View { Group { ForEach(tags) { tag in - TagRowView(tag: tag) - .padding(.horizontal, DS.Constants.layoutPadding) - .padding(.vertical, 8) + HStack { + TagRowView(tag: tag) + Spacer() + Image(systemName: "chevron.right") + } + .padding(.horizontal, DS.Constants.layoutPadding) + .padding(.vertical, 8) } } } + + private var listsListView: some View { + Group { + ForEach(currentAccount.lists) { list in + NavigationLink(value: RouteurDestinations.list(list: list)) { + HStack { + Text(list.title) + Spacer() + Image(systemName: "chevron.right") + } + .padding(.vertical, 8) + .padding(.horizontal, DS.Constants.layoutPadding) + .font(.headline) + .foregroundColor(.white) + } + .contextMenu { + Button("Delete list", role: .destructive) { + Task { + await currentAccount.deleteList(list: list) + } + } + } + } + Button("Create a new list") { + isCreateListAlertPresented = true + } + .padding(.horizontal, DS.Constants.layoutPadding) + } + .alert("Create a new list", isPresented: $isCreateListAlertPresented) { + TextField("List name", text: $createListTitle) + Button("Cancel") { + isCreateListAlertPresented = false + createListTitle = "" + } + Button("Create List") { + guard !createListTitle.isEmpty else { return } + isCreateListAlertPresented = false + Task { + await currentAccount.createList(title: createListTitle) + createListTitle = "" + } + } + } message: { + Text("Enter the name for your list") + } + } } struct AccountDetailView_Previews: PreviewProvider { diff --git a/Packages/Account/Sources/Account/AccountDetailViewModel.swift b/Packages/Account/Sources/Account/AccountDetailViewModel.swift index 409c8141..23a46feb 100644 --- a/Packages/Account/Sources/Account/AccountDetailViewModel.swift +++ b/Packages/Account/Sources/Account/AccountDetailViewModel.swift @@ -15,10 +15,10 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher { } enum Tab: Int { - case statuses, favourites, followedTags, postsAndReplies, media + case statuses, favourites, followedTags, postsAndReplies, media, lists static var currentAccountTabs: [Tab] { - [.statuses, .favourites, .followedTags] + [.statuses, .favourites, .followedTags, .lists] } static var accountTabs: [Tab] { @@ -28,10 +28,11 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher { var title: String { switch self { case .statuses: return "Posts" - case .favourites: return "Favourites" - case .followedTags: return "Followed Tags" + case .favourites: return "Favorites" + case .followedTags: return "Tags" case .postsAndReplies: return "Posts & Replies" case .media: return "Media" + case .lists: return "Lists" } } } @@ -39,6 +40,7 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher { enum TabState { case followedTags(tags: [Tag]) case statuses(statusesState: StatusesState) + case lists } @Published var accountState: AccountState = .loading @@ -77,7 +79,7 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher { } } - private var account: Account? + private(set) var account: Account? private var tabTask: Task? private(set) var statuses: [Status] = [] @@ -91,6 +93,7 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher { /// When the account is already fetched by the parent caller. init(account: Account) { self.accountId = account.id + self.account = account self.accountState = .data(account: account) } @@ -166,7 +169,7 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher { (newFavourites, favouritesNextPage) = try await client.getWithLink(endpoint: Accounts.favourites(sinceId: nextPageId)) favourites.append(contentsOf: newFavourites) tabState = .statuses(statusesState: .display(statuses: favourites, nextPageState: .hasNextPage)) - case .followedTags: + case .followedTags, .lists: break } } catch { @@ -201,6 +204,8 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher { nextPageState: favouritesNextPage != nil ? .hasNextPage : .none)) case .followedTags: tabState = .followedTags(tags: followedTags) + case .lists: + tabState = .lists } } diff --git a/Packages/DesignSystem/Sources/DesignSystem/Views/StatusEditorToolbarItem.swift b/Packages/DesignSystem/Sources/DesignSystem/Views/StatusEditorToolbarItem.swift index d94706d6..0fda4e00 100644 --- a/Packages/DesignSystem/Sources/DesignSystem/Views/StatusEditorToolbarItem.swift +++ b/Packages/DesignSystem/Sources/DesignSystem/Views/StatusEditorToolbarItem.swift @@ -13,3 +13,19 @@ extension View { } } } + +public struct StatusEditorToolbarItem: ToolbarContent { + @EnvironmentObject private var routerPath: RouterPath + + public init() { } + + public var body: some ToolbarContent { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + routerPath.presentedSheet = .newStatusEditor + } label: { + Image(systemName: "square.and.pencil") + } + } + } +} diff --git a/Packages/Env/Sources/Env/CurrentAccount.swift b/Packages/Env/Sources/Env/CurrentAccount.swift index 64d8783a..fa3b20b4 100644 --- a/Packages/Env/Sources/Env/CurrentAccount.swift +++ b/Packages/Env/Sources/Env/CurrentAccount.swift @@ -5,6 +5,7 @@ import Network @MainActor public class CurrentAccount: ObservableObject { @Published public private(set) var account: Account? + @Published public private(set) var lists: [List] = [] private var client: Client? @@ -16,6 +17,7 @@ public class CurrentAccount: ObservableObject { self.client = client Task { await fetchCurrentAccount() + await fetchLists() } } @@ -28,4 +30,31 @@ public class CurrentAccount: ObservableObject { account = try? await client.get(endpoint: Accounts.verifyCredentials) } } + + public func fetchLists() async { + guard let client, client.isAuth else { return } + do { + lists = try await client.get(endpoint: Lists.lists) + } catch { + lists = [] + } + } + + public func createList(title: String) async { + guard let client else { return } + do { + let list: Models.List = try await client.post(endpoint: Lists.createList(title: title)) + lists.append(list) + } catch { } + } + + + public func deleteList(list: Models.List) async { + guard let client else { return } + lists.removeAll(where: { $0.id == list.id }) + let response = try? await client.delete(endpoint: Lists.list(id: list.id)) + if response?.statusCode != 200 { + lists.append(list) + } + } } diff --git a/Packages/Env/Sources/Env/Routeur.swift b/Packages/Env/Sources/Env/Routeur.swift index 398410e1..88f07b9c 100644 --- a/Packages/Env/Sources/Env/Routeur.swift +++ b/Packages/Env/Sources/Env/Routeur.swift @@ -8,6 +8,7 @@ public enum RouteurDestinations: Hashable { case accountDetailWithAccount(account: Account) case statusDetail(id: String) case hashTag(tag: String, account: String?) + case list(list: Models.List) case followers(id: String) case following(id: String) case favouritedBy(id: String) @@ -19,11 +20,17 @@ public enum SheetDestinations: Identifiable { case editStatusEditor(status: Status) case replyToStatusEditor(status: Status) case quoteStatusEditor(status: Status) + case listEdit(list: Models.List) + case listAddAccount(account: Account) public var id: String { switch self { case .editStatusEditor, .newStatusEditor, .replyToStatusEditor, .quoteStatusEditor: return "statusEditor" + case .listEdit: + return "listEdit" + case .listAddAccount: + return "listAddAccount" } } } diff --git a/Packages/Lists/.gitignore b/Packages/Lists/.gitignore new file mode 100644 index 00000000..3b298120 --- /dev/null +++ b/Packages/Lists/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/Packages/Lists/Package.swift b/Packages/Lists/Package.swift new file mode 100644 index 00000000..c2eddc3f --- /dev/null +++ b/Packages/Lists/Package.swift @@ -0,0 +1,33 @@ +// swift-tools-version: 5.7 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "Lists", + platforms: [ + .iOS(.v16), + ], + products: [ + .library( + name: "Lists", + targets: ["Lists"]), + ], + dependencies: [ + .package(name: "Network", path: "../Network"), + .package(name: "Models", path: "../Models"), + .package(name: "Env", path: "../Env"), + .package(name: "DesignSystem", path: "../DesignSystem"), + ], + targets: [ + .target( + name: "Lists", + dependencies: [ + .product(name: "Network", package: "Network"), + .product(name: "Models", package: "Models"), + .product(name: "Env", package: "Env"), + .product(name: "DesignSystem", package: "DesignSystem") + ]), + ] +) + diff --git a/Packages/Lists/README.md b/Packages/Lists/README.md new file mode 100644 index 00000000..c59368a2 --- /dev/null +++ b/Packages/Lists/README.md @@ -0,0 +1,3 @@ +# Lists + +A description of this package. diff --git a/Packages/Lists/Sources/Lists/AddAccounts/ListAddAccountView.swift b/Packages/Lists/Sources/Lists/AddAccounts/ListAddAccountView.swift new file mode 100644 index 00000000..25e6a14a --- /dev/null +++ b/Packages/Lists/Sources/Lists/AddAccounts/ListAddAccountView.swift @@ -0,0 +1,82 @@ +import SwiftUI +import Network +import DesignSystem +import Env +import Models + +public struct ListAddAccountView: View { + @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var client: Client + @EnvironmentObject private var theme: Theme + @EnvironmentObject private var currentAccount: CurrentAccount + @StateObject private var viewModel: ListAddAccountViewModel + + @State private var isCreateListAlertPresented: Bool = false + @State private var createListTitle: String = "" + + + public init(account: Account) { + _viewModel = StateObject(wrappedValue: .init(account: account)) + } + + public var body: some View { + NavigationStack { + List { + ForEach(currentAccount.lists) { list in + HStack { + Toggle(list.title, isOn: .init(get: { + viewModel.inLists.contains(where: { $0.id == list.id }) + }, set: { value in + Task { + if value { + await viewModel.addToList(list: list) + } else { + await viewModel.removeFromList(list: list) + } + } + })) + .disabled(viewModel.isLoadingInfo) + Spacer() + } + .listRowBackground(theme.primaryBackgroundColor) + } + Button("Create a new list") { + isCreateListAlertPresented = true + } + .listRowBackground(theme.primaryBackgroundColor) + } + .scrollContentBackground(.hidden) + .background(theme.secondaryBackgroundColor) + .navigationTitle("Add/Remove \(viewModel.account.displayName)") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem { + Button("Done") { + dismiss() + } + } + } + .alert("Create a new list", isPresented: $isCreateListAlertPresented) { + TextField("List name", text: $createListTitle) + Button("Cancel") { + isCreateListAlertPresented = false + createListTitle = "" + } + Button("Create List") { + guard !createListTitle.isEmpty else { return } + isCreateListAlertPresented = false + Task { + await currentAccount.createList(title: createListTitle) + createListTitle = "" + } + } + } message: { + Text("Enter the name for your list") + } + } + .task { + viewModel.client = client + await viewModel.fetchInfo() + } + } +} diff --git a/Packages/Lists/Sources/Lists/AddAccounts/ListAddAccountViewModel.swift b/Packages/Lists/Sources/Lists/AddAccounts/ListAddAccountViewModel.swift new file mode 100644 index 00000000..7139e211 --- /dev/null +++ b/Packages/Lists/Sources/Lists/AddAccounts/ListAddAccountViewModel.swift @@ -0,0 +1,46 @@ +import SwiftUI +import Models +import Network + +@MainActor +class ListAddAccountViewModel: ObservableObject { + let account: Account + + @Published var inLists: [Models.List] = [] + @Published var isLoadingInfo: Bool = true + + var client: Client? + + init(account: Account) { + self.account = account + } + + func fetchInfo() async { + guard let client else { return } + isLoadingInfo = true + do { + inLists = try await client.get(endpoint: Accounts.lists(id: account.id)) + isLoadingInfo = false + } catch { + withAnimation { + isLoadingInfo = false + } + } + } + + func addToList(list: Models.List) async { + guard let client else { return } + let response = try? await client.post(endpoint: Lists.updateAccounts(listId: list.id, accounts: [account.id])) + if response?.statusCode == 200 { + inLists.append(list) + } + } + + func removeFromList(list: Models.List) async { + guard let client else { return } + let response = try? await client.delete(endpoint: Lists.updateAccounts(listId: list.id, accounts: [account.id])) + if response?.statusCode == 200 { + inLists.removeAll(where: { $0.id == list.id }) + } + } +} diff --git a/Packages/Lists/Sources/Lists/Edit/ListEditView.swift b/Packages/Lists/Sources/Lists/Edit/ListEditView.swift new file mode 100644 index 00000000..3bb7c689 --- /dev/null +++ b/Packages/Lists/Sources/Lists/Edit/ListEditView.swift @@ -0,0 +1,70 @@ +import SwiftUI +import Models +import DesignSystem +import Network + +public struct ListEditView: View { + @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var theme: Theme + @EnvironmentObject private var client: Client + + @StateObject private var viewModel: ListEditViewModel + + public init(list: Models.List) { + _viewModel = StateObject(wrappedValue: .init(list: list)) + } + + public var body: some View { + NavigationStack { + List { + Section("Users in this list") { + if viewModel.isLoadingAccounts { + HStack { + Spacer() + ProgressView() + Spacer() + } + .listRowBackground(theme.primaryBackgroundColor) + } else { + ForEach(viewModel.accounts) { account in + HStack { + AvatarView(url: account.avatar, size: .status) + VStack(alignment: .leading) { + account.displayNameWithEmojis + Text("@\(account.acct)") + .foregroundColor(.gray) + .font(.footnote) + } + } + .listRowBackground(theme.primaryBackgroundColor) + }.onDelete { indexes in + if let index = indexes.first { + Task { + let account = viewModel.accounts[index] + await viewModel.delete(account: account) + } + } + } + } + } + } + .scrollContentBackground(.hidden) + .background(theme.secondaryBackgroundColor) + .toolbar { + ToolbarItem { + Button("Done") { + dismiss() + } + } + } + .navigationTitle(viewModel.list.title) + .navigationBarTitleDisplayMode(.inline) + .onAppear { + viewModel.client = client + Task { + await viewModel.fetchAccounts() + } + } + } + } +} diff --git a/Packages/Lists/Sources/Lists/Edit/ListEditViewModel.swift b/Packages/Lists/Sources/Lists/Edit/ListEditViewModel.swift new file mode 100644 index 00000000..c5aecd3b --- /dev/null +++ b/Packages/Lists/Sources/Lists/Edit/ListEditViewModel.swift @@ -0,0 +1,38 @@ +import SwiftUI +import Models +import Network + +@MainActor +public class ListEditViewModel: ObservableObject { + let list: Models.List + + var client: Client? + + @Published var isLoadingAccounts: Bool = true + @Published var accounts: [Account] = [] + + init(list: Models.List) { + self.list = list + } + + func fetchAccounts() async { + guard let client else { return } + isLoadingAccounts = true + do { + accounts = try await client.get(endpoint: Lists.accounts(listId: list.id)) + isLoadingAccounts = false + } catch { + isLoadingAccounts = false + } + } + + func delete(account: Account) async { + guard let client else { return } + do { + let response = try await client.delete(endpoint: Lists.updateAccounts(listId: list.id, accounts: [account.id])) + if response?.statusCode == 200 { + accounts.removeAll(where: { $0.id == account.id }) + } + } catch { } + } +} diff --git a/Packages/Models/Sources/Models/List.swift b/Packages/Models/Sources/Models/List.swift new file mode 100644 index 00000000..97acd722 --- /dev/null +++ b/Packages/Models/Sources/Models/List.swift @@ -0,0 +1,7 @@ +import Foundation + +public struct List: Decodable, Identifiable, Equatable, Hashable { + public let id: String + public let title: String + public let repliesPolicy: String +} diff --git a/Packages/Network/Sources/Network/Client.swift b/Packages/Network/Sources/Network/Client.swift index 17723531..91af8752 100644 --- a/Packages/Network/Sources/Network/Client.swift +++ b/Packages/Network/Sources/Network/Client.swift @@ -86,6 +86,13 @@ public class Client: ObservableObject, Equatable { try await makeEntityRequest(endpoint: endpoint, method: "POST") } + public func post(endpoint: Endpoint) async throws -> HTTPURLResponse? { + let url = makeURL(endpoint: endpoint) + let request = makeURLRequest(url: url, httpMethod: "POST") + let (_, httpResponse) = try await urlSession.data(for: request) + return httpResponse as? HTTPURLResponse + } + public func put(endpoint: Endpoint) async throws -> Entity { try await makeEntityRequest(endpoint: endpoint, method: "PUT") } diff --git a/Packages/Network/Sources/Network/Endpoint/Accounts.swift b/Packages/Network/Sources/Network/Endpoint/Accounts.swift index 2327dac6..75babebc 100644 --- a/Packages/Network/Sources/Network/Endpoint/Accounts.swift +++ b/Packages/Network/Sources/Network/Endpoint/Accounts.swift @@ -14,6 +14,7 @@ public enum Accounts: Endpoint { case suggestions case followers(id: String, maxId: String?) case following(id: String, maxId: String?) + case lists(id: String) public func path() -> String { switch self { @@ -43,6 +44,8 @@ public enum Accounts: Endpoint { return "accounts/\(id)/following" case .followers(let id, _): return "accounts/\(id)/followers" + case .lists(let id): + return "accounts/\(id)/lists" } } diff --git a/Packages/Network/Sources/Network/Endpoint/Lists.swift b/Packages/Network/Sources/Network/Endpoint/Lists.swift new file mode 100644 index 00000000..17e7c4d3 --- /dev/null +++ b/Packages/Network/Sources/Network/Endpoint/Lists.swift @@ -0,0 +1,39 @@ +import Foundation + +public enum Lists: Endpoint { + case lists + case list(id: String) + case createList(title: String) + case accounts(listId: String) + case updateAccounts(listId: String, accounts: [String]) + + public func path() -> String { + switch self { + case .lists, .createList: + return "lists" + case let .list(id): + return "lists/\(id)" + case let .accounts(listId): + return "lists/\(listId)/accounts" + case let .updateAccounts(listId, _): + return "lists/\(listId)/accounts" + } + } + + public func queryItems() -> [URLQueryItem]? { + switch self { + case .accounts(_): + return [.init(name: "limit", value: String(0))] + case let .createList(title): + return [.init(name: "title", value: title)] + case let .updateAccounts(_, accounts): + var params: [URLQueryItem] = [] + for account in accounts { + params.append(.init(name: "account_ids[]", value: account)) + } + return params + default: + return nil + } + } +} diff --git a/Packages/Network/Sources/Network/Endpoint/Timelines.swift b/Packages/Network/Sources/Network/Endpoint/Timelines.swift index 6ce6c29e..13bbe548 100644 --- a/Packages/Network/Sources/Network/Endpoint/Timelines.swift +++ b/Packages/Network/Sources/Network/Endpoint/Timelines.swift @@ -3,6 +3,7 @@ import Foundation public enum Timelines: Endpoint { case pub(sinceId: String?, maxId: String?, minId: String?, local: Bool) case home(sinceId: String?, maxId: String?, minId: String?) + case list(listId: String, sinceId: String?, maxId: String?, minId: String?) case hashtag(tag: String, maxId: String?) public func path() -> String { @@ -11,6 +12,8 @@ public enum Timelines: Endpoint { return "timelines/public" case .home: return "timelines/home" + case let .list(listId, _, _, _): + return "timelines/list/\(listId)" case let .hashtag(tag, _): return "timelines/tag/\(tag)" } @@ -24,6 +27,8 @@ public enum Timelines: Endpoint { return params case .home(let sinceId, let maxId, let mindId): return makePaginationParam(sinceId: sinceId, maxId: maxId, mindId: mindId) + case .list(_, let sinceId, let maxId, let mindId): + return makePaginationParam(sinceId: sinceId, maxId: maxId, mindId: mindId) case let .hashtag(_, maxId): return makePaginationParam(sinceId: nil, maxId: maxId, mindId: nil) } diff --git a/Packages/Notifications/Sources/Notifications/NotificationRowView.swift b/Packages/Notifications/Sources/Notifications/NotificationRowView.swift index 5b135ff8..4722ec2f 100644 --- a/Packages/Notifications/Sources/Notifications/NotificationRowView.swift +++ b/Packages/Notifications/Sources/Notifications/NotificationRowView.swift @@ -120,7 +120,7 @@ extension Models.Notification.NotificationType { case .poll: return "poll ended" case .update: - return "has been edited" + return "edited a post" } } diff --git a/Packages/Status/Sources/Status/Poll/StatusPollView.swift b/Packages/Status/Sources/Status/Poll/StatusPollView.swift index 57e764c4..a4946f21 100644 --- a/Packages/Status/Sources/Status/Poll/StatusPollView.swift +++ b/Packages/Status/Sources/Status/Poll/StatusPollView.swift @@ -26,6 +26,9 @@ public struct StatusPollView: View { private func percentForOption(option: Poll.Option) -> Int { let ratio = (Float(option.votesCount) / Float(viewModel.poll.votesCount)) * 100 + if ratio.isNaN { + return 0 + } return Int(round(ratio)) } diff --git a/Packages/Timeline/Sources/Timeline/TimelineFilter.swift b/Packages/Timeline/Sources/Timeline/TimelineFilter.swift index 75ba48b8..834b39b8 100644 --- a/Packages/Timeline/Sources/Timeline/TimelineFilter.swift +++ b/Packages/Timeline/Sources/Timeline/TimelineFilter.swift @@ -5,6 +5,7 @@ import Network public enum TimelineFilter: Hashable, Equatable { case pub, local, home, trending case hashtag(tag: String, accountId: String?) + case list(list: List) public func hash(into hasher: inout Hasher) { hasher.combine(title()) @@ -29,6 +30,8 @@ public enum TimelineFilter: Hashable, Equatable { return "Home" case let .hashtag(tag, _): return "#\(tag)" + case let .list(list): + return list.title } } @@ -42,6 +45,8 @@ public enum TimelineFilter: Hashable, Equatable { return "chart.line.uptrend.xyaxis" case .home: return "house" + case .list(_): + return "list.bullet" default: return nil } @@ -53,7 +58,8 @@ public enum TimelineFilter: Hashable, Equatable { case .local: return Timelines.pub(sinceId: sinceId, maxId: maxId, minId: minId, local: true) case .home: return Timelines.home(sinceId: sinceId, maxId: maxId, minId: minId) case .trending: return Trends.statuses(offset: offset) - case let .hashtag(tag, accountId): + case let .list(list): return Timelines.list(listId: list.id, sinceId: sinceId, maxId: maxId, minId: minId) + case let .hashtag(tag, accountId): if let accountId { return Accounts.statuses(id: accountId, sinceId: nil, tag: tag, onlyMedia: nil, excludeReplies: nil) } else { diff --git a/Packages/Timeline/Sources/Timeline/TimelineView.swift b/Packages/Timeline/Sources/Timeline/TimelineView.swift index c48f3411..d23123e3 100644 --- a/Packages/Timeline/Sources/Timeline/TimelineView.swift +++ b/Packages/Timeline/Sources/Timeline/TimelineView.swift @@ -16,6 +16,7 @@ public struct TimelineView: View { @EnvironmentObject private var account: CurrentAccount @EnvironmentObject private var watcher: StreamWatcher @EnvironmentObject private var client: Client + @EnvironmentObject private var routerPath: RouterPath @StateObject private var viewModel = TimelineViewModel() @@ -54,6 +55,22 @@ public struct TimelineView: View { } } .navigationTitle(timeline.title()) + .toolbar{ + switch timeline { + case let .list(list): + ToolbarItem { + Button { + routerPath.presentedSheet = .listEdit(list: list) + } label: { + Image(systemName: "pencil") + } + } + default: + ToolbarItem { + EmptyView() + } + } + } .navigationBarTitleDisplayMode(.inline) .onAppear { viewModel.client = client diff --git a/README.md b/README.md index 46db27bc..f9ec2aa3 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # IceCubesApp +[Public TestFlight beta](https://testflight.apple.com/join/tqI3dK1u) (An proper App Store release will come eventually) +

@@ -16,6 +18,7 @@ For contributors and myself, here is a todo list of features that could be added (while giving you a good idea of what's already done if not in this list, the app is quite complete already!) - [ ] DM / Conversations +- [X] Lists support - [ ] Display images alt - [ ] Editor: Post image alts - [ ] Editor: Add / Edit polls @@ -25,13 +28,14 @@ For contributors and myself, here is a todo list of features that could be added - [ ] Better settings tab - [ ] Edit profile - [X] Light theme -- [ ] More themes +- [X] More themes - [ ] Honor & display server side features (filter, default visibility, etc...) - [X] Open remote status locally - [ ] More context menu everywhere - [ ] Support pinned posts - [ ] Support IceCubesApp://any mastodon links - [ ] Add a share sheet +- [ ] Translate button - [ ] Proper iPad support - [ ] macOS support