From 899ccd8ad73757426e516e654922d2bee3eb7b33 Mon Sep 17 00:00:00 2001 From: Thomas Ricouard Date: Tue, 17 Jan 2023 13:02:05 +0100 Subject: [PATCH] macOS / iPad app fixes + support drop in the editor + global new post button --- IceCubesApp.xcodeproj/project.pbxproj | 4 + IceCubesApp/App/IceCubesApp.swift | 25 ++-- IceCubesApp/App/QuickLookRepresentable.swift | 9 +- IceCubesApp/App/SideBarView.swift | 113 +++++++++++++----- IceCubesApp/App/Tabs/ProfileTab.swift | 50 ++++++++ .../Sources/DesignSystem/DesignSystem.swift | 1 + .../StatusEditorUTTypeSupported.swift | 5 + .../Status/Editor/StatusEditorView.swift | 1 + .../Status/Editor/StatusEditorViewModel.swift | 10 +- 9 files changed, 174 insertions(+), 44 deletions(-) create mode 100644 IceCubesApp/App/Tabs/ProfileTab.swift diff --git a/IceCubesApp.xcodeproj/project.pbxproj b/IceCubesApp.xcodeproj/project.pbxproj index 3e5d33fa..b36c1031 100644 --- a/IceCubesApp.xcodeproj/project.pbxproj +++ b/IceCubesApp.xcodeproj/project.pbxproj @@ -34,6 +34,7 @@ 9F398AA92935FFDB00A889F2 /* Account in Frameworks */ = {isa = PBXBuildFile; productRef = 9F398AA82935FFDB00A889F2 /* Account */; }; 9F398AAB2935FFDB00A889F2 /* Models in Frameworks */ = {isa = PBXBuildFile; productRef = 9F398AAA2935FFDB00A889F2 /* Models */; }; 9F398AB329360A4C00A889F2 /* TimelineTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F398AB229360A4C00A889F2 /* TimelineTab.swift */; }; + 9F4A48192976B21900A1A038 /* ProfileTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4A48182976B21900A1A038 /* ProfileTab.swift */; }; 9F55C68D2955968700F94077 /* ExploreTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F55C68C2955968700F94077 /* ExploreTab.swift */; }; 9F55C6902955993C00F94077 /* Explore in Frameworks */ = {isa = PBXBuildFile; productRef = 9F55C68F2955993C00F94077 /* Explore */; }; 9F5E581929545BE700A53960 /* Env in Frameworks */ = {isa = PBXBuildFile; productRef = 9F5E581829545BE700A53960 /* Env */; }; @@ -125,6 +126,7 @@ 9F398AA52935FE8A00A889F2 /* AppRouteur.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRouteur.swift; sourceTree = ""; }; 9F398AAC2936005300A889F2 /* Account */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Account; path = Packages/Account; sourceTree = ""; }; 9F398AB229360A4C00A889F2 /* TimelineTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineTab.swift; sourceTree = ""; }; + 9F4A48182976B21900A1A038 /* ProfileTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileTab.swift; sourceTree = ""; }; 9F55C68C2955968700F94077 /* ExploreTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExploreTab.swift; sourceTree = ""; }; 9F55C68E295598F900F94077 /* Explore */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Explore; path = Packages/Explore; sourceTree = ""; }; 9F5E581729545B5500A53960 /* Env */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Env; path = Packages/Env; sourceTree = ""; }; @@ -267,6 +269,7 @@ 9F35DB4B2952005C00B3281A /* MessagesTab.swift */, 9F55C68C2955968700F94077 /* ExploreTab.swift */, 9F2B92F5295AE04800DE16D0 /* Tabs.swift */, + 9F4A48182976B21900A1A038 /* ProfileTab.swift */, ); path = Tabs; sourceTree = ""; @@ -537,6 +540,7 @@ 9F398AB329360A4C00A889F2 /* TimelineTab.swift in Sources */, 9F398AA62935FE8A00A889F2 /* AppRouteur.swift in Sources */, 9FBFE63D292A715500C250E9 /* IceCubesApp.swift in Sources */, + 9F4A48192976B21900A1A038 /* ProfileTab.swift in Sources */, 9F2B92FA295DA7D700DE16D0 /* AddAccountsView.swift in Sources */, 639CDF9C296AC82F00C35E58 /* SafariRouteur.swift in Sources */, 9F35DB4729506F6600B3281A /* NotificationTab.swift in Sources */, diff --git a/IceCubesApp/App/IceCubesApp.swift b/IceCubesApp/App/IceCubesApp.swift index 5da77983..a244019c 100644 --- a/IceCubesApp/App/IceCubesApp.swift +++ b/IceCubesApp/App/IceCubesApp.swift @@ -21,12 +21,13 @@ struct IceCubesApp: App { @StateObject private var watcher = StreamWatcher() @StateObject private var quickLook = QuickLook() @StateObject private var theme = Theme.shared + @StateObject private var sidebarRouterPath = RouterPath() @State private var selectedTab: Tab = .timeline @State private var selectSidebarItem: Tab? = .timeline @State private var popToRootTab: Tab = .other - @State private var sideBarLoadedTabs: [Tab] = [] - + @State private var sideBarLoadedTabs: Set = Set() + private var availableTabs: [Tab] { appAccountsManager.currentClient.isAuth ? Tab.loggedInTabs() : Tab.loggedOutTab() } @@ -54,6 +55,14 @@ struct IceCubesApp: App { .edgesIgnoringSafeArea(.bottom) }) } + .commands { + CommandGroup(replacing: CommandGroupPlacement.newItem) { + Button("New post") { + sidebarRouterPath.presentedSheet = .newStatusEditor(visibility: userPreferences.serverPreferences?.postVisibility ?? .pub) + } + .keyboardShortcut("n", modifiers: .command) + } + } .onChange(of: scenePhase) { scenePhase in handleScenePhase(scenePhase: scenePhase) } @@ -84,10 +93,11 @@ struct IceCubesApp: App { private var sidebarView: some View { SideBarView(selectedTab: $selectedTab, popToRootTab: $popToRootTab, - tabs: availableTabs) { + tabs: availableTabs, + routerPath: sidebarRouterPath) { ZStack { - if let account = currentAccount.account, selectedTab == .profile { - AccountDetailView(account: account) + if selectedTab == .profile { + ProfileTab(popToRootTab: $popToRootTab) } ForEach(availableTabs) { tab in if tab == selectedTab || sideBarLoadedTabs.contains(tab) { @@ -96,9 +106,7 @@ struct IceCubesApp: App { .opacity(tab == selectedTab ? 1 : 0) .id(tab) .onAppear { - if !sideBarLoadedTabs.contains(tab) { - sideBarLoadedTabs.append(tab) - } + sideBarLoadedTabs.insert(tab) } } else { EmptyView() @@ -186,4 +194,5 @@ class AppDelegate: NSObject, UIApplicationDelegate { } func application(_: UIApplication, didFailToRegisterForRemoteNotificationsWithError _: Error) {} + } diff --git a/IceCubesApp/App/QuickLookRepresentable.swift b/IceCubesApp/App/QuickLookRepresentable.swift index 1534600b..31d8c1c8 100644 --- a/IceCubesApp/App/QuickLookRepresentable.swift +++ b/IceCubesApp/App/QuickLookRepresentable.swift @@ -56,12 +56,9 @@ class AppQLPreviewCpntroller: QLPreviewController { override func viewDidLoad() { super.viewDidLoad() - navigationItem.rightBarButtonItem = closeButton - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - navigationItem.rightBarButtonItem = closeButton + if UIDevice.current.userInterfaceIdiom != .pad { + navigationItem.rightBarButtonItem = closeButton + } } override func viewDidLayoutSubviews() { diff --git a/IceCubesApp/App/SideBarView.swift b/IceCubesApp/App/SideBarView.swift index 720bd0c3..6e20d242 100644 --- a/IceCubesApp/App/SideBarView.swift +++ b/IceCubesApp/App/SideBarView.swift @@ -7,51 +7,106 @@ import SwiftUI struct SideBarView: View { @EnvironmentObject private var currentAccount: CurrentAccount @EnvironmentObject private var theme: Theme - + @EnvironmentObject private var watcher: StreamWatcher + @EnvironmentObject private var userPreferences: UserPreferences + @Binding var selectedTab: Tab @Binding var popToRootTab: Tab var tabs: [Tab] + @ObservedObject var routerPath = RouterPath() @ViewBuilder var content: () -> Content + private func badgeFor(tab: Tab) -> Int { + if tab == .notifications && selectedTab != tab { + return watcher.unreadNotificationsCount + userPreferences.pushNotificationsCount + } + return 0 + } + + private var profileView: some View { + Button { + selectedTab = .profile + } label: { + AppAccountsSelectorView(routeurPath: RouterPath(), + accountCreationEnabled: false, + avatarSize: .status) + } + .frame(width: .sidebarWidth, height: 60) + .background(selectedTab == .profile ? theme.secondaryBackgroundColor : .clear) + } + + private func makeIconForTab(tab: Tab) -> some View { + ZStack(alignment: .topTrailing) { + Image(systemName: tab.iconName) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 24, height: 24) + .foregroundColor(tab == selectedTab ? theme.tintColor : .gray) + if let badge = badgeFor(tab: tab), badge > 0 { + ZStack { + Circle() + .fill(.red) + Text(String(badge)) + .foregroundColor(.white) + .font(.footnote) + } + .frame(width: 20, height: 20) + .offset(x: 10, y: -10) + } + } + .contentShape(Rectangle()) + .frame(width: .sidebarWidth, height: 50) + } + + private var postButton: some View { + Button { + routerPath.presentedSheet = .newStatusEditor(visibility: userPreferences.serverPreferences?.postVisibility ?? .pub) + } label: { + Image(systemName: "square.and.pencil") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 20, height: 30) + } + .buttonStyle(.borderedProminent) + .keyboardShortcut("n", modifiers: .command) + } + var body: some View { HStack(spacing: 0) { - VStack(alignment: .center) { - Button { - selectedTab = .profile - } label: { - AppAccountsSelectorView(routeurPath: RouterPath(), - accountCreationEnabled: false, - avatarSize: .status) - } - .frame(width: 80, height: 60) - .background(selectedTab == .profile ? theme.secondaryBackgroundColor : .clear) - ForEach(tabs) { tab in - Button { - if tab == selectedTab { - popToRootTab = .other - DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { - popToRootTab = tab + ScrollView { + VStack(alignment: .center) { + profileView + ForEach(tabs) { tab in + Button { + if tab == selectedTab { + popToRootTab = .other + DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { + popToRootTab = tab + } } + selectedTab = tab + if tab == .notifications { + watcher.unreadNotificationsCount = 0 + userPreferences.pushNotificationsCount = 0 + } + } label: { + makeIconForTab(tab: tab) } - selectedTab = tab - } label: { - Image(systemName: tab.iconName) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 24, height: 24) - .foregroundColor(tab == selectedTab ? theme.tintColor : .gray) + .background(tab == selectedTab ? theme.secondaryBackgroundColor : .clear) } - .frame(width: 80, height: 50) - .background(tab == selectedTab ? theme.secondaryBackgroundColor : .clear) + postButton + .padding(.top, 12) + Spacer() } - Spacer() } - .frame(width: 80) - .background(.clear) + .frame(width: .sidebarWidth) + .scrollContentBackground(.hidden) + .background(.thinMaterial) Divider() .edgesIgnoringSafeArea(.top) content() } .background(.thinMaterial) + .withSheetDestinations(sheetDestinations: $routerPath.presentedSheet) } } diff --git a/IceCubesApp/App/Tabs/ProfileTab.swift b/IceCubesApp/App/Tabs/ProfileTab.swift new file mode 100644 index 00000000..a44f1162 --- /dev/null +++ b/IceCubesApp/App/Tabs/ProfileTab.swift @@ -0,0 +1,50 @@ +import Account +import AppAccount +import Conversations +import Env +import Models +import Network +import Shimmer +import SwiftUI + +struct ProfileTab: View { + @EnvironmentObject private var client: Client + @EnvironmentObject private var currentAccount: CurrentAccount + @StateObject private var routeurPath = RouterPath() + @Binding var popToRootTab: Tab + + var body: some View { + NavigationStack(path: $routeurPath.path) { + if let account = currentAccount.account { + AccountDetailView(account: account) + .withAppRouteur() + .withSheetDestinations(sheetDestinations: $routeurPath.presentedSheet) + .toolbar { + if UIDevice.current.userInterfaceIdiom != .pad { + ToolbarItem(placement: .navigationBarLeading) { + AppAccountsSelectorView(routeurPath: routeurPath) + } + } + } + .id(currentAccount.account?.id) + } else { + AccountDetailView(account: .placeholder()) + .redacted(reason: .placeholder) + .shimmering() + } + } + .onChange(of: $popToRootTab.wrappedValue) { popToRootTab in + if popToRootTab == .messages { + routeurPath.path = [] + } + } + .onChange(of: currentAccount.account?.id) { _ in + routeurPath.path = [] + } + .onAppear { + routeurPath.client = client + } + .withSafariRouteur() + .environmentObject(routeurPath) + } +} diff --git a/Packages/DesignSystem/Sources/DesignSystem/DesignSystem.swift b/Packages/DesignSystem/Sources/DesignSystem/DesignSystem.swift index ba0ed1ce..534f9ef6 100644 --- a/Packages/DesignSystem/Sources/DesignSystem/DesignSystem.swift +++ b/Packages/DesignSystem/Sources/DesignSystem/DesignSystem.swift @@ -5,4 +5,5 @@ public extension CGFloat { static let dividerPadding: CGFloat = 2 static let statusColumnsSpacing: CGFloat = 8 static let maxColumnWidth: CGFloat = 650 + static let sidebarWidth: CGFloat = 80 } diff --git a/Packages/Status/Sources/Status/Editor/Components/StatusEditorUTTypeSupported.swift b/Packages/Status/Sources/Status/Editor/Components/StatusEditorUTTypeSupported.swift index 49cd99f9..33aa2de1 100644 --- a/Packages/Status/Sources/Status/Editor/Components/StatusEditorUTTypeSupported.swift +++ b/Packages/Status/Sources/Status/Editor/Components/StatusEditorUTTypeSupported.swift @@ -1,5 +1,6 @@ import Foundation import UIKit +import UniformTypeIdentifiers @MainActor enum StatusEditorUTTypeSupported: String, CaseIterable { @@ -9,6 +10,10 @@ enum StatusEditorUTTypeSupported: String, CaseIterable { case image = "public.image" case jpeg = "public.jpeg" case png = "public.png" + + static func types() -> [UTType] { + [.url, .text, .plainText, .image, .jpeg, .png] + } func loadItemContent(item: NSItemProvider) async throws -> Any? { let result = try await item.loadItem(forTypeIdentifier: rawValue) diff --git a/Packages/Status/Sources/Status/Editor/StatusEditorView.swift b/Packages/Status/Sources/Status/Editor/StatusEditorView.swift index 4c97017d..7202ef02 100644 --- a/Packages/Status/Sources/Status/Editor/StatusEditorView.swift +++ b/Packages/Status/Sources/Status/Editor/StatusEditorView.swift @@ -67,6 +67,7 @@ public struct StatusEditorView: View { viewModel: viewModel) } } + .onDrop(of: StatusEditorUTTypeSupported.types(), delegate: viewModel) .onAppear { viewModel.client = client viewModel.currentAccount = currentAccount.account diff --git a/Packages/Status/Sources/Status/Editor/StatusEditorViewModel.swift b/Packages/Status/Sources/Status/Editor/StatusEditorViewModel.swift index fb9b3c50..482ac590 100644 --- a/Packages/Status/Sources/Status/Editor/StatusEditorViewModel.swift +++ b/Packages/Status/Sources/Status/Editor/StatusEditorViewModel.swift @@ -238,7 +238,7 @@ public class StatusEditorViewModel: ObservableObject { for range in urlRanges { statusText.addAttributes([.foregroundColor: UIColor(theme?.tintColor ?? .brand), - .underlineStyle: NSUnderlineStyle.single, + .underlineStyle: NSUnderlineStyle.single.rawValue, .underlineColor: UIColor(theme?.tintColor ?? .brand)], range: NSRange(location: range.location, length: range.length)) } @@ -451,3 +451,11 @@ public class StatusEditorViewModel: ObservableObject { data: data) } } + +extension StatusEditorViewModel: DropDelegate { + public func performDrop(info: DropInfo) -> Bool { + let item = info.itemProviders(for: StatusEditorUTTypeSupported.types()) + processItemsProvider(items: item) + return true + } +}