diff --git a/IceCubesApp.xcodeproj/project.pbxproj b/IceCubesApp.xcodeproj/project.pbxproj index e0d03ce3..59bd11ae 100644 --- a/IceCubesApp.xcodeproj/project.pbxproj +++ b/IceCubesApp.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 9F24EEB829360C330042359D /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9F24EEB729360C330042359D /* Preview Assets.xcassets */; }; 9F295540292B6C3400E0E81B /* Timeline in Frameworks */ = {isa = PBXBuildFile; productRef = 9F29553F292B6C3400E0E81B /* Timeline */; }; + 9F2B92F6295AE04800DE16D0 /* Tabs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F2B92F5295AE04800DE16D0 /* Tabs.swift */; }; 9F35DB44294F9A7D00B3281A /* Status in Frameworks */ = {isa = PBXBuildFile; productRef = 9F35DB43294F9A7D00B3281A /* Status */; }; 9F35DB4729506F6600B3281A /* NotificationTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F35DB4629506F6600B3281A /* NotificationTab.swift */; }; 9F35DB4A29506FA100B3281A /* Notifications in Frameworks */ = {isa = PBXBuildFile; productRef = 9F35DB4929506FA100B3281A /* Notifications */; }; @@ -34,6 +35,7 @@ 9F24EEB729360C330042359D /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 9F29553D292B67B600E0E81B /* Network */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Network; path = Packages/Network; sourceTree = ""; }; 9F29553E292B6AF600E0E81B /* Timeline */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Timeline; path = Packages/Timeline; sourceTree = ""; }; + 9F2B92F5295AE04800DE16D0 /* Tabs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tabs.swift; sourceTree = ""; }; 9F35DB42294F9A2900B3281A /* Status */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Status; path = Packages/Status; sourceTree = ""; }; 9F35DB45294FA04C00B3281A /* DesignSystem */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = DesignSystem; path = Packages/DesignSystem; sourceTree = ""; }; 9F35DB4629506F6600B3281A /* NotificationTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationTab.swift; sourceTree = ""; }; @@ -104,6 +106,7 @@ 9F35DB4629506F6600B3281A /* NotificationTab.swift */, 9F35DB4B2952005C00B3281A /* AccountTab.swift */, 9F55C68C2955968700F94077 /* ExploreTab.swift */, + 9F2B92F5295AE04800DE16D0 /* Tabs.swift */, ); path = Tabs; sourceTree = ""; @@ -259,6 +262,7 @@ 9F35DB4C2952005C00B3281A /* AccountTab.swift in Sources */, 9FAE4ACB293783B000772766 /* SettingsTab.swift in Sources */, 9FAE4AD32937A0C600772766 /* AppAccountsManager.swift in Sources */, + 9F2B92F6295AE04800DE16D0 /* Tabs.swift in Sources */, 9F398AB329360A4C00A889F2 /* TimelineTab.swift in Sources */, 9F398AA62935FE8A00A889F2 /* AppRouteur.swift in Sources */, 9FBFE63D292A715500C250E9 /* IceCubesApp.swift in Sources */, @@ -393,7 +397,7 @@ CODE_SIGN_IDENTITY = "-"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 500; + CURRENT_PROJECT_VERSION = 550; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"IceCubesApp/Resources\""; DEVELOPMENT_TEAM = Z6P74P6T99; @@ -411,8 +415,8 @@ "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; IPHONEOS_DEPLOYMENT_TARGET = 16.1; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; @@ -439,7 +443,7 @@ CODE_SIGN_IDENTITY = "-"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 500; + CURRENT_PROJECT_VERSION = 550; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"IceCubesApp/Resources\""; DEVELOPMENT_TEAM = Z6P74P6T99; @@ -457,8 +461,8 @@ "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; IPHONEOS_DEPLOYMENT_TARGET = 16.1; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; diff --git a/IceCubesApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/IceCubesApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3fac4b2b..c5e1ea79 100644 --- a/IceCubesApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/IceCubesApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -51,7 +51,7 @@ "location" : "https://github.com/Dimillian/TextView", "state" : { "branch" : "main", - "revision" : "3555eecb81f918091d4f65c071dd94e64995b41b" + "revision" : "26b2930e82bb379a4abf0fcba408c0a09fbbb407" } } ], diff --git a/IceCubesApp/App/AppRouteur.swift b/IceCubesApp/App/AppRouteur.swift index cbbfbe49..11f3c9fe 100644 --- a/IceCubesApp/App/AppRouteur.swift +++ b/IceCubesApp/App/AppRouteur.swift @@ -38,6 +38,8 @@ extension View { StatusEditorView(mode: .new) case let .editStatusEditor(status): StatusEditorView(mode: .edit(status: status)) + case let .quoteStatusEditor(status): + StatusEditorView(mode: .quote(status: status)) } } } diff --git a/IceCubesApp/App/IceCubesApp.swift b/IceCubesApp/App/IceCubesApp.swift index 6e01798f..65784022 100644 --- a/IceCubesApp/App/IceCubesApp.swift +++ b/IceCubesApp/App/IceCubesApp.swift @@ -7,10 +7,6 @@ import DesignSystem @main struct IceCubesApp: App { - enum Tab: Int { - case timeline, notifications, explore, account, settings, other - } - public static let defaultServer = "mastodon.social" @Environment(\.scenePhase) private var scenePhase @@ -37,34 +33,14 @@ struct IceCubesApp: App { } selectedTab = newTab })) { - TimelineTab(popToRootTab: $popToRootTab) - .tabItem { - Label("Timeline", systemImage: "rectangle.on.rectangle") - } - .tag(Tab.timeline) - if appAccountsManager.currentClient.isAuth { - NotificationsTab(popToRootTab: $popToRootTab) + ForEach(appAccountsManager.currentClient.isAuth ? Tab.loggedInTabs() : Tab.loggedOutTab()) { tab in + tab.makeContentView(popToRootTab: $popToRootTab) .tabItem { - Label("Notifications", systemImage: "bell") + tab.label + .badge(tab == .notifications ? watcher.unreadNotificationsCount : 0) } - .badge(watcher.unreadNotificationsCount) - .tag(Tab.notifications) - ExploreTab(popToRootTab: $popToRootTab) - .tabItem { - Label("Explore", systemImage: "magnifyingglass") - } - .tag(Tab.explore) - AccountTab(popToRootTab: $popToRootTab) - .tabItem { - Label("Profile", systemImage: "person.circle") - } - .tag(Tab.account) + .tag(tab) } - SettingsTabs() - .tabItem { - Label("Settings", systemImage: "gear") - } - .tag(Tab.settings) } .tint(theme.tintColor) .onChange(of: appAccountsManager.currentClient) { newClient in diff --git a/IceCubesApp/App/Tabs/AccountTab.swift b/IceCubesApp/App/Tabs/AccountTab.swift index 018e23c6..62ef62fe 100644 --- a/IceCubesApp/App/Tabs/AccountTab.swift +++ b/IceCubesApp/App/Tabs/AccountTab.swift @@ -6,14 +6,15 @@ import Models import Shimmer struct AccountTab: View { + @EnvironmentObject private var client: Client @EnvironmentObject private var currentAccount: CurrentAccount @StateObject private var routeurPath = RouterPath() - @Binding var popToRootTab: IceCubesApp.Tab + @Binding var popToRootTab: Tab var body: some View { NavigationStack(path: $routeurPath.path) { if let account = currentAccount.account { - AccountDetailView(account: account, isCurrentUser: true) + AccountDetailView(account: account) .withAppRouteur() .withSheetDestinations(sheetDestinations: $routeurPath.presentedSheet) } else { @@ -28,5 +29,8 @@ struct AccountTab: View { routeurPath.path = [] } } + .onAppear { + routeurPath.client = client + } } } diff --git a/IceCubesApp/App/Tabs/ExploreTab.swift b/IceCubesApp/App/Tabs/ExploreTab.swift index 8fd7e56b..c4e00440 100644 --- a/IceCubesApp/App/Tabs/ExploreTab.swift +++ b/IceCubesApp/App/Tabs/ExploreTab.swift @@ -3,10 +3,13 @@ import Env import Models import Shimmer import Explore +import Env +import Network struct ExploreTab: View { + @EnvironmentObject private var client: Client @StateObject private var routeurPath = RouterPath() - @Binding var popToRootTab: IceCubesApp.Tab + @Binding var popToRootTab: Tab var body: some View { NavigationStack(path: $routeurPath.path) { @@ -20,5 +23,8 @@ struct ExploreTab: View { routeurPath.path = [] } } + .onAppear { + routeurPath.client = client + } } } diff --git a/IceCubesApp/App/Tabs/NotificationTab.swift b/IceCubesApp/App/Tabs/NotificationTab.swift index 85a374ce..d7588118 100644 --- a/IceCubesApp/App/Tabs/NotificationTab.swift +++ b/IceCubesApp/App/Tabs/NotificationTab.swift @@ -5,9 +5,10 @@ import Network import Notifications struct NotificationsTab: View { + @EnvironmentObject private var client: Client @EnvironmentObject private var watcher: StreamWatcher @StateObject private var routeurPath = RouterPath() - @Binding var popToRootTab: IceCubesApp.Tab + @Binding var popToRootTab: Tab var body: some View { NavigationStack(path: $routeurPath.path) { @@ -16,6 +17,7 @@ struct NotificationsTab: View { .withSheetDestinations(sheetDestinations: $routeurPath.presentedSheet) } .onAppear { + routeurPath.client = client watcher.unreadNotificationsCount = 0 } .environmentObject(routeurPath) diff --git a/IceCubesApp/App/Tabs/Settings/SettingsTab.swift b/IceCubesApp/App/Tabs/Settings/SettingsTab.swift index 27bc488b..86587729 100644 --- a/IceCubesApp/App/Tabs/Settings/SettingsTab.swift +++ b/IceCubesApp/App/Tabs/Settings/SettingsTab.swift @@ -84,7 +84,7 @@ struct SettingsTabs: View { LabeledContent("Email", value: instanceData.email) LabeledContent("Version", value: instanceData.version) LabeledContent("Users", value: "\(instanceData.stats.userCount)") - LabeledContent("Status", value: "\(instanceData.stats.statusCount)") + LabeledContent("Posts", value: "\(instanceData.stats.statusCount)") LabeledContent("Domains", value: "\(instanceData.stats.domainCount)") } } diff --git a/IceCubesApp/App/Tabs/Tabs.swift b/IceCubesApp/App/Tabs/Tabs.swift new file mode 100644 index 00000000..f3ffd2e3 --- /dev/null +++ b/IceCubesApp/App/Tabs/Tabs.swift @@ -0,0 +1,58 @@ +import Foundation +import Status +import Account +import Explore +import SwiftUI + +enum Tab: Int, Identifiable { + case timeline, notifications, explore, account, settings, other + + var id: Int { + rawValue + } + + static func loggedOutTab() -> [Tab] { + [.timeline, .settings] + } + + static func loggedInTabs() -> [Tab] { + [.timeline, .notifications, .explore, .account, .settings] + } + + @ViewBuilder + func makeContentView(popToRootTab: Binding) -> some View { + switch self { + case .timeline: + TimelineTab(popToRootTab: popToRootTab) + case .notifications: + NotificationsTab(popToRootTab: popToRootTab) + case .explore: + ExploreTab(popToRootTab: popToRootTab) + case .account: + AccountTab(popToRootTab: popToRootTab) + case .settings: + SettingsTabs() + case .other: + EmptyView() + } + } + + @ViewBuilder + var label: some View { + switch self { + case .timeline: + Label("Timeline", systemImage: "rectangle.on.rectangle") + case .notifications: + Label("Notifications", systemImage: "bell") + case .explore: + Label("Explore", systemImage: "magnifyingglass") + case .account: + Label("Profile", systemImage: "person.circle") + case .settings: + Label("Settings", systemImage: "gear") + case .other: + EmptyView() + } + } +} + diff --git a/IceCubesApp/App/Tabs/TimelineTab.swift b/IceCubesApp/App/Tabs/TimelineTab.swift index 7e02f065..103cd3a4 100644 --- a/IceCubesApp/App/Tabs/TimelineTab.swift +++ b/IceCubesApp/App/Tabs/TimelineTab.swift @@ -7,7 +7,7 @@ import Combine struct TimelineTab: View { @EnvironmentObject private var client: Client @StateObject private var routeurPath = RouterPath() - @Binding var popToRootTab: IceCubesApp.Tab + @Binding var popToRootTab: Tab @State private var timeline: TimelineFilter = .home var body: some View { @@ -31,6 +31,7 @@ struct TimelineTab: View { } } .onAppear { + routeurPath.client = client if !client.isAuth { timeline = .pub } diff --git a/Packages/Account/Sources/Account/AccountDetailHeaderView.swift b/Packages/Account/Sources/Account/AccountDetailHeaderView.swift index 78c4b33b..3e91d929 100644 --- a/Packages/Account/Sources/Account/AccountDetailHeaderView.swift +++ b/Packages/Account/Sources/Account/AccountDetailHeaderView.swift @@ -14,6 +14,7 @@ struct AccountDetailHeaderView: View { let isCurrentUser: Bool let account: Account let relationship: Relationshionship? + let scrollViewProxy: ScrollViewProxy? @Binding var scrollOffset: CGFloat @@ -82,7 +83,13 @@ struct AccountDetailHeaderView: View { } Spacer() Group { - makeCustomInfoLabel(title: "Posts", count: account.statusesCount) + Button { + withAnimation { + scrollViewProxy?.scrollTo("status", anchor: .top) + } + } label: { + makeCustomInfoLabel(title: "Posts", count: account.statusesCount) + } NavigationLink(value: RouteurDestinations.following(id: account.id)) { makeCustomInfoLabel(title: "Following", count: account.followingCount) } @@ -138,6 +145,7 @@ struct AccountDetailHeaderView_Previews: PreviewProvider { AccountDetailHeaderView(isCurrentUser: false, account: .placeholder(), relationship: .placeholder(), + scrollViewProxy: nil, scrollOffset: .constant(0)) } } diff --git a/Packages/Account/Sources/Account/AccountDetailView.swift b/Packages/Account/Sources/Account/AccountDetailView.swift index 5d221865..a9892a89 100644 --- a/Packages/Account/Sources/Account/AccountDetailView.swift +++ b/Packages/Account/Sources/Account/AccountDetailView.swift @@ -17,56 +17,59 @@ public struct AccountDetailView: View { @StateObject private var viewModel: AccountDetailViewModel @State private var scrollOffset: CGFloat = 0 @State private var isFieldsSheetDisplayed: Bool = false - - private let isCurrentUser: Bool + @State private var isCurrentUser: Bool = false /// When coming from a URL like a mention tap in a status. public init(accountId: String) { _viewModel = StateObject(wrappedValue: .init(accountId: accountId)) - isCurrentUser = false } /// When the account is already fetched by the parent caller. - public init(account: Account, isCurrentUser: Bool = false) { - _viewModel = StateObject(wrappedValue: .init(account: account, - isCurrentUser: isCurrentUser)) - self.isCurrentUser = isCurrentUser + public init(account: Account) { + _viewModel = StateObject(wrappedValue: .init(account: account)) } public var body: some View { - ScrollViewOffsetReader { offset in - self.scrollOffset = offset - } content: { - LazyVStack(alignment: .leading) { - headerView - familliarFollowers - .offset(y: -36) - featuredTagsView - .offset(y: -36) - if isCurrentUser { - Picker("", selection: $viewModel.selectedTab) { - ForEach(AccountDetailViewModel.Tab.allCases, id: \.self) { tab in - Text(tab.title).tag(tab) + ScrollViewReader { proxy in + ScrollViewOffsetReader { offset in + self.scrollOffset = offset + } content: { + LazyVStack(alignment: .leading) { + makeHeaderView(proxy: proxy) + familliarFollowers + .offset(y: -36) + featuredTagsView + .offset(y: -36) + Group { + if isCurrentUser { + Picker("", selection: $viewModel.selectedTab) { + ForEach(AccountDetailViewModel.Tab.allCases, id: \.self) { tab in + Text(tab.title).tag(tab) + } + } + .pickerStyle(.segmented) + .padding(.horizontal, DS.Constants.layoutPadding) + .offset(y: -20) + } else { + Divider() + .offset(y: -20) } } - .pickerStyle(.segmented) - .padding(.horizontal, DS.Constants.layoutPadding) - .offset(y: -20) - } else { - Divider() - .offset(y: -20) - } - - switch viewModel.tabState { - case .statuses: - StatusesListView(fetcher: viewModel) - case let .followedTags(tags): - makeTagsListView(tags: tags) + .id("status") + + switch viewModel.tabState { + case .statuses: + StatusesListView(fetcher: viewModel) + case let .followedTags(tags): + makeTagsListView(tags: tags) + } } } } .task { guard reasons != .placeholder else { return } + isCurrentUser = currentAccount.account?.id == viewModel.accountId + viewModel.isCurrentUser = isCurrentUser viewModel.client = client await viewModel.fetchAccount() if viewModel.statuses.isEmpty { @@ -90,18 +93,20 @@ public struct AccountDetailView: View { } @ViewBuilder - private var headerView: some View { + private func makeHeaderView(proxy: ScrollViewProxy?) -> some View { switch viewModel.accountState { case .loading: AccountDetailHeaderView(isCurrentUser: isCurrentUser, account: .placeholder(), relationship: .placeholder(), + scrollViewProxy: proxy, scrollOffset: $scrollOffset) .redacted(reason: .placeholder) case let .data(account): AccountDetailHeaderView(isCurrentUser: isCurrentUser, account: account, relationship: viewModel.relationship, + scrollViewProxy: proxy, scrollOffset: $scrollOffset) case let .error(error): Text("Error: \(error.localizedDescription)") diff --git a/Packages/Account/Sources/Account/AccountDetailViewModel.swift b/Packages/Account/Sources/Account/AccountDetailViewModel.swift index a6ef3f15..1ab732ab 100644 --- a/Packages/Account/Sources/Account/AccountDetailViewModel.swift +++ b/Packages/Account/Sources/Account/AccountDetailViewModel.swift @@ -8,6 +8,7 @@ import Env class AccountDetailViewModel: ObservableObject, StatusesFetcher { let accountId: String var client: Client? + var isCurrentUser: Bool = false enum AccountState { case loading, data(account: Account), error(error: Error) @@ -62,7 +63,6 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher { private var account: Account? private(set) var statuses: [Status] = [] - private let isCurrentUser: Bool /// When coming from a URL like a mention tap in a status. init(accountId: String) { @@ -71,10 +71,9 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher { } /// When the account is already fetched by the parent caller. - init(account: Account, isCurrentUser: Bool) { + init(account: Account) { self.accountId = account.id self.accountState = .data(account: account) - self.isCurrentUser = isCurrentUser } func fetchAccount() async { diff --git a/Packages/Account/Sources/Account/AccountsLIst/AccountsListRow.swift b/Packages/Account/Sources/Account/AccountsLIst/AccountsListRow.swift index cb321350..b07ea302 100644 --- a/Packages/Account/Sources/Account/AccountsLIst/AccountsListRow.swift +++ b/Packages/Account/Sources/Account/AccountsLIst/AccountsListRow.swift @@ -18,6 +18,7 @@ public class AccountsListRowViewModel: ObservableObject { } public struct AccountsListRow: View { + @EnvironmentObject private var currentAccount: CurrentAccount @EnvironmentObject private var routeurPath: RouterPath @EnvironmentObject private var client: Client @@ -45,8 +46,10 @@ public struct AccountsListRow: View { }) } Spacer() - FollowButton(viewModel: .init(accountId: viewModel.account.id, - relationship: viewModel.relationShip)) + if currentAccount.account?.id != viewModel.account.id { + FollowButton(viewModel: .init(accountId: viewModel.account.id, + relationship: viewModel.relationShip)) + } } .onAppear { viewModel.client = client diff --git a/Packages/DesignSystem/Sources/DesignSystem/AccountExt.swift b/Packages/DesignSystem/Sources/DesignSystem/AccountExt.swift index cb0851fa..6153fc33 100644 --- a/Packages/DesignSystem/Sources/DesignSystem/AccountExt.swift +++ b/Packages/DesignSystem/Sources/DesignSystem/AccountExt.swift @@ -5,27 +5,35 @@ import Models @MainActor extension Account { + private struct Part: Identifiable { + let id = UUID().uuidString + let value: Substring + } + public var displayNameWithEmojis: some View { - let splittedDisplayName = displayName.split(separator: ":") - return HStack(spacing: 0) { - ForEach(splittedDisplayName, id: \.self) { part in - if let emoji = emojis.first(where: { $0.shortcode == part }) { - LazyImage(url: emoji.url) { state in - if let image = state.image { - image - .resizingMode(.aspectFit) - } else if state.isLoading { - ProgressView() - } else { - ProgressView() - } - } - .processors([.resize(size: .init(width: 20, height: 20))]) - .frame(width: 20, height: 20) - } else { - Text(part) - } - } - } - } + let splittedDisplayName = displayName.split(separator: ":").map{ Part(value: $0) } + return HStack(spacing: 0) { + if displayName.isEmpty { + Text(" ") + } + ForEach(splittedDisplayName, id: \.id) { part in + if let emoji = emojis.first(where: { $0.shortcode == part.value }) { + LazyImage(url: emoji.url) { state in + if let image = state.image { + image + .resizingMode(.aspectFit) + } else if state.isLoading { + ProgressView() + } else { + ProgressView() + } + } + .processors([.resize(size: .init(width: 20, height: 20))]) + .frame(width: 20, height: 20) + } else { + Text(part.value) + } + } + } + } } diff --git a/Packages/DesignSystem/Sources/DesignSystem/Views/AvatarView.swift b/Packages/DesignSystem/Sources/DesignSystem/Views/AvatarView.swift index ca033fa9..fb4b2ac8 100644 --- a/Packages/DesignSystem/Sources/DesignSystem/Views/AvatarView.swift +++ b/Packages/DesignSystem/Sources/DesignSystem/Views/AvatarView.swift @@ -4,7 +4,7 @@ import NukeUI public struct AvatarView: View { public enum Size { - case account, status, badge + case account, status, embed, badge var size: CGSize { switch self { @@ -12,6 +12,8 @@ public struct AvatarView: View { return .init(width: 80, height: 80) case .status: return .init(width: 40, height: 40) + case .embed: + return .init(width: 34, height: 34) case .badge: return .init(width: 28, height: 28) } diff --git a/Packages/Env/Sources/Env/Routeur.swift b/Packages/Env/Sources/Env/Routeur.swift index b538d66b..87a8ce38 100644 --- a/Packages/Env/Sources/Env/Routeur.swift +++ b/Packages/Env/Sources/Env/Routeur.swift @@ -1,6 +1,7 @@ import Foundation import SwiftUI import Models +import Network public enum RouteurDestinations: Hashable { case accountDetail(id: String) @@ -17,16 +18,19 @@ public enum SheetDestinations: Identifiable { case newStatusEditor case editStatusEditor(status: Status) case replyToStatusEditor(status: Status) + case quoteStatusEditor(status: Status) public var id: String { switch self { - case .editStatusEditor, .newStatusEditor, .replyToStatusEditor: + case .editStatusEditor, .newStatusEditor, .replyToStatusEditor, .quoteStatusEditor: return "statusEditor" } } } public class RouterPath: ObservableObject { + public var client: Client? + @Published public var path: [RouteurDestinations] = [] @Published public var presentedSheet: SheetDestinations? @@ -44,6 +48,10 @@ public class RouterPath: ObservableObject { } else if let mention = status.mentions.first(where: { $0.url == url }) { navigate(to: .accountDetail(id: mention.id)) return .handled + } else if let client = client, + let id = status.content.findStatusesIds(instance: client.server)?.first(where: { String($0) == url.lastPathComponent}) { + navigate(to: .statusDetail(id: String(id))) + return .handled } return .systemAction } diff --git a/Packages/Explore/Sources/Explore/ExploreView.swift b/Packages/Explore/Sources/Explore/ExploreView.swift index d586d846..a566d768 100644 --- a/Packages/Explore/Sources/Explore/ExploreView.swift +++ b/Packages/Explore/Sources/Explore/ExploreView.swift @@ -13,19 +13,19 @@ public struct ExploreView: View { @EnvironmentObject private var routeurPath: RouterPath @StateObject private var viewModel = ExploreViewModel() - @State private var searchQuery: String = "" - + public init() { } public var body: some View { List { - if !viewModel.isLoaded { - ForEach(Status.placeholders()) { status in - StatusRowView(viewModel: .init(status: status, isEmbed: false)) - .padding(.vertical, 8) - .redacted(reason: .placeholder) - .shimmering() + if !viewModel.searchQuery.isEmpty { + if let results = viewModel.results[viewModel.searchQuery] { + makeSearchResultsView(results: results) + } else { + loadingView } + } else if !viewModel.isLoaded { + loadingView } else { trendingTagsSection suggestedAccountsSection @@ -45,7 +45,51 @@ public struct ExploreView: View { } .listStyle(.grouped) .navigationTitle("Explore") - .searchable(text: $searchQuery) + .searchable(text: $viewModel.searchQuery, + tokens: $viewModel.tokens, + suggestedTokens: $viewModel.suggestedToken, + prompt: Text("Search users, posts and tags"), + token: { token in + Text(token.rawValue) + }) + } + + private var loadingView: some View { + ForEach(Status.placeholders()) { status in + StatusRowView(viewModel: .init(status: status, isEmbed: false)) + .padding(.vertical, 8) + .redacted(reason: .placeholder) + .shimmering() + } + } + + @ViewBuilder + private func makeSearchResultsView(results: SearchResults) -> some View { + if !results.accounts.isEmpty { + Section("Users") { + ForEach(results.accounts) { account in + if let relationship = results.relationships.first(where: { $0.id == account.id }) { + AccountsListRow(viewModel: .init(account: account, relationShip: relationship)) + } + } + } + } + if !results.hashtags.isEmpty { + Section("Tags") { + ForEach(results.hashtags) { tag in + TagRowView(tag: tag) + .padding(.vertical, 4) + } + } + } + if !results.statuses.isEmpty { + Section("Posts") { + ForEach(results.statuses) { status in + StatusRowView(viewModel: .init(status: status)) + .padding(.vertical, 8) + } + } + } } private var suggestedAccountsSection: some View { diff --git a/Packages/Explore/Sources/Explore/ExploreViewModel.swift b/Packages/Explore/Sources/Explore/ExploreViewModel.swift index 242b2646..a7d6569f 100644 --- a/Packages/Explore/Sources/Explore/ExploreViewModel.swift +++ b/Packages/Explore/Sources/Explore/ExploreViewModel.swift @@ -6,6 +6,44 @@ import Network class ExploreViewModel: ObservableObject { var client: Client? + enum Token: String, Identifiable { + case user = "@user" + case statuses = "@posts" + case tag = "#hasgtag" + + var id: String { + rawValue + } + + var apiType: String { + switch self { + case .user: + return "accounts" + case .tag: + return "hashtags" + case .statuses: + return "statuses" + } + } + } + + @Published var tokens: [Token] = [] + @Published var suggestedToken: [Token] = [] + @Published var searchQuery = "" { + didSet { + if searchQuery.starts(with: "@") { + suggestedToken = [.user, .statuses] + } else if searchQuery.starts(with: "#") { + suggestedToken = [.tag] + } else if !tokens.isEmpty { + suggestedToken = [] + search() + } else { + search() + } + } + } + @Published var results: [String: SearchResults] = [:] @Published var isLoaded = false @Published var suggestedAccounts: [Account] = [] @Published var suggestedAccountsRelationShips: [Relationshionship] = [] @@ -13,10 +51,13 @@ class ExploreViewModel: ObservableObject { @Published var trendingStatuses: [Status] = [] @Published var trendingLinks: [Card] = [] + private var searchTask: Task? + func fetchTrending() async { guard let client else { return } do { isLoaded = false + async let suggestedAccounts: [Account] = client.get(endpoint: Accounts.suggestions) async let trendingTags: [Tag] = client.get(endpoint: Trends.tags) async let trendingStatuses: [Status] = client.get(endpoint: Trends.statuses) @@ -32,4 +73,24 @@ class ExploreViewModel: ObservableObject { isLoaded = true } catch { } } + + func search() { + guard !searchQuery.isEmpty else { return } + searchTask?.cancel() + searchTask = nil + searchTask = Task { + guard let client else { return } + do { + let apiType = tokens.first?.apiType + var results: SearchResults = try await client.get(endpoint: Search.search(query: searchQuery, + type: apiType, + offset: nil), + forceVersion: .v2) + let relationships: [Relationshionship] = + try await client.get(endpoint: Accounts.relationships(ids: results.accounts.map{ $0.id })) + results.relationships = relationships + self.results[searchQuery] = results + } catch { } + } + } } diff --git a/Packages/Models/Sources/Models/Alias/HTMLString.swift b/Packages/Models/Sources/Models/Alias/HTMLString.swift index e7d0d47f..e04ec75c 100644 --- a/Packages/Models/Sources/Models/Alias/HTMLString.swift +++ b/Packages/Models/Sources/Models/Alias/HTMLString.swift @@ -24,6 +24,25 @@ extension HTMLString { } } + public func findStatusesIds(instance: String) -> [Int]? { + do { + let document: Document = try SwiftSoup.parse(self) + let links: Elements = try document.select("a") + var ids: [Int] = [] + for link in links { + let href = try link.attr("href") + if href.contains(instance), + let url = URL(string: href), + let statusId = Int(url.lastPathComponent) { + ids.append(statusId) + } + } + return ids + } catch { + return nil + } + } + public var asSafeAttributedString: AttributedString { do { // Add space between hashtags and mentions that follow each other diff --git a/Packages/Models/Sources/Models/MediaAttachement.swift b/Packages/Models/Sources/Models/MediaAttachement.swift index 3f7375bc..668943b0 100644 --- a/Packages/Models/Sources/Models/MediaAttachement.swift +++ b/Packages/Models/Sources/Models/MediaAttachement.swift @@ -1,6 +1,15 @@ import Foundation -public struct MediaAttachement: Codable, Identifiable, Hashable { +public struct MediaAttachement: Codable, Identifiable, Hashable { + + public struct MetaContainer: Codable, Equatable { + public struct Meta: Codable, Equatable { + public let width: Int? + public let height: Int? + } + public let original: Meta + } + public enum SupportedType: String { case image, gifv } @@ -17,5 +26,6 @@ public struct MediaAttachement: Codable, Identifiable, Hashable { public let url: URL public let previewUrl: URL? public let description: String? + public let meta: MetaContainer? } diff --git a/Packages/Models/Sources/Models/SearchResults.swift b/Packages/Models/Sources/Models/SearchResults.swift new file mode 100644 index 00000000..8bb759da --- /dev/null +++ b/Packages/Models/Sources/Models/SearchResults.swift @@ -0,0 +1,12 @@ +import Foundation + +public struct SearchResults: Decodable { + enum CodingKeys: String, CodingKey { + case accounts, statuses, hashtags + } + + public let accounts: [Account] + public var relationships: [Relationshionship] = [] + public let statuses: [Status] + public let hashtags: [Tag] +} diff --git a/Packages/Models/Sources/Models/Status.swift b/Packages/Models/Sources/Models/Status.swift index 79ee59ab..e09818e7 100644 --- a/Packages/Models/Sources/Models/Status.swift +++ b/Packages/Models/Sources/Models/Status.swift @@ -5,6 +5,14 @@ public struct Application: Codable, Identifiable { name } public let name: String + public let website: URL? +} + +public enum Visibility: String, Codable { + case pub = "public" + case unlisted + case priv = "private" + case direct } public protocol AnyStatus { @@ -27,6 +35,7 @@ public protocol AnyStatus { var url: URL? { get } var application: Application? { get } var inReplyToAccountId: String? { get } + var visibility: Visibility { get } } @@ -54,6 +63,7 @@ public struct Status: AnyStatus, Codable, Identifiable { public let url: URL? public let application: Application? public let inReplyToAccountId: String? + public let visibility: Visibility public static func placeholder() -> Status { .init(id: UUID().uuidString, @@ -74,7 +84,8 @@ public struct Status: AnyStatus, Codable, Identifiable { emojis: [], url: nil, application: nil, - inReplyToAccountId: nil) + inReplyToAccountId: nil, + visibility: .pub) } public static func placeholders() -> [Status] { @@ -105,4 +116,5 @@ public struct ReblogStatus: AnyStatus, Codable, Identifiable { public let url: URL? public var application: Application? public let inReplyToAccountId: String? + public let visibility: Visibility } diff --git a/Packages/Network/Sources/Network/Client.swift b/Packages/Network/Sources/Network/Client.swift index 230573b9..e84a4375 100644 --- a/Packages/Network/Sources/Network/Client.swift +++ b/Packages/Network/Sources/Network/Client.swift @@ -10,7 +10,7 @@ public class Client: ObservableObject, Equatable { } public enum Version: String { - case v1 + case v1, v2 } public enum OauthError: Error { @@ -40,14 +40,14 @@ public class Client: ObservableObject, Equatable { self.oauthToken = oauthToken } - private func makeURL(scheme: String = "https", endpoint: Endpoint) -> URL { + private func makeURL(scheme: String = "https", endpoint: Endpoint, forceVersion: Version? = nil) -> URL { var components = URLComponents() components.scheme = scheme components.host = server if type(of: endpoint) == Oauth.self { components.path += "/\(endpoint.path())" } else { - components.path += "/api/\(version.rawValue)/\(endpoint.path())" + components.path += "/api/\(forceVersion?.rawValue ?? version.rawValue)/\(endpoint.path())" } components.queryItems = endpoint.queryItems() return components.url! @@ -67,8 +67,8 @@ public class Client: ObservableObject, Equatable { return makeURLRequest(url: url, httpMethod: "GET") } - public func get(endpoint: Endpoint) async throws -> Entity { - try await makeEntityRequest(endpoint: endpoint, method: "GET") + public func get(endpoint: Endpoint, forceVersion: Version? = nil) async throws -> Entity { + try await makeEntityRequest(endpoint: endpoint, method: "GET", forceVersion: forceVersion) } public func getWithLink(endpoint: Endpoint) async throws -> (Entity, LinkHandler?) { @@ -97,8 +97,10 @@ public class Client: ObservableObject, Equatable { return httpResponse as? HTTPURLResponse } - private func makeEntityRequest(endpoint: Endpoint, method: String) async throws -> Entity { - let url = makeURL(endpoint: endpoint) + private func makeEntityRequest(endpoint: Endpoint, + method: String, + forceVersion: Version? = nil) async throws -> Entity { + let url = makeURL(endpoint: endpoint, forceVersion: forceVersion) let request = makeURLRequest(url: url, httpMethod: method) let (data, httpResponse) = try await urlSession.data(for: request) logResponseOnError(httpResponse: httpResponse, data: data) diff --git a/Packages/Network/Sources/Network/Endpoint/Search.swift b/Packages/Network/Sources/Network/Endpoint/Search.swift new file mode 100644 index 00000000..491f767f --- /dev/null +++ b/Packages/Network/Sources/Network/Endpoint/Search.swift @@ -0,0 +1,26 @@ +import Foundation + +public enum Search: Endpoint { + case search(query: String, type: String?, offset: Int?) + + public func path() -> String { + switch self { + case .search: + return "search" + } + } + + public func queryItems() -> [URLQueryItem]? { + switch self { + case let .search(query, type, offset): + var params: [URLQueryItem] = [.init(name: "q", value: query)] + if let type { + params.append(.init(name: "type", value: type)) + } + if let offset { + params.append(.init(name: "offset", value: String(offset))) + } + return params + } + } +} diff --git a/Packages/Status/Sources/Status/Editor/StatusEditorView.swift b/Packages/Status/Sources/Status/Editor/StatusEditorView.swift index 9da4b2bb..5cff982b 100644 --- a/Packages/Status/Sources/Status/Editor/StatusEditorView.swift +++ b/Packages/Status/Sources/Status/Editor/StatusEditorView.swift @@ -21,12 +21,17 @@ public struct StatusEditorView: View { public var body: some View { NavigationStack { ZStack(alignment: .bottom) { - VStack(spacing: 12) { - accountHeaderView - TextView($viewModel.statusText) - .placeholder("What's on your mind") - mediasView - Spacer() + ScrollView { + VStack(spacing: 12) { + accountHeaderView + TextView($viewModel.statusText) + .placeholder("What's on your mind") + if let status = viewModel.embededStatus { + StatusEmbededView(status: status) + } + mediasView + Spacer() + } } accessoryView .padding(.bottom, 12) @@ -34,6 +39,9 @@ public struct StatusEditorView: View { .onAppear { viewModel.client = client viewModel.prepareStatusText() + if !client.isAuth { + dismiss() + } } .padding(.horizontal, DS.Constants.layoutPadding) .navigationTitle(viewModel.mode.title) diff --git a/Packages/Status/Sources/Status/Editor/StatusEditorViewModel.swift b/Packages/Status/Sources/Status/Editor/StatusEditorViewModel.swift index 2f2f4565..87e53c1c 100644 --- a/Packages/Status/Sources/Status/Editor/StatusEditorViewModel.swift +++ b/Packages/Status/Sources/Status/Editor/StatusEditorViewModel.swift @@ -6,38 +6,20 @@ import PhotosUI @MainActor public class StatusEditorViewModel: ObservableObject { - public enum Mode { - case replyTo(status: Status) - case new - case edit(status: Status) - - var replyToStatus: Status? { - switch self { - case let .replyTo(status): - return status - default: - return nil - } - } - - var title: String { - switch self { - case .new: - return "New Post" - case .edit: - return "Edit your post" - case let .replyTo(status): - return "Reply to \(status.account.displayName)" - } - } + struct ImageContainer: Identifiable { + let id = UUID().uuidString + let image: UIImage } - let mode: Mode + var mode: Mode + let generator = UINotificationFeedbackGenerator() - @Published var statusText = NSAttributedString(string: "") { + var client: Client? + + @Published var statusText = NSMutableAttributedString(string: "") { didSet { - guard !internalUpdate else { return } highlightMeta() + checkEmbed() } } @@ -49,16 +31,8 @@ public class StatusEditorViewModel: ObservableObject { } @Published var mediasImages: [ImageContainer] = [] - struct ImageContainer: Identifiable { - let id = UUID().uuidString - let image: UIImage - } - - var client: Client? - private var internalUpdate: Bool = false - - let generator = UINotificationFeedbackGenerator() - + @Published var embededStatus: Status? + init(mode: Mode) { self.mode = mode } @@ -69,7 +43,7 @@ public class StatusEditorViewModel: ObservableObject { isPosting = true let postStatus: Status? switch mode { - case .new, .replyTo: + case .new, .replyTo, .quote: postStatus = try await client.post(endpoint: Statuses.postStatus(status: statusText.string, inReplyTo: mode.replyToStatus?.id, mediaIds: nil, @@ -93,45 +67,65 @@ public class StatusEditorViewModel: ObservableObject { func prepareStatusText() { switch mode { case let .replyTo(status): - statusText = .init(string: "@\(status.account.acct) ") + statusText = .init(string: "@\(status.reblog?.account.acct ?? status.account.acct) ") case let .edit(status): - statusText = .init(string: status.content.asRawText) + statusText = .init(status.content.asSafeAttributedString) + case let .quote(status): + self.embededStatus = status + if let url = status.reblog?.url ?? status.url { + statusText = .init(string: "\n\nFrom: @\(status.reblog?.account.acct ?? status.account.acct)\n\(url)") + } default: break } } - func highlightMeta() { - let mutableString = NSMutableAttributedString(string: statusText.string) - mutableString.addAttributes([.foregroundColor: UIColor(Color.label)], - range: NSMakeRange(0, mutableString.string.utf16.count)) + private func highlightMeta() { + statusText.addAttributes([.foregroundColor: UIColor(Color.label)], + range: NSMakeRange(0, statusText.string.utf16.count)) let hashtagPattern = "(#+[a-zA-Z0-9(_)]{1,})" let mentionPattern = "(@+[a-zA-Z0-9(_).]{1,})" - var ranges: [NSRange] = [NSRange]() + let urlPattern = "(?i)https?://(?:www\\.)?\\S+(?:/|\\b)" do { let hashtagRegex = try NSRegularExpression(pattern: hashtagPattern, options: []) let mentionRegex = try NSRegularExpression(pattern: mentionPattern, options: []) + let urlRegex = try NSRegularExpression(pattern: urlPattern, options: []) - ranges = hashtagRegex.matches(in: mutableString.string, + var ranges = hashtagRegex.matches(in: statusText.string, options: [], - range: NSMakeRange(0, mutableString.string.utf16.count)).map { $0.range } - ranges.append(contentsOf: mentionRegex.matches(in: mutableString.string, + range: NSMakeRange(0, statusText.string.utf16.count)).map { $0.range } + ranges.append(contentsOf: mentionRegex.matches(in: statusText.string, options: [], - range: NSMakeRange(0, mutableString.string.utf16.count)).map {$0.range}) + range: NSMakeRange(0, statusText.string.utf16.count)).map {$0.range}) + + let urlRanges = urlRegex.matches(in: statusText.string, + options: [], + range: NSMakeRange(0, statusText.string.utf16.count)).map { $0.range } for range in ranges { - mutableString.addAttributes([.foregroundColor: UIColor(Color.brand)], + statusText.addAttributes([.foregroundColor: UIColor(Color.brand)], range: NSRange(location: range.location, length: range.length)) } - internalUpdate = true - statusText = mutableString - internalUpdate = false + + for range in urlRanges { + statusText.addAttributes([.foregroundColor: UIColor(Color.brand), + .underlineStyle: NSUnderlineStyle.single, + .underlineColor: UIColor(Color.brand)], + range: NSRange(location: range.location, length: range.length)) + } } catch { } } + private func checkEmbed() { + if let embededStatus, !statusText.string.contains(embededStatus.reblog?.id ?? embededStatus.id) { + self.embededStatus = nil + self.mode = .new + } + } + func inflateSelectedMedias() { for media in selectedMedias { media.loadTransferable(type: Data.self) { [weak self] result in diff --git a/Packages/Status/Sources/Status/Editor/StatusEditorViewModelMode.swift b/Packages/Status/Sources/Status/Editor/StatusEditorViewModelMode.swift new file mode 100644 index 00000000..faf2860e --- /dev/null +++ b/Packages/Status/Sources/Status/Editor/StatusEditorViewModelMode.swift @@ -0,0 +1,32 @@ +import Models + +extension StatusEditorViewModel { + public enum Mode { + case replyTo(status: Status) + case new + case edit(status: Status) + case quote(status: Status) + + var replyToStatus: Status? { + switch self { + case let .replyTo(status): + return status + default: + return nil + } + } + + var title: String { + switch self { + case .new: + return "New Post" + case .edit: + return "Edit your post" + case let .replyTo(status): + return "Reply to \(status.reblog?.account.displayName ?? status.account.displayName)" + case let .quote(status): + return "Quote of \(status.reblog?.account.displayName ?? status.account.displayName)" + } + } + } +} diff --git a/Packages/Status/Sources/Status/Embed/StatusEmbededView.swift b/Packages/Status/Sources/Status/Embed/StatusEmbededView.swift new file mode 100644 index 00000000..289a0a7c --- /dev/null +++ b/Packages/Status/Sources/Status/Embed/StatusEmbededView.swift @@ -0,0 +1,48 @@ +import SwiftUI +import Models +import DesignSystem + +@MainActor +public struct StatusEmbededView: View { + public let status: Status + + public init(status: Status) { + self.status = status + } + + public var body: some View { + HStack { + VStack(alignment: .leading) { + makeAccountView(account: status.reblog?.account ?? status.account) + StatusRowView(viewModel: .init(status: status, isEmbed: true)) + } + Spacer() + } + .padding(8) + .background(Color.gray.opacity(0.10)) + .cornerRadius(4) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(.gray.opacity(0.35), lineWidth: 1) + ) + .padding(.top, 8) + } + + private func makeAccountView(account: Account) -> some View { + HStack(alignment: .center) { + AvatarView(url: account.avatar, size: .embed) + VStack(alignment: .leading, spacing: 0) { + status.account.displayNameWithEmojis + .font(.footnote) + .fontWeight(.semibold) + Group { + Text("@\(account.acct)") + + Text(" ⸱ ") + + Text(status.reblog?.createdAt.formatted ?? status.createdAt.formatted) + } + .font(.caption) + .foregroundColor(.gray) + } + } + } +} diff --git a/Packages/Status/Sources/Status/Ext/Visibility.swift b/Packages/Status/Sources/Status/Ext/Visibility.swift new file mode 100644 index 00000000..b3bcc22c --- /dev/null +++ b/Packages/Status/Sources/Status/Ext/Visibility.swift @@ -0,0 +1,16 @@ +import Models + +extension Visibility { + public var iconName: String { + switch self { + case .pub: + return "globe.americas" + case .unlisted: + return "lock.open" + case .priv: + return "lock" + case .direct: + return "at.circle" + } + } +} diff --git a/Packages/Status/Sources/Status/Row/StatusActionsView.swift b/Packages/Status/Sources/Status/Row/StatusActionsView.swift index f88a6cad..101e8bec 100644 --- a/Packages/Status/Sources/Status/Row/StatusActionsView.swift +++ b/Packages/Status/Sources/Status/Row/StatusActionsView.swift @@ -5,6 +5,7 @@ import Network import DesignSystem struct StatusActionsView: View { + @Environment(\.openURL) private var openURL @EnvironmentObject private var routeurPath: RouterPath @ObservedObject var viewModel: StatusRowViewModel @@ -91,10 +92,19 @@ struct StatusActionsView: View { HStack { Text(viewModel.status.createdAt.asDate, style: .date) Text(viewModel.status.createdAt.asDate, style: .time) + Text("·") + Image(systemName: viewModel.status.visibility.iconName) Spacer() Text(viewModel.status.application?.name ?? "") + .underline() + .onTapGesture { + if let url = viewModel.status.application?.website { + openURL(url) + } + } } .font(.caption) + .foregroundColor(.gray) if viewModel.favouritesCount > 0 { Divider() Button { diff --git a/Packages/Status/Sources/Status/Row/StatusMediaPreviewView.swift b/Packages/Status/Sources/Status/Row/StatusMediaPreviewView.swift index c88d8d19..c702d4a3 100644 --- a/Packages/Status/Sources/Status/Row/StatusMediaPreviewView.swift +++ b/Packages/Status/Sources/Status/Row/StatusMediaPreviewView.swift @@ -3,6 +3,7 @@ import Models import Env import Shimmer import NukeUI +import DesignSystem public struct StatusMediaPreviewView: View { @EnvironmentObject private var quickLook: QuickLook @@ -10,6 +11,7 @@ public struct StatusMediaPreviewView: View { public let attachements: [MediaAttachement] @State private var isQuickLookLoading: Bool = false + @State private var width: CGFloat = 0 private var imageMaxHeight: CGFloat { if attachements.count == 1 { @@ -18,6 +20,20 @@ public struct StatusMediaPreviewView: View { return attachements.count > 2 ? 100 : 200 } + private func size(for media: MediaAttachement) -> CGSize? { + if let width = media.meta?.original.width, + let height = media.meta?.original.height { + return .init(width: CGFloat(width), height: CGFloat(height)) + } + return nil + } + + private func imageSize(from: CGSize, newWidth: CGFloat) -> CGSize { + let ratio = newWidth / from.width + let newHeight = from.height * ratio + return .init(width: newWidth, height: newHeight) + } + public var body: some View { Group { if attachements.count == 1, let attachement = attachements.first { @@ -60,21 +76,38 @@ public struct StatusMediaPreviewView: View { private func makeFeaturedImagePreview(attachement: MediaAttachement) -> some View { switch attachement.supportedType { case .image: - AsyncImage( - url: attachement.url, - content: { image in - image - .resizable() - .aspectRatio(contentMode: .fill) - .cornerRadius(4) - }, - placeholder: { - RoundedRectangle(cornerRadius: 4) - .fill(Color.gray) - .frame(height: imageMaxHeight) - .shimmering() + if let size = size(for: attachement) { + let newSize = imageSize(from: size, + newWidth: UIScreen.main.bounds.width - (DS.Constants.layoutPadding * 2)) + LazyImage(url: attachement.url) { state in + if let image = state.image { + image + .resizingMode(.aspectFill) + .cornerRadius(4) + .frame(width: newSize.width, height: newSize.height) + } else { + RoundedRectangle(cornerRadius: 4) + .fill(Color.gray) + .frame(width: newSize.width, height: newSize.height) + .shimmering() + } } - ) + } else { + AsyncImage( + url: attachement.url, + content: { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + .cornerRadius(4) + }, + placeholder: { + RoundedRectangle(cornerRadius: 4) + .fill(Color.gray) + .frame(height: imageMaxHeight) + .shimmering() + }) + } case .gifv: VideoPlayerView(viewModel: .init(url: attachement.url)) .frame(height: imageMaxHeight) diff --git a/Packages/Status/Sources/Status/Row/StatusRowView.swift b/Packages/Status/Sources/Status/Row/StatusRowView.swift index 73429f5d..d67c363d 100644 --- a/Packages/Status/Sources/Status/Row/StatusRowView.swift +++ b/Packages/Status/Sources/Status/Row/StatusRowView.swift @@ -35,6 +35,14 @@ public struct StatusRowView: View { } .onAppear { viewModel.client = client + if !viewModel.isEmbed { + Task { + await viewModel.loadEmbededStatus() + } + } + } + .contextMenu { + contextMenu } } @@ -81,54 +89,65 @@ public struct StatusRowView: View { Button { routeurPath.navigate(to: .accountDetailWithAccount(account: status.account)) } label: { - makeAccountView(status: status) + accountView(status: status) }.buttonStyle(.plain) Spacer() menuButton } } - - Group { - Text(status.content.asSafeAttributedString) - .font(.body) - .environment(\.openURL, OpenURLAction { url in - routeurPath.handleStatus(status: status, url: url) - }) - - if !status.mediaAttachments.isEmpty { - if viewModel.isEmbed { - Image(systemName: "paperclip") - } else { - StatusMediaPreviewView(attachements: status.mediaAttachments) - .padding(.vertical, 4) - } - } - if let card = status.card, !viewModel.isEmbed { - StatusCardView(card: card) - } - } - .contentShape(Rectangle()) - .onTapGesture { - routeurPath.navigate(to: .statusDetail(id: viewModel.status.reblog?.id ?? viewModel.status.id)) - } + makeStatusContentView(status: status) } } } - @ViewBuilder - private func makeAccountView(status: AnyStatus) -> some View { - AvatarView(url: status.account.avatar, size: .status) - VStack(alignment: .leading, spacing: 0) { - status.account.displayNameWithEmojis - .font(.subheadline) - .fontWeight(.semibold) - Group { - Text("@\(status.account.acct)") + - Text(" ⸱ ") + - Text(status.createdAt.formatted) + private func makeStatusContentView(status: AnyStatus) -> some View { + Group { + Text(status.content.asSafeAttributedString) + .font(.body) + .environment(\.openURL, OpenURLAction { url in + routeurPath.handleStatus(status: status, url: url) + }) + + if !viewModel.isEmbed, let embed = viewModel.embededStatus { + StatusEmbededView(status: embed) + } + + if !status.mediaAttachments.isEmpty { + if viewModel.isEmbed { + Image(systemName: "paperclip") + } else { + StatusMediaPreviewView(attachements: status.mediaAttachments) + .padding(.vertical, 4) + } + } + if let card = status.card, !viewModel.isEmbed { + StatusCardView(card: card) + } + } + .contentShape(Rectangle()) + .onTapGesture { + routeurPath.navigate(to: .statusDetail(id: viewModel.status.reblog?.id ?? viewModel.status.id)) + } + } + + @ViewBuilder + private func accountView(status: AnyStatus) -> some View { + HStack(alignment: .center) { + AvatarView(url: status.account.avatar, size: .status) + VStack(alignment: .leading, spacing: 0) { + status.account.displayNameWithEmojis + .font(.headline) + .fontWeight(.semibold) + Group { + Text("@\(status.account.acct)") + + Text(" ⸱ ") + + Text(status.createdAt.formatted) + + Text(" ⸱ ") + + Text(Image(systemName: viewModel.status.visibility.iconName)) + } + .font(.footnote) + .foregroundColor(.gray) } - .font(.footnote) - .foregroundColor(.gray) } } @@ -154,6 +173,21 @@ public struct StatusRowView: View { } } label: { Label(viewModel.isFavourited ? "Unfavorite" : "Favorite", systemImage: "star") } + Button { Task { + if viewModel.isReblogged { + await viewModel.unReblog() + } else { + await viewModel.reblog() + } + } } label: { + Label(viewModel.isReblogged ? "Unboost" : "Boost", systemImage: "arrow.left.arrow.right.circle") + } + Button { + routeurPath.presentedSheet = .quoteStatusEditor(status: viewModel.status) + } label: { + Label("Quote this post", systemImage: "quote.bubble") + } + if let url = viewModel.status.reblog?.url ?? viewModel.status.url { Button { UIApplication.shared.open(url) } label: { Label("View in Browser", systemImage: "safari") diff --git a/Packages/Status/Sources/Status/Row/StatusRowViewModel.swift b/Packages/Status/Sources/Status/Row/StatusRowViewModel.swift index 4233facd..5d0aead3 100644 --- a/Packages/Status/Sources/Status/Row/StatusRowViewModel.swift +++ b/Packages/Status/Sources/Status/Row/StatusRowViewModel.swift @@ -13,6 +13,7 @@ public class StatusRowViewModel: ObservableObject { @Published var isReblogged: Bool @Published var reblogsCount: Int @Published var repliesCount: Int + @Published var embededStatus: Status? var client: Client? @@ -34,6 +35,16 @@ public class StatusRowViewModel: ObservableObject { self.repliesCount = status.reblog?.repliesCount ?? status.repliesCount } + func loadEmbededStatus() async { + guard let client, + let ids = status.content.findStatusesIds(instance: client.server), + !ids.isEmpty, + let id = ids.first else { return } + do { + self.embededStatus = try await client.get(endpoint: Statuses.status(id: String(id))) + } catch { } + } + func favourite() async { guard let client, client.isAuth else { return } isFavourited = true diff --git a/Packages/Timeline/Sources/Timeline/TimelineView.swift b/Packages/Timeline/Sources/Timeline/TimelineView.swift index c0099fcd..3b020258 100644 --- a/Packages/Timeline/Sources/Timeline/TimelineView.swift +++ b/Packages/Timeline/Sources/Timeline/TimelineView.swift @@ -7,6 +7,10 @@ import DesignSystem import Env public struct TimelineView: View { + private enum Constants { + static let scrollToTop = "top" + } + @Environment(\.scenePhase) private var scenePhase @EnvironmentObject private var account: CurrentAccount @EnvironmentObject private var watcher: StreamWatcher @@ -25,7 +29,7 @@ public struct TimelineView: View { LazyVStack { tagHeaderView .padding(.bottom, 16) - .id("top") + .id(Constants.scrollToTop) StatusesListView(fetcher: viewModel) } .padding(.top, DS.Constants.layoutPadding) @@ -70,8 +74,10 @@ public struct TimelineView: View { private func makePendingNewPostsView(proxy: ScrollViewProxy) -> some View { if !viewModel.pendingStatuses.isEmpty { Button { - proxy.scrollTo("top") - viewModel.displayPendingStatuses() + proxy.scrollTo(Constants.scrollToTop) + withAnimation { + viewModel.displayPendingStatuses() + } } label: { Text(viewModel.pendingStatusesButtonTitle) } diff --git a/Packages/Timeline/Sources/Timeline/TimelineViewModel.swift b/Packages/Timeline/Sources/Timeline/TimelineViewModel.swift index 1f54e6ea..c653c038 100644 --- a/Packages/Timeline/Sources/Timeline/TimelineViewModel.swift +++ b/Packages/Timeline/Sources/Timeline/TimelineViewModel.swift @@ -128,7 +128,8 @@ class TimelineViewModel: ObservableObject, StatusesFetcher { statuses.insert(event.status, at: 0) statusesState = .display(statuses: statuses, nextPageState: .hasNextPage) } else if pendingStatusesEnabled, - !statuses.contains(where: { $0.id == event.status.id }) { + !statuses.contains(where: { $0.id == event.status.id }), + !pendingStatuses.contains(where: { $0.id == event.status.id }){ pendingStatuses.insert(event.status, at: 0) pendingStatusesState = .stream }