mirror of
https://github.com/Dimillian/IceCubesApp.git
synced 2024-11-22 16:31:00 +00:00
Search & Pin remote local timeline + general polish
This commit is contained in:
parent
27f0ee45b7
commit
f922ba344d
25 changed files with 493 additions and 158 deletions
|
@ -28,6 +28,8 @@
|
||||||
9F7335EA2966B3F800AFF0BA /* Conversations in Frameworks */ = {isa = PBXBuildFile; productRef = 9F7335E92966B3F800AFF0BA /* Conversations */; };
|
9F7335EA2966B3F800AFF0BA /* Conversations in Frameworks */ = {isa = PBXBuildFile; productRef = 9F7335E92966B3F800AFF0BA /* Conversations */; };
|
||||||
9F7335ED2967463400AFF0BA /* AVKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9F7335EB2967461B00AFF0BA /* AVKit.framework */; };
|
9F7335ED2967463400AFF0BA /* AVKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9F7335EB2967461B00AFF0BA /* AVKit.framework */; };
|
||||||
9F7335EF29674F7100AFF0BA /* QuickLook.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9F7335EE29674F7100AFF0BA /* QuickLook.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 */; };
|
9FAE4ACB293783B000772766 /* SettingsTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAE4ACA293783B000772766 /* SettingsTab.swift */; };
|
||||||
9FAE4ACE29379A5A00772766 /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 9FAE4ACD29379A5A00772766 /* KeychainSwift */; };
|
9FAE4ACE29379A5A00772766 /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 9FAE4ACD29379A5A00772766 /* KeychainSwift */; };
|
||||||
9FAE4AD129379AD600772766 /* AppAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAE4AD029379AD600772766 /* AppAccount.swift */; };
|
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 = "<group>"; };
|
9F7335E82966B3DC00AFF0BA /* Conversations */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Conversations; path = Packages/Conversations; sourceTree = "<group>"; };
|
||||||
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; };
|
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; };
|
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 = "<group>"; };
|
||||||
|
9F7335F62968274500AFF0BA /* AppAccountsSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAccountsSelectorView.swift; sourceTree = "<group>"; };
|
||||||
9FAE4AC8293774FF00772766 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
9FAE4AC8293774FF00772766 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||||
9FAE4ACA293783B000772766 /* SettingsTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsTab.swift; sourceTree = "<group>"; };
|
9FAE4ACA293783B000772766 /* SettingsTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsTab.swift; sourceTree = "<group>"; };
|
||||||
9FAE4AD029379AD600772766 /* AppAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAccount.swift; sourceTree = "<group>"; };
|
9FAE4AD029379AD600772766 /* AppAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAccount.swift; sourceTree = "<group>"; };
|
||||||
|
@ -118,11 +122,20 @@
|
||||||
path = Resources;
|
path = Resources;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
9F7335F02967607A00AFF0BA /* Timeline */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
9F398AB229360A4C00A889F2 /* TimelineTab.swift */,
|
||||||
|
9F7335F12967608F00AFF0BA /* AddRemoteTimelineVIew.swift */,
|
||||||
|
);
|
||||||
|
path = Timeline;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
9FAE4AC9293783A200772766 /* Tabs */ = {
|
9FAE4AC9293783A200772766 /* Tabs */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
9F7335F02967607A00AFF0BA /* Timeline */,
|
||||||
9FE151A4293C90EA00E9683D /* Settings */,
|
9FE151A4293C90EA00E9683D /* Settings */,
|
||||||
9F398AB229360A4C00A889F2 /* TimelineTab.swift */,
|
|
||||||
9F35DB4629506F6600B3281A /* NotificationTab.swift */,
|
9F35DB4629506F6600B3281A /* NotificationTab.swift */,
|
||||||
9F35DB4B2952005C00B3281A /* MessagesTab.swift */,
|
9F35DB4B2952005C00B3281A /* MessagesTab.swift */,
|
||||||
9F55C68C2955968700F94077 /* ExploreTab.swift */,
|
9F55C68C2955968700F94077 /* ExploreTab.swift */,
|
||||||
|
@ -138,6 +151,7 @@
|
||||||
9FAE4AD22937A0C600772766 /* AppAccountsManager.swift */,
|
9FAE4AD22937A0C600772766 /* AppAccountsManager.swift */,
|
||||||
9F2B92FE295EB87100DE16D0 /* AppAccountView.swift */,
|
9F2B92FE295EB87100DE16D0 /* AppAccountView.swift */,
|
||||||
9F2B9300295EB8A100DE16D0 /* AppAccountViewModel.swift */,
|
9F2B9300295EB8A100DE16D0 /* AppAccountViewModel.swift */,
|
||||||
|
9F7335F62968274500AFF0BA /* AppAccountsSelectorView.swift */,
|
||||||
);
|
);
|
||||||
path = AppAccounts;
|
path = AppAccounts;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -301,6 +315,8 @@
|
||||||
9FBFE63D292A715500C250E9 /* IceCubesApp.swift in Sources */,
|
9FBFE63D292A715500C250E9 /* IceCubesApp.swift in Sources */,
|
||||||
9F2B92FA295DA7D700DE16D0 /* AddAccountsView.swift in Sources */,
|
9F2B92FA295DA7D700DE16D0 /* AddAccountsView.swift in Sources */,
|
||||||
9F35DB4729506F6600B3281A /* NotificationTab.swift in Sources */,
|
9F35DB4729506F6600B3281A /* NotificationTab.swift in Sources */,
|
||||||
|
9F7335F22967608F00AFF0BA /* AddRemoteTimelineVIew.swift in Sources */,
|
||||||
|
9F7335F72968274500AFF0BA /* AppAccountsSelectorView.swift in Sources */,
|
||||||
9FAE4AD129379AD600772766 /* AppAccount.swift in Sources */,
|
9FAE4AD129379AD600772766 /* AppAccount.swift in Sources */,
|
||||||
9F55C68D2955968700F94077 /* ExploreTab.swift in Sources */,
|
9F55C68D2955968700F94077 /* ExploreTab.swift in Sources */,
|
||||||
);
|
);
|
||||||
|
|
64
IceCubesApp/App/AppAccounts/AppAccountsSelectorView.swift
Normal file
64
IceCubesApp/App/AppAccounts/AppAccountsSelectorView.swift
Normal file
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -16,6 +16,8 @@ extension View {
|
||||||
AccountDetailView(account: account)
|
AccountDetailView(account: account)
|
||||||
case let .statusDetail(id):
|
case let .statusDetail(id):
|
||||||
StatusDetailView(statusId: id)
|
StatusDetailView(statusId: id)
|
||||||
|
case let .remoteStatusDetail(url):
|
||||||
|
StatusDetailView(remoteStatusURL: url)
|
||||||
case let .hashTag(tag, accountId):
|
case let .hashTag(tag, accountId):
|
||||||
TimelineView(timeline: .constant(.hashtag(tag: tag, accountId: accountId)), scrollToTopSignal: .constant(0))
|
TimelineView(timeline: .constant(.hashtag(tag: tag, accountId: accountId)), scrollToTopSignal: .constant(0))
|
||||||
case let .list(list):
|
case let .list(list):
|
||||||
|
@ -49,6 +51,8 @@ extension View {
|
||||||
ListEditView(list: list)
|
ListEditView(list: list)
|
||||||
case let .listAddAccount(account):
|
case let .listAddAccount(account):
|
||||||
ListAddAccountView(account: account)
|
ListAddAccountView(account: account)
|
||||||
|
case .addAccount:
|
||||||
|
AddAccountView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,6 +13,7 @@ struct IceCubesApp: App {
|
||||||
@StateObject private var appAccountsManager = AppAccountsManager()
|
@StateObject private var appAccountsManager = AppAccountsManager()
|
||||||
@StateObject private var currentInstance = CurrentInstance()
|
@StateObject private var currentInstance = CurrentInstance()
|
||||||
@StateObject private var currentAccount = CurrentAccount()
|
@StateObject private var currentAccount = CurrentAccount()
|
||||||
|
@StateObject private var userPreferences = UserPreferences()
|
||||||
@StateObject private var watcher = StreamWatcher()
|
@StateObject private var watcher = StreamWatcher()
|
||||||
@StateObject private var quickLook = QuickLook()
|
@StateObject private var quickLook = QuickLook()
|
||||||
@StateObject private var theme = Theme()
|
@StateObject private var theme = Theme()
|
||||||
|
@ -39,6 +40,7 @@ struct IceCubesApp: App {
|
||||||
.environmentObject(quickLook)
|
.environmentObject(quickLook)
|
||||||
.environmentObject(currentAccount)
|
.environmentObject(currentAccount)
|
||||||
.environmentObject(currentInstance)
|
.environmentObject(currentInstance)
|
||||||
|
.environmentObject(userPreferences)
|
||||||
.environmentObject(theme)
|
.environmentObject(theme)
|
||||||
.environmentObject(watcher)
|
.environmentObject(watcher)
|
||||||
.quickLookPreview($quickLook.url, in: quickLook.urls)
|
.quickLookPreview($quickLook.url, in: quickLook.urls)
|
||||||
|
|
|
@ -19,6 +19,9 @@ struct ExploreTab: View {
|
||||||
.withSheetDestinations(sheetDestinations: $routeurPath.presentedSheet)
|
.withSheetDestinations(sheetDestinations: $routeurPath.presentedSheet)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
statusEditorToolbarItem(routeurPath: routeurPath, visibility: .pub)
|
statusEditorToolbarItem(routeurPath: routeurPath, visibility: .pub)
|
||||||
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
|
AppAccountsSelectorView(routeurPath: routeurPath)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.environmentObject(routeurPath)
|
.environmentObject(routeurPath)
|
||||||
|
|
|
@ -19,6 +19,11 @@ struct MessagesTab: View {
|
||||||
ConversationsListView()
|
ConversationsListView()
|
||||||
.withAppRouteur()
|
.withAppRouteur()
|
||||||
.withSheetDestinations(sheetDestinations: $routeurPath.presentedSheet)
|
.withSheetDestinations(sheetDestinations: $routeurPath.presentedSheet)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
|
AppAccountsSelectorView(routeurPath: routeurPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
.id(currentAccount.account?.id)
|
.id(currentAccount.account?.id)
|
||||||
}
|
}
|
||||||
.environmentObject(routeurPath)
|
.environmentObject(routeurPath)
|
||||||
|
|
|
@ -18,6 +18,9 @@ struct NotificationsTab: View {
|
||||||
.withSheetDestinations(sheetDestinations: $routeurPath.presentedSheet)
|
.withSheetDestinations(sheetDestinations: $routeurPath.presentedSheet)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
statusEditorToolbarItem(routeurPath: routeurPath, visibility: .pub)
|
statusEditorToolbarItem(routeurPath: routeurPath, visibility: .pub)
|
||||||
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
|
AppAccountsSelectorView(routeurPath: routeurPath)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.id(currentAccount.account?.id)
|
.id(currentAccount.account?.id)
|
||||||
}
|
}
|
||||||
|
|
|
@ -32,6 +32,7 @@ struct AddAccountView: View {
|
||||||
.keyboardType(.URL)
|
.keyboardType(.URL)
|
||||||
.textContentType(.URL)
|
.textContentType(.URL)
|
||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
.focused($isInstanceURLFieldFocused)
|
.focused($isInstanceURLFieldFocused)
|
||||||
if let instanceFetchError {
|
if let instanceFetchError {
|
||||||
Text(instanceFetchError)
|
Text(instanceFetchError)
|
||||||
|
|
102
IceCubesApp/App/Tabs/Timeline/AddRemoteTimelineVIew.swift
Normal file
102
IceCubesApp/App/Tabs/Timeline/AddRemoteTimelineVIew.swift
Normal file
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -7,17 +7,19 @@ import DesignSystem
|
||||||
import Models
|
import Models
|
||||||
|
|
||||||
struct TimelineTab: View {
|
struct TimelineTab: View {
|
||||||
@EnvironmentObject private var appAccounts: AppAccountsManager
|
|
||||||
@EnvironmentObject private var theme: Theme
|
@EnvironmentObject private var theme: Theme
|
||||||
@EnvironmentObject private var currentAccount: CurrentAccount
|
@EnvironmentObject private var currentAccount: CurrentAccount
|
||||||
|
@EnvironmentObject private var preferences: UserPreferences
|
||||||
@EnvironmentObject private var client: Client
|
@EnvironmentObject private var client: Client
|
||||||
@StateObject private var routeurPath = RouterPath()
|
@StateObject private var routeurPath = RouterPath()
|
||||||
@Binding var popToRootTab: Tab
|
@Binding var popToRootTab: Tab
|
||||||
|
|
||||||
|
@State private var didAppear: Bool = false
|
||||||
@State private var timeline: TimelineFilter = .home
|
@State private var timeline: TimelineFilter = .home
|
||||||
@State private var scrollToTopSignal: Int = 0
|
@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 {
|
var body: some View {
|
||||||
NavigationStack(path: $routeurPath.path) {
|
NavigationStack(path: $routeurPath.path) {
|
||||||
|
@ -25,33 +27,29 @@ struct TimelineTab: View {
|
||||||
.withAppRouteur()
|
.withAppRouteur()
|
||||||
.withSheetDestinations(sheetDestinations: $routeurPath.presentedSheet)
|
.withSheetDestinations(sheetDestinations: $routeurPath.presentedSheet)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarTitleMenu {
|
toolbarView
|
||||||
timelineFilterButton
|
|
||||||
}
|
|
||||||
if client.isAuth {
|
|
||||||
ToolbarItem(placement: .navigationBarLeading) {
|
|
||||||
accountButton
|
|
||||||
}
|
|
||||||
statusEditorToolbarItem(routeurPath: routeurPath, visibility: .pub)
|
|
||||||
} else {
|
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
|
||||||
addAccountButton
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.id(currentAccount.account?.id)
|
.id(currentAccount.account?.id)
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $isAddAccountSheetDisplayed) {
|
.sheet(isPresented: $isAddRemoteLocalTimelinePresented) {
|
||||||
AddAccountView()
|
AddRemoteTimelineVIew(addedInstance: $newlyAddedLocalTimeline)
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
routeurPath.client = client
|
routeurPath.client = client
|
||||||
timeline = client.isAuth ? .home : .pub
|
if !didAppear {
|
||||||
|
didAppear = true
|
||||||
|
timeline = client.isAuth ? .home : .federated
|
||||||
|
}
|
||||||
Task {
|
Task {
|
||||||
await currentAccount.fetchLists()
|
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
|
.onChange(of: $popToRootTab.wrappedValue) { popToRootTab in
|
||||||
if popToRootTab == .timeline {
|
if popToRootTab == .timeline {
|
||||||
if routeurPath.path.isEmpty {
|
if routeurPath.path.isEmpty {
|
||||||
|
@ -64,6 +62,14 @@ struct TimelineTab: View {
|
||||||
.onChange(of: currentAccount.account?.id) { _ in
|
.onChange(of: currentAccount.account?.id) { _ in
|
||||||
routeurPath.path = []
|
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 {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
if !preferences.remoteLocalTimelines.isEmpty {
|
||||||
private var accountButton: some View {
|
Menu("Local Timelines") {
|
||||||
|
ForEach(preferences.remoteLocalTimelines, id: \.self) { server in
|
||||||
|
Button {
|
||||||
|
timeline = .remoteLocal(server: server)
|
||||||
|
} label: {
|
||||||
|
Label(server, systemImage: "dot.radiowaves.right")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
if let account = currentAccount.account {
|
isAddRemoteLocalTimelinePresented = true
|
||||||
routeurPath.navigate(to: .accountDetailWithAccount(account: account))
|
|
||||||
}
|
|
||||||
} label: {
|
} label: {
|
||||||
if let avatar = currentAccount.account?.avatar {
|
Label("Add a local timeline", systemImage: "badge.plus.radiowaves.right")
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var addAccountButton: some View {
|
private var addAccountButton: some View {
|
||||||
Button {
|
Button {
|
||||||
isAddAccountSheetDisplayed = true
|
routeurPath.presentedSheet = .addAccount
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "person.badge.plus")
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -32,20 +32,26 @@ struct AccountDetailHeaderView: View {
|
||||||
private var headerImageView: some View {
|
private var headerImageView: some View {
|
||||||
GeometryReader { proxy in
|
GeometryReader { proxy in
|
||||||
ZStack(alignment: .bottomTrailing) {
|
ZStack(alignment: .bottomTrailing) {
|
||||||
LazyImage(url: account.header) { state in
|
if reasons.contains(.placeholder) {
|
||||||
if let image = state.image {
|
Rectangle()
|
||||||
image
|
.foregroundColor(.gray)
|
||||||
.resizingMode(.aspectFill)
|
.frame(height: bannerHeight)
|
||||||
} else if state.isLoading {
|
} else {
|
||||||
Color.gray
|
LazyImage(url: account.header) { state in
|
||||||
.frame(height: bannerHeight)
|
if let image = state.image {
|
||||||
.shimmering()
|
image
|
||||||
} else {
|
.resizingMode(.aspectFill)
|
||||||
Color.gray
|
} else if state.isLoading {
|
||||||
.frame(height: bannerHeight)
|
Color.gray
|
||||||
|
.frame(height: bannerHeight)
|
||||||
|
.shimmering()
|
||||||
|
} else {
|
||||||
|
Color.gray
|
||||||
|
.frame(height: bannerHeight)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.frame(height: bannerHeight)
|
||||||
}
|
}
|
||||||
.frame(height: bannerHeight)
|
|
||||||
|
|
||||||
if relationship?.followedBy == true {
|
if relationship?.followedBy == true {
|
||||||
Text("Follows You")
|
Text("Follows You")
|
||||||
|
|
|
@ -112,6 +112,7 @@ public struct AccountDetailView: View {
|
||||||
scrollViewProxy: proxy,
|
scrollViewProxy: proxy,
|
||||||
scrollOffset: $scrollOffset)
|
scrollOffset: $scrollOffset)
|
||||||
.redacted(reason: .placeholder)
|
.redacted(reason: .placeholder)
|
||||||
|
.shimmering()
|
||||||
case let .data(account):
|
case let .data(account):
|
||||||
AccountDetailHeaderView(isCurrentUser: isCurrentUser,
|
AccountDetailHeaderView(isCurrentUser: isCurrentUser,
|
||||||
account: account,
|
account: account,
|
||||||
|
|
|
@ -3,6 +3,7 @@ import Shimmer
|
||||||
import NukeUI
|
import NukeUI
|
||||||
|
|
||||||
public struct AvatarView: View {
|
public struct AvatarView: View {
|
||||||
|
@Environment(\.redactionReasons) private var reasons
|
||||||
@EnvironmentObject private var theme: Theme
|
@EnvironmentObject private var theme: Theme
|
||||||
|
|
||||||
public enum Size {
|
public enum Size {
|
||||||
|
@ -33,7 +34,6 @@ public struct AvatarView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Environment(\.redactionReasons) private var reasons
|
|
||||||
public let url: URL
|
public let url: URL
|
||||||
public let size: Size
|
public let size: Size
|
||||||
|
|
||||||
|
@ -47,7 +47,7 @@ public struct AvatarView: View {
|
||||||
if reasons == .placeholder {
|
if reasons == .placeholder {
|
||||||
RoundedRectangle(cornerRadius: size.cornerRadius)
|
RoundedRectangle(cornerRadius: size.cornerRadius)
|
||||||
.fill(.gray)
|
.fill(.gray)
|
||||||
.frame(maxWidth: size.size.width, maxHeight: size.size.height)
|
.frame(width: size.size.width, height: size.size.height)
|
||||||
} else {
|
} else {
|
||||||
LazyImage(url: url) { state in
|
LazyImage(url: url) { state in
|
||||||
if let image = state.image {
|
if let image = state.image {
|
||||||
|
|
21
Packages/Env/Sources/Env/Ext/AppStorage.swift
Normal file
21
Packages/Env/Sources/Env/Ext/AppStorage.swift
Normal file
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -7,6 +7,7 @@ public enum RouteurDestinations: Hashable {
|
||||||
case accountDetail(id: String)
|
case accountDetail(id: String)
|
||||||
case accountDetailWithAccount(account: Account)
|
case accountDetailWithAccount(account: Account)
|
||||||
case statusDetail(id: String)
|
case statusDetail(id: String)
|
||||||
|
case remoteStatusDetail(url: URL)
|
||||||
case hashTag(tag: String, account: String?)
|
case hashTag(tag: String, account: String?)
|
||||||
case list(list: Models.List)
|
case list(list: Models.List)
|
||||||
case followers(id: String)
|
case followers(id: String)
|
||||||
|
@ -23,6 +24,7 @@ public enum SheetDestinations: Identifiable {
|
||||||
case mentionStatusEditor(account: Account, visibility: Models.Visibility)
|
case mentionStatusEditor(account: Account, visibility: Models.Visibility)
|
||||||
case listEdit(list: Models.List)
|
case listEdit(list: Models.List)
|
||||||
case listAddAccount(account: Account)
|
case listAddAccount(account: Account)
|
||||||
|
case addAccount
|
||||||
|
|
||||||
public var id: String {
|
public var id: String {
|
||||||
switch self {
|
switch self {
|
||||||
|
@ -32,6 +34,8 @@ public enum SheetDestinations: Identifiable {
|
||||||
return "listEdit"
|
return "listEdit"
|
||||||
case .listAddAccount:
|
case .listAddAccount:
|
||||||
return "listAddAccount"
|
return "listAddAccount"
|
||||||
|
case .addAccount:
|
||||||
|
return "addAccount"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -62,9 +66,7 @@ public class RouterPath: ObservableObject {
|
||||||
if url.absoluteString.contains(client.server) {
|
if url.absoluteString.contains(client.server) {
|
||||||
navigate(to: .statusDetail(id: String(id)))
|
navigate(to: .statusDetail(id: String(id)))
|
||||||
} else {
|
} else {
|
||||||
Task {
|
navigate(to: .remoteStatusDetail(url: url))
|
||||||
await navigateToStatusFrom(url: url)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return .handled
|
return .handled
|
||||||
}
|
}
|
||||||
|
@ -85,23 +87,7 @@ public class RouterPath: ObservableObject {
|
||||||
}
|
}
|
||||||
return .systemAction
|
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 {
|
public func navigateToAccountFrom(acct: String, url: URL) async {
|
||||||
guard let client else { return }
|
guard let client else { return }
|
||||||
Task {
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
8
Packages/Env/Sources/Env/UserPreferences.swift
Normal file
8
Packages/Env/Sources/Env/UserPreferences.swift
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import SwiftUI
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public class UserPreferences: ObservableObject {
|
||||||
|
@AppStorage("remote_local_timeline") public var remoteLocalTimelines: [String] = []
|
||||||
|
|
||||||
|
public init() { }
|
||||||
|
}
|
|
@ -18,6 +18,10 @@ public struct StatusDetailView: View {
|
||||||
_viewModel = StateObject(wrappedValue: .init(statusId: statusId))
|
_viewModel = StateObject(wrappedValue: .init(statusId: statusId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public init(remoteStatusURL: URL) {
|
||||||
|
_viewModel = StateObject(wrappedValue: .init(remoteStatusURL: remoteStatusURL))
|
||||||
|
}
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
ScrollViewReader { proxy in
|
ScrollViewReader { proxy in
|
||||||
ScrollView {
|
ScrollView {
|
||||||
|
@ -70,7 +74,10 @@ public struct StatusDetailView: View {
|
||||||
guard !isLoaded else { return }
|
guard !isLoaded else { return }
|
||||||
isLoaded = true
|
isLoaded = true
|
||||||
viewModel.client = client
|
viewModel.client = client
|
||||||
await viewModel.fetchStatusDetail()
|
let result = await viewModel.fetch()
|
||||||
|
if !result {
|
||||||
|
_ = routeurPath.path.popLast()
|
||||||
|
}
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
proxy.scrollTo(viewModel.statusId, anchor: .center)
|
proxy.scrollTo(viewModel.statusId, anchor: .center)
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,7 +5,8 @@ import Network
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
class StatusDetailViewModel: ObservableObject {
|
class StatusDetailViewModel: ObservableObject {
|
||||||
public let statusId: String
|
public var statusId: String?
|
||||||
|
public var remoteStatusURL: URL?
|
||||||
|
|
||||||
var client: Client?
|
var client: Client?
|
||||||
|
|
||||||
|
@ -19,10 +20,44 @@ class StatusDetailViewModel: ObservableObject {
|
||||||
init(statusId: String) {
|
init(statusId: String) {
|
||||||
state = .loading
|
state = .loading
|
||||||
self.statusId = statusId
|
self.statusId = statusId
|
||||||
|
self.remoteStatusURL = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchStatusDetail() async {
|
init(remoteStatusURL: URL) {
|
||||||
guard let client else { return }
|
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 {
|
do {
|
||||||
let status: Status = try await client.get(endpoint: Statuses.status(id: statusId))
|
let status: Status = try await client.get(endpoint: Statuses.status(id: statusId))
|
||||||
let context: StatusContext = try await client.get(endpoint: Statuses.context(id: statusId))
|
let context: StatusContext = try await client.get(endpoint: Statuses.context(id: statusId))
|
||||||
|
|
|
@ -5,9 +5,11 @@ import DesignSystem
|
||||||
|
|
||||||
public struct StatusesListView<Fetcher>: View where Fetcher: StatusesFetcher {
|
public struct StatusesListView<Fetcher>: View where Fetcher: StatusesFetcher {
|
||||||
@ObservedObject private var fetcher: Fetcher
|
@ObservedObject private var fetcher: Fetcher
|
||||||
|
private let isRemote: Bool
|
||||||
|
|
||||||
public init(fetcher: Fetcher) {
|
public init(fetcher: Fetcher, isRemote: Bool = false) {
|
||||||
self.fetcher = fetcher
|
self.fetcher = fetcher
|
||||||
|
self.isRemote = isRemote
|
||||||
}
|
}
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
|
@ -26,7 +28,7 @@ public struct StatusesListView<Fetcher>: View where Fetcher: StatusesFetcher {
|
||||||
Text(error.localizedDescription)
|
Text(error.localizedDescription)
|
||||||
case let .display(statuses, nextPageState):
|
case let .display(statuses, nextPageState):
|
||||||
ForEach(statuses, id: \.viewId) { status in
|
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)
|
.id(status.id)
|
||||||
.padding(.horizontal, .layoutPadding)
|
.padding(.horizontal, .layoutPadding)
|
||||||
Divider()
|
Divider()
|
||||||
|
|
|
@ -8,26 +8,29 @@ struct StatusRowContextMenu: View {
|
||||||
@ObservedObject var viewModel: StatusRowViewModel
|
@ObservedObject var viewModel: StatusRowViewModel
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button { Task {
|
if !viewModel.isRemote {
|
||||||
if viewModel.isFavourited {
|
Button { Task {
|
||||||
await viewModel.unFavourite()
|
if viewModel.isFavourited {
|
||||||
} else {
|
await viewModel.unFavourite()
|
||||||
await viewModel.favourite()
|
} else {
|
||||||
|
await viewModel.favourite()
|
||||||
|
}
|
||||||
|
} } label: {
|
||||||
|
Label(viewModel.isFavourited ? "Unfavorite" : "Favorite", systemImage: "star")
|
||||||
}
|
}
|
||||||
} } label: {
|
Button { Task {
|
||||||
Label(viewModel.isFavourited ? "Unfavorite" : "Favorite", systemImage: "star")
|
if viewModel.isReblogged {
|
||||||
}
|
await viewModel.unReblog()
|
||||||
Button { Task {
|
} else {
|
||||||
if viewModel.isReblogged {
|
await viewModel.reblog()
|
||||||
await viewModel.unReblog()
|
}
|
||||||
} else {
|
} } label: {
|
||||||
await viewModel.reblog()
|
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 {
|
Button {
|
||||||
routeurPath.presentedSheet = .quoteStatusEditor(status: viewModel.status)
|
routeurPath.presentedSheet = .quoteStatusEditor(status: viewModel.status)
|
||||||
} label: {
|
} label: {
|
||||||
|
@ -63,7 +66,7 @@ struct StatusRowContextMenu: View {
|
||||||
Label("Delete", systemImage: "trash")
|
Label("Delete", systemImage: "trash")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else if !viewModel.isRemote {
|
||||||
Section(viewModel.status.account.acct) {
|
Section(viewModel.status.account.acct) {
|
||||||
Button {
|
Button {
|
||||||
routeurPath.presentedSheet = .mentionStatusEditor(account: viewModel.status.account, visibility: .pub)
|
routeurPath.presentedSheet = .mentionStatusEditor(account: viewModel.status.account, visibility: .pub)
|
||||||
|
|
|
@ -42,13 +42,13 @@ public struct StatusRowView: View {
|
||||||
replyView
|
replyView
|
||||||
}
|
}
|
||||||
statusView
|
statusView
|
||||||
if !viewModel.isCompact && viewModel.showActions {
|
if !viewModel.isCompact && !viewModel.isRemote {
|
||||||
StatusActionsView(viewModel: viewModel)
|
StatusActionsView(viewModel: viewModel)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
.tint(viewModel.isFocused ? theme.tintColor : .gray)
|
.tint(viewModel.isFocused ? theme.tintColor : .gray)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture {
|
.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)
|
.foregroundColor(.gray)
|
||||||
.fontWeight(.semibold)
|
.fontWeight(.semibold)
|
||||||
.onTapGesture {
|
.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)
|
.foregroundColor(.gray)
|
||||||
.fontWeight(.semibold)
|
.fontWeight(.semibold)
|
||||||
.onTapGesture {
|
.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 {
|
if !viewModel.isCompact {
|
||||||
HStack(alignment: .top) {
|
HStack(alignment: .top) {
|
||||||
Button {
|
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: {
|
} label: {
|
||||||
accountView(status: status)
|
accountView(status: status)
|
||||||
}.buttonStyle(.plain)
|
}.buttonStyle(.plain)
|
||||||
|
@ -180,7 +198,7 @@ public struct StatusRowView: View {
|
||||||
}
|
}
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
routeurPath.navigate(to: .statusDetail(id: viewModel.status.reblog?.id ?? viewModel.status.id))
|
viewModel.navigateToDetail(routeurPath: routeurPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,13 +1,14 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Models
|
import Models
|
||||||
import Network
|
import Network
|
||||||
|
import Env
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
public class StatusRowViewModel: ObservableObject {
|
public class StatusRowViewModel: ObservableObject {
|
||||||
let status: Status
|
let status: Status
|
||||||
let isCompact: Bool
|
let isCompact: Bool
|
||||||
let isFocused: Bool
|
let isFocused: Bool
|
||||||
let showActions: Bool
|
let isRemote: Bool
|
||||||
|
|
||||||
@Published var favouritesCount: Int
|
@Published var favouritesCount: Int
|
||||||
@Published var isFavourited: Bool
|
@Published var isFavourited: Bool
|
||||||
|
@ -29,11 +30,11 @@ public class StatusRowViewModel: ObservableObject {
|
||||||
public init(status: Status,
|
public init(status: Status,
|
||||||
isCompact: Bool = false,
|
isCompact: Bool = false,
|
||||||
isFocused: Bool = false,
|
isFocused: Bool = false,
|
||||||
showActions: Bool = true) {
|
isRemote: Bool = false) {
|
||||||
self.status = status
|
self.status = status
|
||||||
self.isCompact = isCompact
|
self.isCompact = isCompact
|
||||||
self.isFocused = isFocused
|
self.isFocused = isFocused
|
||||||
self.showActions = showActions
|
self.isRemote = isRemote
|
||||||
if let reblog = status.reblog {
|
if let reblog = status.reblog {
|
||||||
self.isFavourited = reblog.favourited == true
|
self.isFavourited = reblog.favourited == true
|
||||||
self.isReblogged = reblog.reblogged == true
|
self.isReblogged = reblog.reblogged == true
|
||||||
|
@ -51,6 +52,14 @@ public class StatusRowViewModel: ObservableObject {
|
||||||
self.isFiltered = filter != nil
|
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 {
|
func loadEmbededStatus() async {
|
||||||
guard let client,
|
guard let client,
|
||||||
let urls = status.content.findStatusesURLs(),
|
let urls = status.content.findStatusesURLs(),
|
||||||
|
|
|
@ -3,9 +3,10 @@ import Models
|
||||||
import Network
|
import Network
|
||||||
|
|
||||||
public enum TimelineFilter: Hashable, Equatable {
|
public enum TimelineFilter: Hashable, Equatable {
|
||||||
case pub, local, home, trending
|
case federated, local, home, trending
|
||||||
case hashtag(tag: String, accountId: String?)
|
case hashtag(tag: String, accountId: String?)
|
||||||
case list(list: List)
|
case list(list: List)
|
||||||
|
case remoteLocal(server: String)
|
||||||
|
|
||||||
public func hash(into hasher: inout Hasher) {
|
public func hash(into hasher: inout Hasher) {
|
||||||
hasher.combine(title())
|
hasher.combine(title())
|
||||||
|
@ -13,14 +14,14 @@ public enum TimelineFilter: Hashable, Equatable {
|
||||||
|
|
||||||
public static func availableTimeline(client: Client) -> [TimelineFilter] {
|
public static func availableTimeline(client: Client) -> [TimelineFilter] {
|
||||||
if !client.isAuth {
|
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 {
|
public func title() -> String {
|
||||||
switch self {
|
switch self {
|
||||||
case .pub:
|
case .federated:
|
||||||
return "Federated"
|
return "Federated"
|
||||||
case .local:
|
case .local:
|
||||||
return "Local"
|
return "Local"
|
||||||
|
@ -32,12 +33,14 @@ public enum TimelineFilter: Hashable, Equatable {
|
||||||
return "#\(tag)"
|
return "#\(tag)"
|
||||||
case let .list(list):
|
case let .list(list):
|
||||||
return list.title
|
return list.title
|
||||||
|
case let .remoteLocal(server):
|
||||||
|
return server
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func iconName() -> String? {
|
public func iconName() -> String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .pub:
|
case .federated:
|
||||||
return "globe.americas"
|
return "globe.americas"
|
||||||
case .local:
|
case .local:
|
||||||
return "person.3"
|
return "person.3"
|
||||||
|
@ -47,6 +50,8 @@ public enum TimelineFilter: Hashable, Equatable {
|
||||||
return "house"
|
return "house"
|
||||||
case .list(_):
|
case .list(_):
|
||||||
return "list.bullet"
|
return "list.bullet"
|
||||||
|
case .remoteLocal:
|
||||||
|
return "dot.radiowaves.right"
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -54,8 +59,9 @@ public enum TimelineFilter: Hashable, Equatable {
|
||||||
|
|
||||||
public func endpoint(sinceId: String?, maxId: String?, minId: String?, offset: Int?) -> Endpoint {
|
public func endpoint(sinceId: String?, maxId: String?, minId: String?, offset: Int?) -> Endpoint {
|
||||||
switch self {
|
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 .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 .home: return Timelines.home(sinceId: sinceId, maxId: maxId, minId: minId)
|
||||||
case .trending: return Trends.statuses(offset: offset)
|
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 .list(list): return Timelines.list(listId: list.id, sinceId: sinceId, maxId: maxId, minId: minId)
|
||||||
|
|
|
@ -41,7 +41,12 @@ public struct TimelineView: View {
|
||||||
LazyVStack {
|
LazyVStack {
|
||||||
tagHeaderView
|
tagHeaderView
|
||||||
.padding(.bottom, 16)
|
.padding(.bottom, 16)
|
||||||
StatusesListView(fetcher: viewModel)
|
switch viewModel.timeline {
|
||||||
|
case .remoteLocal:
|
||||||
|
StatusesListView(fetcher: viewModel, isRemote: true)
|
||||||
|
default:
|
||||||
|
StatusesListView(fetcher: viewModel)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(.top, .layoutPadding)
|
.padding(.top, .layoutPadding)
|
||||||
}
|
}
|
||||||
|
@ -55,33 +60,19 @@ public struct TimelineView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(timeline.title())
|
.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)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
viewModel.client = client
|
if viewModel.client == nil {
|
||||||
viewModel.timeline = timeline
|
viewModel.client = client
|
||||||
|
viewModel.timeline = timeline
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.refreshable {
|
.refreshable {
|
||||||
feedbackGenerator.impactOccurred(intensity: 0.3)
|
feedbackGenerator.impactOccurred(intensity: 0.3)
|
||||||
await viewModel.fetchStatuses(userIntent: true)
|
await viewModel.fetchStatuses(userIntent: true)
|
||||||
feedbackGenerator.impactOccurred(intensity: 0.7)
|
feedbackGenerator.impactOccurred(intensity: 0.7)
|
||||||
}
|
}
|
||||||
.onChange(of: watcher.latestEvent?.id) { id in
|
.onChange(of: watcher.latestEvent?.id) { _ in
|
||||||
if let latestEvent = watcher.latestEvent {
|
if let latestEvent = watcher.latestEvent {
|
||||||
viewModel.handleEvent(event: latestEvent, currentAccount: account)
|
viewModel.handleEvent(event: latestEvent, currentAccount: account)
|
||||||
}
|
}
|
||||||
|
@ -92,7 +83,13 @@ public struct TimelineView: View {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.onChange(of: timeline) { newTimeline in
|
.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
|
.onChange(of: scenePhase, perform: { scenePhase in
|
||||||
switch scenePhase {
|
switch scenePhase {
|
||||||
|
|
|
@ -18,7 +18,7 @@ class TimelineViewModel: ObservableObject, StatusesFetcher {
|
||||||
private var statuses: [Status] = []
|
private var statuses: [Status] = []
|
||||||
|
|
||||||
@Published var statusesState: StatusesState = .loading
|
@Published var statusesState: StatusesState = .loading
|
||||||
@Published var timeline: TimelineFilter = .pub {
|
@Published var timeline: TimelineFilter = .federated {
|
||||||
didSet {
|
didSet {
|
||||||
Task {
|
Task {
|
||||||
if oldValue != timeline {
|
if oldValue != timeline {
|
||||||
|
|
Loading…
Reference in a new issue