mirror of
https://github.com/Dimillian/IceCubesApp.git
synced 2024-10-31 22:28:58 +00:00
Added lists support + bunch of bug fixes
This commit is contained in:
parent
a6da64ce14
commit
e0253fb439
28 changed files with 556 additions and 12 deletions
|
@ -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 = "<group>"; };
|
||||
9FBFE642292A715600C250E9 /* IceCubesApp.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = IceCubesApp.entitlements; sourceTree = "<group>"; };
|
||||
9FD34822293D06E800DB0EE9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
9FD542E52962D2CE0045321A /* Lists */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Lists; path = Packages/Lists; sourceTree = "<group>"; };
|
||||
9FE151A5293C90F900E9683D /* IconSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconSelectorView.swift; sourceTree = "<group>"; };
|
||||
/* 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 */;
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -33,6 +33,9 @@ struct AccountTab: View {
|
|||
routeurPath.path = []
|
||||
}
|
||||
}
|
||||
.onChange(of: currentAccount.account?.id) { _ in
|
||||
routeurPath.path = []
|
||||
}
|
||||
.onAppear {
|
||||
routeurPath.client = client
|
||||
}
|
||||
|
|
|
@ -27,6 +27,9 @@ struct ExploreTab: View {
|
|||
routeurPath.path = []
|
||||
}
|
||||
}
|
||||
.onChange(of: currentAccount.account?.id) { _ in
|
||||
routeurPath.path = []
|
||||
}
|
||||
.onAppear {
|
||||
routeurPath.client = client
|
||||
}
|
||||
|
|
|
@ -31,5 +31,8 @@ struct NotificationsTab: View {
|
|||
routeurPath.path = []
|
||||
}
|
||||
}
|
||||
.onChange(of: currentAccount.account?.id) { _ in
|
||||
routeurPath.path = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
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 {
|
||||
|
|
|
@ -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<Void, Never>?
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
9
Packages/Lists/.gitignore
vendored
Normal file
9
Packages/Lists/.gitignore
vendored
Normal file
|
@ -0,0 +1,9 @@
|
|||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
/*.xcodeproj
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
.swiftpm/config/registries.json
|
||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||
.netrc
|
33
Packages/Lists/Package.swift
Normal file
33
Packages/Lists/Package.swift
Normal file
|
@ -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")
|
||||
]),
|
||||
]
|
||||
)
|
||||
|
3
Packages/Lists/README.md
Normal file
3
Packages/Lists/README.md
Normal file
|
@ -0,0 +1,3 @@
|
|||
# Lists
|
||||
|
||||
A description of this package.
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
70
Packages/Lists/Sources/Lists/Edit/ListEditView.swift
Normal file
70
Packages/Lists/Sources/Lists/Edit/ListEditView.swift
Normal file
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
38
Packages/Lists/Sources/Lists/Edit/ListEditViewModel.swift
Normal file
38
Packages/Lists/Sources/Lists/Edit/ListEditViewModel.swift
Normal file
|
@ -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 { }
|
||||
}
|
||||
}
|
7
Packages/Models/Sources/Models/List.swift
Normal file
7
Packages/Models/Sources/Models/List.swift
Normal file
|
@ -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
|
||||
}
|
|
@ -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<Entity: Decodable>(endpoint: Endpoint) async throws -> Entity {
|
||||
try await makeEntityRequest(endpoint: endpoint, method: "PUT")
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
39
Packages/Network/Sources/Network/Endpoint/Lists.swift
Normal file
39
Packages/Network/Sources/Network/Endpoint/Lists.swift
Normal file
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -120,7 +120,7 @@ extension Models.Notification.NotificationType {
|
|||
case .poll:
|
||||
return "poll ended"
|
||||
case .update:
|
||||
return "has been edited"
|
||||
return "edited a post"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
||||
|
|
|
@ -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,6 +58,7 @@ 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 .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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
# IceCubesApp
|
||||
|
||||
[Public TestFlight beta](https://testflight.apple.com/join/tqI3dK1u) (An proper App Store release will come eventually)
|
||||
|
||||
<p float="left">
|
||||
<img src="Images/image1.png" width="300" />
|
||||
<img src="Images/image2.png" width="300" />
|
||||
|
@ -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
|
||||
|
||||
|
|
Loading…
Reference in a new issue