diff --git a/IceCubesApp.xcodeproj/project.pbxproj b/IceCubesApp.xcodeproj/project.pbxproj index 71f84421..1dac7305 100644 --- a/IceCubesApp.xcodeproj/project.pbxproj +++ b/IceCubesApp.xcodeproj/project.pbxproj @@ -28,6 +28,8 @@ 9F7335EA2966B3F800AFF0BA /* Conversations in Frameworks */ = {isa = PBXBuildFile; productRef = 9F7335E92966B3F800AFF0BA /* Conversations */; }; 9F7335ED2967463400AFF0BA /* AVKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9F7335EB2967461B00AFF0BA /* AVKit.framework */; }; 9F7335EF29674F7100AFF0BA /* QuickLook.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9F7335EE29674F7100AFF0BA /* QuickLook.framework */; }; + 9F7335F22967608F00AFF0BA /* AddRemoteTimelineVIew.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F7335F12967608F00AFF0BA /* AddRemoteTimelineVIew.swift */; }; + 9F7335F72968274500AFF0BA /* AppAccountsSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F7335F62968274500AFF0BA /* AppAccountsSelectorView.swift */; }; 9FAE4ACB293783B000772766 /* SettingsTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAE4ACA293783B000772766 /* SettingsTab.swift */; }; 9FAE4ACE29379A5A00772766 /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 9FAE4ACD29379A5A00772766 /* KeychainSwift */; }; 9FAE4AD129379AD600772766 /* AppAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAE4AD029379AD600772766 /* AppAccount.swift */; }; @@ -63,6 +65,8 @@ 9F7335E82966B3DC00AFF0BA /* Conversations */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Conversations; path = Packages/Conversations; sourceTree = ""; }; 9F7335EB2967461B00AFF0BA /* AVKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AVKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.2.sdk/System/Library/Frameworks/AVKit.framework; sourceTree = DEVELOPER_DIR; }; 9F7335EE29674F7100AFF0BA /* QuickLook.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuickLook.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.2.sdk/System/Library/Frameworks/QuickLook.framework; sourceTree = DEVELOPER_DIR; }; + 9F7335F12967608F00AFF0BA /* AddRemoteTimelineVIew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddRemoteTimelineVIew.swift; sourceTree = ""; }; + 9F7335F62968274500AFF0BA /* AppAccountsSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAccountsSelectorView.swift; sourceTree = ""; }; 9FAE4AC8293774FF00772766 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 9FAE4ACA293783B000772766 /* SettingsTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsTab.swift; sourceTree = ""; }; 9FAE4AD029379AD600772766 /* AppAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAccount.swift; sourceTree = ""; }; @@ -118,11 +122,20 @@ path = Resources; sourceTree = ""; }; + 9F7335F02967607A00AFF0BA /* Timeline */ = { + isa = PBXGroup; + children = ( + 9F398AB229360A4C00A889F2 /* TimelineTab.swift */, + 9F7335F12967608F00AFF0BA /* AddRemoteTimelineVIew.swift */, + ); + path = Timeline; + sourceTree = ""; + }; 9FAE4AC9293783A200772766 /* Tabs */ = { isa = PBXGroup; children = ( + 9F7335F02967607A00AFF0BA /* Timeline */, 9FE151A4293C90EA00E9683D /* Settings */, - 9F398AB229360A4C00A889F2 /* TimelineTab.swift */, 9F35DB4629506F6600B3281A /* NotificationTab.swift */, 9F35DB4B2952005C00B3281A /* MessagesTab.swift */, 9F55C68C2955968700F94077 /* ExploreTab.swift */, @@ -138,6 +151,7 @@ 9FAE4AD22937A0C600772766 /* AppAccountsManager.swift */, 9F2B92FE295EB87100DE16D0 /* AppAccountView.swift */, 9F2B9300295EB8A100DE16D0 /* AppAccountViewModel.swift */, + 9F7335F62968274500AFF0BA /* AppAccountsSelectorView.swift */, ); path = AppAccounts; sourceTree = ""; @@ -301,6 +315,8 @@ 9FBFE63D292A715500C250E9 /* IceCubesApp.swift in Sources */, 9F2B92FA295DA7D700DE16D0 /* AddAccountsView.swift in Sources */, 9F35DB4729506F6600B3281A /* NotificationTab.swift in Sources */, + 9F7335F22967608F00AFF0BA /* AddRemoteTimelineVIew.swift in Sources */, + 9F7335F72968274500AFF0BA /* AppAccountsSelectorView.swift in Sources */, 9FAE4AD129379AD600772766 /* AppAccount.swift in Sources */, 9F55C68D2955968700F94077 /* ExploreTab.swift in Sources */, ); diff --git a/IceCubesApp/App/AppAccounts/AppAccountsSelectorView.swift b/IceCubesApp/App/AppAccounts/AppAccountsSelectorView.swift new file mode 100644 index 00000000..5ff4f933 --- /dev/null +++ b/IceCubesApp/App/AppAccounts/AppAccountsSelectorView.swift @@ -0,0 +1,64 @@ +import SwiftUI +import Env +import DesignSystem + +struct AppAccountsSelectorView: View { + @EnvironmentObject private var currentAccount: CurrentAccount + @EnvironmentObject private var appAccounts: AppAccountsManager + + @ObservedObject var routeurPath: RouterPath + + @State private var accountsViewModel: [AppAccountViewModel] = [] + + var body: some View { + Button { + if let account = currentAccount.account { + routeurPath.navigate(to: .accountDetailWithAccount(account: account)) + } + } label: { + if let avatar = currentAccount.account?.avatar { + AvatarView(url: avatar, size: .badge) + } else { + EmptyView() + } + } + .onAppear { + refreshAccounts() + } + .contextMenu { + ForEach(accountsViewModel, id: \.appAccount.id) { viewModel in + Button { + appAccounts.currentAccount = viewModel.appAccount + } label: { + HStack { + if viewModel.account?.id == currentAccount.account?.id { + Image(systemName: "checkmark.circle.fill") + } + Text("\(viewModel.account?.displayName ?? "")") + } + } + } + Button { + routeurPath.presentedSheet = .addAccount + } label: { + Label("Add Account", systemImage: "person.badge.plus") + } + } + .onChange(of: currentAccount.account?.id) { _ in + refreshAccounts() + } + } + + private func refreshAccounts() { + if accountsViewModel.isEmpty || appAccounts.availableAccounts.count != accountsViewModel.count { + accountsViewModel = [] + for account in appAccounts.availableAccounts { + let viewModel: AppAccountViewModel = .init(appAccount: account) + accountsViewModel.append(viewModel) + Task { + await viewModel.fetchAccount() + } + } + } + } +} diff --git a/IceCubesApp/App/AppRouteur.swift b/IceCubesApp/App/AppRouteur.swift index 3e82df54..cd638d2b 100644 --- a/IceCubesApp/App/AppRouteur.swift +++ b/IceCubesApp/App/AppRouteur.swift @@ -16,6 +16,8 @@ extension View { AccountDetailView(account: account) case let .statusDetail(id): StatusDetailView(statusId: id) + case let .remoteStatusDetail(url): + StatusDetailView(remoteStatusURL: url) case let .hashTag(tag, accountId): TimelineView(timeline: .constant(.hashtag(tag: tag, accountId: accountId)), scrollToTopSignal: .constant(0)) case let .list(list): @@ -49,6 +51,8 @@ extension View { ListEditView(list: list) case let .listAddAccount(account): ListAddAccountView(account: account) + case .addAccount: + AddAccountView() } } } diff --git a/IceCubesApp/App/IceCubesApp.swift b/IceCubesApp/App/IceCubesApp.swift index df6c5490..ae7da68a 100644 --- a/IceCubesApp/App/IceCubesApp.swift +++ b/IceCubesApp/App/IceCubesApp.swift @@ -13,6 +13,7 @@ struct IceCubesApp: App { @StateObject private var appAccountsManager = AppAccountsManager() @StateObject private var currentInstance = CurrentInstance() @StateObject private var currentAccount = CurrentAccount() + @StateObject private var userPreferences = UserPreferences() @StateObject private var watcher = StreamWatcher() @StateObject private var quickLook = QuickLook() @StateObject private var theme = Theme() @@ -39,6 +40,7 @@ struct IceCubesApp: App { .environmentObject(quickLook) .environmentObject(currentAccount) .environmentObject(currentInstance) + .environmentObject(userPreferences) .environmentObject(theme) .environmentObject(watcher) .quickLookPreview($quickLook.url, in: quickLook.urls) diff --git a/IceCubesApp/App/Tabs/ExploreTab.swift b/IceCubesApp/App/Tabs/ExploreTab.swift index 8c96b69b..54fb1804 100644 --- a/IceCubesApp/App/Tabs/ExploreTab.swift +++ b/IceCubesApp/App/Tabs/ExploreTab.swift @@ -19,6 +19,9 @@ struct ExploreTab: View { .withSheetDestinations(sheetDestinations: $routeurPath.presentedSheet) .toolbar { statusEditorToolbarItem(routeurPath: routeurPath, visibility: .pub) + ToolbarItem(placement: .navigationBarLeading) { + AppAccountsSelectorView(routeurPath: routeurPath) + } } } .environmentObject(routeurPath) diff --git a/IceCubesApp/App/Tabs/MessagesTab.swift b/IceCubesApp/App/Tabs/MessagesTab.swift index d1e4c589..e4325282 100644 --- a/IceCubesApp/App/Tabs/MessagesTab.swift +++ b/IceCubesApp/App/Tabs/MessagesTab.swift @@ -19,6 +19,11 @@ struct MessagesTab: View { ConversationsListView() .withAppRouteur() .withSheetDestinations(sheetDestinations: $routeurPath.presentedSheet) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + AppAccountsSelectorView(routeurPath: routeurPath) + } + } .id(currentAccount.account?.id) } .environmentObject(routeurPath) diff --git a/IceCubesApp/App/Tabs/NotificationTab.swift b/IceCubesApp/App/Tabs/NotificationTab.swift index c96e5c8b..edf58e2a 100644 --- a/IceCubesApp/App/Tabs/NotificationTab.swift +++ b/IceCubesApp/App/Tabs/NotificationTab.swift @@ -18,6 +18,9 @@ struct NotificationsTab: View { .withSheetDestinations(sheetDestinations: $routeurPath.presentedSheet) .toolbar { statusEditorToolbarItem(routeurPath: routeurPath, visibility: .pub) + ToolbarItem(placement: .navigationBarLeading) { + AppAccountsSelectorView(routeurPath: routeurPath) + } } .id(currentAccount.account?.id) } diff --git a/IceCubesApp/App/Tabs/Settings/AddAccountsView.swift b/IceCubesApp/App/Tabs/Settings/AddAccountsView.swift index ebe30ecc..b56d576a 100644 --- a/IceCubesApp/App/Tabs/Settings/AddAccountsView.swift +++ b/IceCubesApp/App/Tabs/Settings/AddAccountsView.swift @@ -32,6 +32,7 @@ struct AddAccountView: View { .keyboardType(.URL) .textContentType(.URL) .textInputAutocapitalization(.never) + .autocorrectionDisabled() .focused($isInstanceURLFieldFocused) if let instanceFetchError { Text(instanceFetchError) diff --git a/IceCubesApp/App/Tabs/Timeline/AddRemoteTimelineVIew.swift b/IceCubesApp/App/Tabs/Timeline/AddRemoteTimelineVIew.swift new file mode 100644 index 00000000..9835d218 --- /dev/null +++ b/IceCubesApp/App/Tabs/Timeline/AddRemoteTimelineVIew.swift @@ -0,0 +1,102 @@ +import SwiftUI +import Network +import Models +import Env +import DesignSystem +import NukeUI +import Shimmer + +struct AddRemoteTimelineVIew: View { + @Environment(\.dismiss) private var dismiss + + @EnvironmentObject private var theme: Theme + + @State private var instanceName: String = "" + @State private var instance: Instance? + @State private var instances: [InstanceSocial] = [] + + @FocusState private var isInstanceURLFieldFocused: Bool + + @Binding var addedInstance: String + + var body: some View { + NavigationStack { + Form { + TextField("Instance URL", text: $instanceName) + .listRowBackground(theme.primaryBackgroundColor) + .keyboardType(.URL) + .textContentType(.URL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .focused($isInstanceURLFieldFocused) + if let instance { + Label("\(instance.title) is a valid instance", systemImage: "checkmark.seal.fill") + .foregroundColor(.green) + .listRowBackground(theme.primaryBackgroundColor) + } + Button { + guard instance != nil else { return } + addedInstance = instanceName + dismiss() + } label: { + Text("Add") + } + .listRowBackground(theme.primaryBackgroundColor) + + instancesListView + } + .formStyle(.grouped) + .navigationTitle("Add remote local timeline") + .navigationBarTitleDisplayMode(.inline) + .scrollContentBackground(.hidden) + .background(theme.secondaryBackgroundColor) + .scrollDismissesKeyboard(.immediately) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel", action: { dismiss() }) + } + } + .onChange(of: instanceName, perform: { newValue in + Task { + let client = Client(server: newValue) + instance = try? await client.get(endpoint: Instances.instance) + } + }) + .onAppear { + isInstanceURLFieldFocused = true + let client = InstanceSocialClient() + Task { + self.instances = await client.fetchInstances() + } + } + } + } + + private var instancesListView: some View { + Section("Suggestions") { + if instances.isEmpty { + ProgressView() + .listRowBackground(theme.primaryBackgroundColor) + } else { + ForEach(instanceName.isEmpty ? instances : instances.filter{ $0.name.contains(instanceName.lowercased()) }) { instance in + Button { + self.instanceName = instance.name + } label: { + VStack(alignment: .leading, spacing: 4) { + Text(instance.name) + .font(.headline) + .foregroundColor(.primary) + Text(instance.info?.shortDescription ?? "") + .font(.body) + .foregroundColor(.gray) + Text("\(instance.users) users βΈ± \(instance.statuses) posts") + .font(.footnote) + .foregroundColor(.gray) + } + } + .listRowBackground(theme.primaryBackgroundColor) + } + } + } + } +} diff --git a/IceCubesApp/App/Tabs/TimelineTab.swift b/IceCubesApp/App/Tabs/Timeline/TimelineTab.swift similarity index 50% rename from IceCubesApp/App/Tabs/TimelineTab.swift rename to IceCubesApp/App/Tabs/Timeline/TimelineTab.swift index a9df962f..54073821 100644 --- a/IceCubesApp/App/Tabs/TimelineTab.swift +++ b/IceCubesApp/App/Tabs/Timeline/TimelineTab.swift @@ -7,17 +7,19 @@ import DesignSystem import Models struct TimelineTab: View { - @EnvironmentObject private var appAccounts: AppAccountsManager @EnvironmentObject private var theme: Theme @EnvironmentObject private var currentAccount: CurrentAccount + @EnvironmentObject private var preferences: UserPreferences @EnvironmentObject private var client: Client @StateObject private var routeurPath = RouterPath() @Binding var popToRootTab: Tab + @State private var didAppear: Bool = false @State private var timeline: TimelineFilter = .home @State private var scrollToTopSignal: Int = 0 - @State private var isAddAccountSheetDisplayed = false - @State private var accountsViewModel: [AppAccountViewModel] = [] + + @State private var newlyAddedLocalTimeline: String = "" + @State private var isAddRemoteLocalTimelinePresented: Bool = false var body: some View { NavigationStack(path: $routeurPath.path) { @@ -25,33 +27,29 @@ struct TimelineTab: View { .withAppRouteur() .withSheetDestinations(sheetDestinations: $routeurPath.presentedSheet) .toolbar { - ToolbarTitleMenu { - timelineFilterButton - } - if client.isAuth { - ToolbarItem(placement: .navigationBarLeading) { - accountButton - } - statusEditorToolbarItem(routeurPath: routeurPath, visibility: .pub) - } else { - ToolbarItem(placement: .navigationBarTrailing) { - addAccountButton - } - } + toolbarView } .id(currentAccount.account?.id) } - .sheet(isPresented: $isAddAccountSheetDisplayed) { - AddAccountView() + .sheet(isPresented: $isAddRemoteLocalTimelinePresented) { + AddRemoteTimelineVIew(addedInstance: $newlyAddedLocalTimeline) } .onAppear { routeurPath.client = client - timeline = client.isAuth ? .home : .pub + if !didAppear { + didAppear = true + timeline = client.isAuth ? .home : .federated + } Task { await currentAccount.fetchLists() } } - .environmentObject(routeurPath) + .onChange(of: client.isAuth, perform: { isAuth in + timeline = isAuth ? .home : .federated + }) + .onChange(of: currentAccount.account?.id, perform: { _ in + timeline = client.isAuth ? .home : .federated + }) .onChange(of: $popToRootTab.wrappedValue) { popToRootTab in if popToRootTab == .timeline { if routeurPath.path.isEmpty { @@ -64,6 +62,14 @@ struct TimelineTab: View { .onChange(of: currentAccount.account?.id) { _ in routeurPath.path = [] } + .onChange(of: isAddRemoteLocalTimelinePresented) { isPresented in + if !isPresented && !newlyAddedLocalTimeline.isEmpty { + preferences.remoteLocalTimelines.append(newlyAddedLocalTimeline) + timeline = .remoteLocal(server: newlyAddedLocalTimeline) + newlyAddedLocalTimeline = "" + } + } + .environmentObject(routeurPath) } @@ -99,58 +105,72 @@ struct TimelineTab: View { } } } - } - - private var accountButton: some View { + + if !preferences.remoteLocalTimelines.isEmpty { + Menu("Local Timelines") { + ForEach(preferences.remoteLocalTimelines, id: \.self) { server in + Button { + timeline = .remoteLocal(server: server) + } label: { + Label(server, systemImage: "dot.radiowaves.right") + } + } + } + } + Button { - if let account = currentAccount.account { - routeurPath.navigate(to: .accountDetailWithAccount(account: account)) - } + isAddRemoteLocalTimelinePresented = true } label: { - if let avatar = currentAccount.account?.avatar { - AvatarView(url: avatar, size: .badge) - } - } - .onAppear { - if accountsViewModel.isEmpty || appAccounts.availableAccounts.count != accountsViewModel.count { - accountsViewModel = [] - for account in appAccounts.availableAccounts { - let viewModel: AppAccountViewModel = .init(appAccount: account) - accountsViewModel.append(viewModel) - Task { - await viewModel.fetchAccount() - } - } - } - } - .contextMenu { - ForEach(accountsViewModel, id: \.appAccount.id) { viewModel in - Button { - appAccounts.currentAccount = viewModel.appAccount - timeline = .home - } label: { - HStack { - if viewModel.account?.id == currentAccount.account?.id { - Image(systemName: "checkmark.circle.fill") - } - Text("\(viewModel.account?.displayName ?? "")") - } - } - } - Button { - isAddAccountSheetDisplayed = true - } label: { - Label("Add Account", systemImage: "person.badge.plus") - } + Label("Add a local timeline", systemImage: "badge.plus.radiowaves.right") } } - + private var addAccountButton: some View { Button { - isAddAccountSheetDisplayed = true + routeurPath.presentedSheet = .addAccount } label: { Image(systemName: "person.badge.plus") } } + + @ToolbarContentBuilder + private var toolbarView: some ToolbarContent { + ToolbarTitleMenu { + timelineFilterButton + } + if client.isAuth { + ToolbarItem(placement: .navigationBarLeading) { + AppAccountsSelectorView(routeurPath: routeurPath) + } + statusEditorToolbarItem(routeurPath: routeurPath, visibility: .pub) + } else { + ToolbarItem(placement: .navigationBarTrailing) { + addAccountButton + } + } + switch timeline { + case let .list(list): + ToolbarItem { + Button { + routeurPath.presentedSheet = .listEdit(list: list) + } label: { + Image(systemName: "list.bullet") + } + } + case let .remoteLocal(server): + ToolbarItem { + Button { + preferences.remoteLocalTimelines.removeAll(where: { $0 == server }) + timeline = client.isAuth ? .home : .federated + } label: { + Image(systemName: "pin.slash") + } + } + default: + ToolbarItem { + EmptyView() + } + } + } } diff --git a/Packages/Account/Sources/Account/AccountDetailHeaderView.swift b/Packages/Account/Sources/Account/AccountDetailHeaderView.swift index 235d174d..612db2ec 100644 --- a/Packages/Account/Sources/Account/AccountDetailHeaderView.swift +++ b/Packages/Account/Sources/Account/AccountDetailHeaderView.swift @@ -32,20 +32,26 @@ struct AccountDetailHeaderView: View { private var headerImageView: some View { GeometryReader { proxy in ZStack(alignment: .bottomTrailing) { - LazyImage(url: account.header) { state in - if let image = state.image { - image - .resizingMode(.aspectFill) - } else if state.isLoading { - Color.gray - .frame(height: bannerHeight) - .shimmering() - } else { - Color.gray - .frame(height: bannerHeight) + if reasons.contains(.placeholder) { + Rectangle() + .foregroundColor(.gray) + .frame(height: bannerHeight) + } else { + LazyImage(url: account.header) { state in + if let image = state.image { + image + .resizingMode(.aspectFill) + } else if state.isLoading { + Color.gray + .frame(height: bannerHeight) + .shimmering() + } else { + Color.gray + .frame(height: bannerHeight) + } } + .frame(height: bannerHeight) } - .frame(height: bannerHeight) if relationship?.followedBy == true { Text("Follows You") diff --git a/Packages/Account/Sources/Account/AccountDetailView.swift b/Packages/Account/Sources/Account/AccountDetailView.swift index cf3bf5e7..a983fb08 100644 --- a/Packages/Account/Sources/Account/AccountDetailView.swift +++ b/Packages/Account/Sources/Account/AccountDetailView.swift @@ -112,6 +112,7 @@ public struct AccountDetailView: View { scrollViewProxy: proxy, scrollOffset: $scrollOffset) .redacted(reason: .placeholder) + .shimmering() case let .data(account): AccountDetailHeaderView(isCurrentUser: isCurrentUser, account: account, diff --git a/Packages/DesignSystem/Sources/DesignSystem/Views/AvatarView.swift b/Packages/DesignSystem/Sources/DesignSystem/Views/AvatarView.swift index d51eed7e..07d86ef8 100644 --- a/Packages/DesignSystem/Sources/DesignSystem/Views/AvatarView.swift +++ b/Packages/DesignSystem/Sources/DesignSystem/Views/AvatarView.swift @@ -3,6 +3,7 @@ import Shimmer import NukeUI public struct AvatarView: View { + @Environment(\.redactionReasons) private var reasons @EnvironmentObject private var theme: Theme public enum Size { @@ -33,7 +34,6 @@ public struct AvatarView: View { } } - @Environment(\.redactionReasons) private var reasons public let url: URL public let size: Size @@ -47,7 +47,7 @@ public struct AvatarView: View { if reasons == .placeholder { RoundedRectangle(cornerRadius: size.cornerRadius) .fill(.gray) - .frame(maxWidth: size.size.width, maxHeight: size.size.height) + .frame(width: size.size.width, height: size.size.height) } else { LazyImage(url: url) { state in if let image = state.image { diff --git a/Packages/Env/Sources/Env/Ext/AppStorage.swift b/Packages/Env/Sources/Env/Ext/AppStorage.swift new file mode 100644 index 00000000..20676120 --- /dev/null +++ b/Packages/Env/Sources/Env/Ext/AppStorage.swift @@ -0,0 +1,21 @@ +import Foundation + +extension Array: RawRepresentable where Element: Codable { + public init?(rawValue: String) { + guard let data = rawValue.data(using: .utf8), + let result = try? JSONDecoder().decode([Element].self, from: data) + else { + return nil + } + self = result + } + + public var rawValue: String { + guard let data = try? JSONEncoder().encode(self), + let result = String(data: data, encoding: .utf8) + else { + return "[]" + } + return result + } +} diff --git a/Packages/Env/Sources/Env/Routeur.swift b/Packages/Env/Sources/Env/Routeur.swift index 786a4ed0..5673a4a4 100644 --- a/Packages/Env/Sources/Env/Routeur.swift +++ b/Packages/Env/Sources/Env/Routeur.swift @@ -7,6 +7,7 @@ public enum RouteurDestinations: Hashable { case accountDetail(id: String) case accountDetailWithAccount(account: Account) case statusDetail(id: String) + case remoteStatusDetail(url: URL) case hashTag(tag: String, account: String?) case list(list: Models.List) case followers(id: String) @@ -23,6 +24,7 @@ public enum SheetDestinations: Identifiable { case mentionStatusEditor(account: Account, visibility: Models.Visibility) case listEdit(list: Models.List) case listAddAccount(account: Account) + case addAccount public var id: String { switch self { @@ -32,6 +34,8 @@ public enum SheetDestinations: Identifiable { return "listEdit" case .listAddAccount: return "listAddAccount" + case .addAccount: + return "addAccount" } } } @@ -62,9 +66,7 @@ public class RouterPath: ObservableObject { if url.absoluteString.contains(client.server) { navigate(to: .statusDetail(id: String(id))) } else { - Task { - await navigateToStatusFrom(url: url) - } + navigate(to: .remoteStatusDetail(url: url)) } return .handled } @@ -85,23 +87,7 @@ public class RouterPath: ObservableObject { } return .systemAction } - - public func navigateToStatusFrom(url: URL) async { - guard let client else { return } - Task { - let results: SearchResults? = try? await client.get(endpoint: Search.search(query: url.absoluteString, - type: "statuses", - offset: nil, - following: nil), - forceVersion: .v2) - if let status = results?.statuses.first { - navigate(to: .statusDetail(id: status.id)) - } else { - await UIApplication.shared.open(url) - } - } - } - + public func navigateToAccountFrom(acct: String, url: URL) async { guard let client else { return } Task { @@ -117,4 +103,20 @@ public class RouterPath: ObservableObject { } } } + + public func navigateToAccountFrom(url: URL) async { + guard let client else { return } + Task { + let results: SearchResults? = try? await client.get(endpoint: Search.search(query: url.absoluteString, + type: "accounts", + offset: nil, + following: nil), + forceVersion: .v2) + if let account = results?.accounts.first { + navigate(to: .accountDetailWithAccount(account: account)) + } else { + await UIApplication.shared.open(url) + } + } + } } diff --git a/Packages/Env/Sources/Env/UserPreferences.swift b/Packages/Env/Sources/Env/UserPreferences.swift new file mode 100644 index 00000000..84416f18 --- /dev/null +++ b/Packages/Env/Sources/Env/UserPreferences.swift @@ -0,0 +1,8 @@ +import SwiftUI +import Foundation + +public class UserPreferences: ObservableObject { + @AppStorage("remote_local_timeline") public var remoteLocalTimelines: [String] = [] + + public init() { } +} diff --git a/Packages/Status/Sources/Status/Detail/StatusDetailVIew.swift b/Packages/Status/Sources/Status/Detail/StatusDetailVIew.swift index 3c78980c..c3c8848e 100644 --- a/Packages/Status/Sources/Status/Detail/StatusDetailVIew.swift +++ b/Packages/Status/Sources/Status/Detail/StatusDetailVIew.swift @@ -18,6 +18,10 @@ public struct StatusDetailView: View { _viewModel = StateObject(wrappedValue: .init(statusId: statusId)) } + public init(remoteStatusURL: URL) { + _viewModel = StateObject(wrappedValue: .init(remoteStatusURL: remoteStatusURL)) + } + public var body: some View { ScrollViewReader { proxy in ScrollView { @@ -70,7 +74,10 @@ public struct StatusDetailView: View { guard !isLoaded else { return } isLoaded = true viewModel.client = client - await viewModel.fetchStatusDetail() + let result = await viewModel.fetch() + if !result { + _ = routeurPath.path.popLast() + } DispatchQueue.main.async { proxy.scrollTo(viewModel.statusId, anchor: .center) } diff --git a/Packages/Status/Sources/Status/Detail/StatusDetailViewModel.swift b/Packages/Status/Sources/Status/Detail/StatusDetailViewModel.swift index 59334223..81417b8a 100644 --- a/Packages/Status/Sources/Status/Detail/StatusDetailViewModel.swift +++ b/Packages/Status/Sources/Status/Detail/StatusDetailViewModel.swift @@ -5,7 +5,8 @@ import Network @MainActor class StatusDetailViewModel: ObservableObject { - public let statusId: String + public var statusId: String? + public var remoteStatusURL: URL? var client: Client? @@ -19,10 +20,44 @@ class StatusDetailViewModel: ObservableObject { init(statusId: String) { state = .loading self.statusId = statusId + self.remoteStatusURL = nil } - func fetchStatusDetail() async { - guard let client else { return } + init(remoteStatusURL: URL) { + state = .loading + self.remoteStatusURL = remoteStatusURL + self.statusId = nil + } + + func fetch() async -> Bool { + if statusId != nil { + await fetchStatusDetail() + return true + } else if remoteStatusURL != nil { + return await fetchRemoteStatus() + } + return false + } + + private func fetchRemoteStatus() async -> Bool { + guard let client, let remoteStatusURL else { return false } + let results: SearchResults? = try? await client.get(endpoint: Search.search(query: remoteStatusURL.absoluteString, + type: "statuses", + offset: nil, + following: nil), + forceVersion: .v2) + if let statusId = results?.statuses.first?.id { + self.statusId = statusId + await fetchStatusDetail() + return true + } else { + await UIApplication.shared.open(remoteStatusURL) + return false + } + } + + private func fetchStatusDetail() async { + guard let client, let statusId else { return } do { let status: Status = try await client.get(endpoint: Statuses.status(id: statusId)) let context: StatusContext = try await client.get(endpoint: Statuses.context(id: statusId)) diff --git a/Packages/Status/Sources/Status/List/StatusesListView.swift b/Packages/Status/Sources/Status/List/StatusesListView.swift index c9e090de..b1d3b7d8 100644 --- a/Packages/Status/Sources/Status/List/StatusesListView.swift +++ b/Packages/Status/Sources/Status/List/StatusesListView.swift @@ -5,9 +5,11 @@ import DesignSystem public struct StatusesListView: View where Fetcher: StatusesFetcher { @ObservedObject private var fetcher: Fetcher + private let isRemote: Bool - public init(fetcher: Fetcher) { + public init(fetcher: Fetcher, isRemote: Bool = false) { self.fetcher = fetcher + self.isRemote = isRemote } public var body: some View { @@ -26,7 +28,7 @@ public struct StatusesListView: View where Fetcher: StatusesFetcher { Text(error.localizedDescription) case let .display(statuses, nextPageState): ForEach(statuses, id: \.viewId) { status in - StatusRowView(viewModel: .init(status: status, isCompact: false)) + StatusRowView(viewModel: .init(status: status, isCompact: false, isRemote: isRemote)) .id(status.id) .padding(.horizontal, .layoutPadding) Divider() diff --git a/Packages/Status/Sources/Status/Row/StatusRowContextMenu.swift b/Packages/Status/Sources/Status/Row/StatusRowContextMenu.swift index 2d97a487..417db5c9 100644 --- a/Packages/Status/Sources/Status/Row/StatusRowContextMenu.swift +++ b/Packages/Status/Sources/Status/Row/StatusRowContextMenu.swift @@ -8,26 +8,29 @@ struct StatusRowContextMenu: View { @ObservedObject var viewModel: StatusRowViewModel var body: some View { - Button { Task { - if viewModel.isFavourited { - await viewModel.unFavourite() - } else { - await viewModel.favourite() + if !viewModel.isRemote { + Button { Task { + if viewModel.isFavourited { + await viewModel.unFavourite() + } else { + await viewModel.favourite() + } + } } label: { + Label(viewModel.isFavourited ? "Unfavorite" : "Favorite", systemImage: "star") } - } } label: { - Label(viewModel.isFavourited ? "Unfavorite" : "Favorite", systemImage: "star") - } - Button { Task { - if viewModel.isReblogged { - await viewModel.unReblog() - } else { - await viewModel.reblog() + Button { Task { + if viewModel.isReblogged { + await viewModel.unReblog() + } else { + await viewModel.reblog() + } + } } label: { + Label(viewModel.isReblogged ? "Unboost" : "Boost", systemImage: "arrow.left.arrow.right.circle") } - } } label: { - Label(viewModel.isReblogged ? "Unboost" : "Boost", systemImage: "arrow.left.arrow.right.circle") + } - if viewModel.status.visibility == .pub { + if viewModel.status.visibility == .pub, !viewModel.isRemote { Button { routeurPath.presentedSheet = .quoteStatusEditor(status: viewModel.status) } label: { @@ -63,7 +66,7 @@ struct StatusRowContextMenu: View { Label("Delete", systemImage: "trash") } } - } else { + } else if !viewModel.isRemote { Section(viewModel.status.account.acct) { Button { routeurPath.presentedSheet = .mentionStatusEditor(account: viewModel.status.account, visibility: .pub) diff --git a/Packages/Status/Sources/Status/Row/StatusRowView.swift b/Packages/Status/Sources/Status/Row/StatusRowView.swift index bc5a99cd..c2da4dad 100644 --- a/Packages/Status/Sources/Status/Row/StatusRowView.swift +++ b/Packages/Status/Sources/Status/Row/StatusRowView.swift @@ -42,13 +42,13 @@ public struct StatusRowView: View { replyView } statusView - if !viewModel.isCompact && viewModel.showActions { + if !viewModel.isCompact && !viewModel.isRemote { StatusActionsView(viewModel: viewModel) .padding(.vertical, 8) .tint(viewModel.isFocused ? theme.tintColor : .gray) .contentShape(Rectangle()) .onTapGesture { - routeurPath.navigate(to: .statusDetail(id: viewModel.status.reblog?.id ?? viewModel.status.id)) + viewModel.navigateToDetail(routeurPath: routeurPath) } } } @@ -93,7 +93,13 @@ public struct StatusRowView: View { .foregroundColor(.gray) .fontWeight(.semibold) .onTapGesture { - routeurPath.navigate(to: .accountDetailWithAccount(account: viewModel.status.account)) + if viewModel.isRemote, let url = viewModel.status.account.url { + Task { + await routeurPath.navigateToAccountFrom(url: url) + } + } else { + routeurPath.navigate(to: .accountDetailWithAccount(account: viewModel.status.account)) + } } } } @@ -111,7 +117,13 @@ public struct StatusRowView: View { .foregroundColor(.gray) .fontWeight(.semibold) .onTapGesture { - routeurPath.navigate(to: .accountDetail(id: mention.id)) + if viewModel.isRemote { + Task { + await routeurPath.navigateToAccountFrom(url: mention.url) + } + } else { + routeurPath.navigate(to: .accountDetail(id: mention.id)) + } } } } @@ -122,7 +134,13 @@ public struct StatusRowView: View { if !viewModel.isCompact { HStack(alignment: .top) { Button { - routeurPath.navigate(to: .accountDetailWithAccount(account: status.account)) + if viewModel.isRemote, let url = status.account.url { + Task { + await routeurPath.navigateToAccountFrom(url: url) + } + } else { + routeurPath.navigate(to: .accountDetailWithAccount(account: status.account)) + } } label: { accountView(status: status) }.buttonStyle(.plain) @@ -180,7 +198,7 @@ public struct StatusRowView: View { } .contentShape(Rectangle()) .onTapGesture { - routeurPath.navigate(to: .statusDetail(id: viewModel.status.reblog?.id ?? viewModel.status.id)) + viewModel.navigateToDetail(routeurPath: routeurPath) } } diff --git a/Packages/Status/Sources/Status/Row/StatusRowViewModel.swift b/Packages/Status/Sources/Status/Row/StatusRowViewModel.swift index fb4b24d8..52386b49 100644 --- a/Packages/Status/Sources/Status/Row/StatusRowViewModel.swift +++ b/Packages/Status/Sources/Status/Row/StatusRowViewModel.swift @@ -1,13 +1,14 @@ import SwiftUI import Models import Network +import Env @MainActor public class StatusRowViewModel: ObservableObject { let status: Status let isCompact: Bool let isFocused: Bool - let showActions: Bool + let isRemote: Bool @Published var favouritesCount: Int @Published var isFavourited: Bool @@ -29,11 +30,11 @@ public class StatusRowViewModel: ObservableObject { public init(status: Status, isCompact: Bool = false, isFocused: Bool = false, - showActions: Bool = true) { + isRemote: Bool = false) { self.status = status self.isCompact = isCompact self.isFocused = isFocused - self.showActions = showActions + self.isRemote = isRemote if let reblog = status.reblog { self.isFavourited = reblog.favourited == true self.isReblogged = reblog.reblogged == true @@ -51,6 +52,14 @@ public class StatusRowViewModel: ObservableObject { self.isFiltered = filter != nil } + func navigateToDetail(routeurPath: RouterPath) { + if isRemote, let url = status.reblog?.url ?? status.url { + routeurPath.navigate(to: .remoteStatusDetail(url: url)) + } else { + routeurPath.navigate(to: .statusDetail(id: status.reblog?.id ?? status.id)) + } + } + func loadEmbededStatus() async { guard let client, let urls = status.content.findStatusesURLs(), diff --git a/Packages/Timeline/Sources/Timeline/TimelineFilter.swift b/Packages/Timeline/Sources/Timeline/TimelineFilter.swift index 4105a222..d0f70812 100644 --- a/Packages/Timeline/Sources/Timeline/TimelineFilter.swift +++ b/Packages/Timeline/Sources/Timeline/TimelineFilter.swift @@ -3,9 +3,10 @@ import Models import Network public enum TimelineFilter: Hashable, Equatable { - case pub, local, home, trending + case federated, local, home, trending case hashtag(tag: String, accountId: String?) case list(list: List) + case remoteLocal(server: String) public func hash(into hasher: inout Hasher) { hasher.combine(title()) @@ -13,14 +14,14 @@ public enum TimelineFilter: Hashable, Equatable { public static func availableTimeline(client: Client) -> [TimelineFilter] { if !client.isAuth { - return [.pub, .local, .trending] + return [.federated, .local, .trending] } - return [.pub, .local, .trending, .home] + return [.federated, .local, .trending, .home] } public func title() -> String { switch self { - case .pub: + case .federated: return "Federated" case .local: return "Local" @@ -32,12 +33,14 @@ public enum TimelineFilter: Hashable, Equatable { return "#\(tag)" case let .list(list): return list.title + case let .remoteLocal(server): + return server } } public func iconName() -> String? { switch self { - case .pub: + case .federated: return "globe.americas" case .local: return "person.3" @@ -47,6 +50,8 @@ public enum TimelineFilter: Hashable, Equatable { return "house" case .list(_): return "list.bullet" + case .remoteLocal: + return "dot.radiowaves.right" default: return nil } @@ -54,8 +59,9 @@ public enum TimelineFilter: Hashable, Equatable { public func endpoint(sinceId: String?, maxId: String?, minId: String?, offset: Int?) -> Endpoint { switch self { - case .pub: return Timelines.pub(sinceId: sinceId, maxId: maxId, minId: minId, local: false) + case .federated: return Timelines.pub(sinceId: sinceId, maxId: maxId, minId: minId, local: false) case .local: return Timelines.pub(sinceId: sinceId, maxId: maxId, minId: minId, local: true) + case .remoteLocal: 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) diff --git a/Packages/Timeline/Sources/Timeline/TimelineView.swift b/Packages/Timeline/Sources/Timeline/TimelineView.swift index 14620c86..36ea44aa 100644 --- a/Packages/Timeline/Sources/Timeline/TimelineView.swift +++ b/Packages/Timeline/Sources/Timeline/TimelineView.swift @@ -41,7 +41,12 @@ public struct TimelineView: View { LazyVStack { tagHeaderView .padding(.bottom, 16) - StatusesListView(fetcher: viewModel) + switch viewModel.timeline { + case .remoteLocal: + StatusesListView(fetcher: viewModel, isRemote: true) + default: + StatusesListView(fetcher: viewModel) + } } .padding(.top, .layoutPadding) } @@ -55,33 +60,19 @@ 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 - viewModel.timeline = timeline + if viewModel.client == nil { + viewModel.client = client + viewModel.timeline = timeline + } } .refreshable { feedbackGenerator.impactOccurred(intensity: 0.3) await viewModel.fetchStatuses(userIntent: true) feedbackGenerator.impactOccurred(intensity: 0.7) } - .onChange(of: watcher.latestEvent?.id) { id in + .onChange(of: watcher.latestEvent?.id) { _ in if let latestEvent = watcher.latestEvent { viewModel.handleEvent(event: latestEvent, currentAccount: account) } @@ -92,7 +83,13 @@ public struct TimelineView: View { } }) .onChange(of: timeline) { newTimeline in - viewModel.timeline = timeline + switch newTimeline { + case let .remoteLocal(server): + viewModel.client = Client(server: server) + default: + viewModel.client = client + } + viewModel.timeline = newTimeline } .onChange(of: scenePhase, perform: { scenePhase in switch scenePhase { diff --git a/Packages/Timeline/Sources/Timeline/TimelineViewModel.swift b/Packages/Timeline/Sources/Timeline/TimelineViewModel.swift index c76c643a..c4b49237 100644 --- a/Packages/Timeline/Sources/Timeline/TimelineViewModel.swift +++ b/Packages/Timeline/Sources/Timeline/TimelineViewModel.swift @@ -18,7 +18,7 @@ class TimelineViewModel: ObservableObject, StatusesFetcher { private var statuses: [Status] = [] @Published var statusesState: StatusesState = .loading - @Published var timeline: TimelineFilter = .pub { + @Published var timeline: TimelineFilter = .federated { didSet { Task { if oldValue != timeline {