Search & Pin remote local timeline + general polish

This commit is contained in:
Thomas Ricouard 2023-01-06 12:14:05 +01:00
parent 27f0ee45b7
commit f922ba344d
25 changed files with 493 additions and 158 deletions

View file

@ -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 */,
); );

View 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()
}
}
}
}
}

View file

@ -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()
} }
} }
} }

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)
} }

View file

@ -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)

View 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)
}
}
}
}
}

View file

@ -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 {
} }
} }
} }
}
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 { 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()
}
}
}
} }

View file

@ -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")

View file

@ -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,

View file

@ -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 {

View 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
}
}

View file

@ -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
} }
@ -86,22 +88,6 @@ 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)
}
}
}
} }

View file

@ -0,0 +1,8 @@
import SwiftUI
import Foundation
public class UserPreferences: ObservableObject {
@AppStorage("remote_local_timeline") public var remoteLocalTimelines: [String] = []
public init() { }
}

View file

@ -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)
} }

View file

@ -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))

View file

@ -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()

View file

@ -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)

View file

@ -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)
} }
} }

View file

@ -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(),

View file

@ -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)

View file

@ -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 {

View file

@ -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 {