Refactor + add more shortcuts on macOS

This commit is contained in:
Thomas Ricouard 2023-10-26 06:23:00 +02:00
parent 494b0df0e3
commit cf0f0fd891
18 changed files with 500 additions and 372 deletions

View file

@ -80,6 +80,10 @@
9FAE4ACE29379A5A00772766 /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 9FAE4ACD29379A5A00772766 /* KeychainSwift */; };
9FB143D12983104700A27BB1 /* glass.caf in Resources */ = {isa = PBXBuildFile; fileRef = 9F2A542B296B1177009B2D7C /* glass.caf */; };
9FB143D22983104A00A27BB1 /* glass.wav in Resources */ = {isa = PBXBuildFile; fileRef = 9F2A542D296B1CC0009B2D7C /* glass.wav */; };
9FB183222AE9268800BBB692 /* IceCubesApp+Menu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FB183212AE9268800BBB692 /* IceCubesApp+Menu.swift */; };
9FB183252AE926E900BBB692 /* IceCubesApp+Sidebar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FB183242AE926E900BBB692 /* IceCubesApp+Sidebar.swift */; };
9FB183272AE9279F00BBB692 /* IceCubesApp+Tabbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FB183262AE9279F00BBB692 /* IceCubesApp+Tabbar.swift */; };
9FB183292AE9449100BBB692 /* IceCubesApp+Scene.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FB183282AE9449100BBB692 /* IceCubesApp+Scene.swift */; };
9FBFE63D292A715500C250E9 /* IceCubesApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBFE63C292A715500C250E9 /* IceCubesApp.swift */; };
9FBFE64E292A72BD00C250E9 /* Network in Frameworks */ = {isa = PBXBuildFile; productRef = 9FBFE64D292A72BD00C250E9 /* Network */; };
9FD34823293D06E800DB0EE9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9FD34822293D06E800DB0EE9 /* Assets.xcassets */; };
@ -214,6 +218,10 @@
9FAD85CE2975B68900496AB1 /* SideBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SideBarView.swift; 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>"; };
9FB183212AE9268800BBB692 /* IceCubesApp+Menu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "IceCubesApp+Menu.swift"; sourceTree = "<group>"; };
9FB183242AE926E900BBB692 /* IceCubesApp+Sidebar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "IceCubesApp+Sidebar.swift"; sourceTree = "<group>"; };
9FB183262AE9279F00BBB692 /* IceCubesApp+Tabbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "IceCubesApp+Tabbar.swift"; sourceTree = "<group>"; };
9FB183282AE9449100BBB692 /* IceCubesApp+Scene.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "IceCubesApp+Scene.swift"; sourceTree = "<group>"; };
9FBFE639292A715500C250E9 /* IceCubesApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = IceCubesApp.app; sourceTree = BUILT_PRODUCTS_DIR; };
9FBFE63C292A715500C250E9 /* IceCubesApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IceCubesApp.swift; sourceTree = "<group>"; };
9FBFE642292A715600C250E9 /* IceCubesApp.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = IceCubesApp.entitlements; sourceTree = "<group>"; };
@ -337,9 +345,9 @@
9F398AB429360A5800A889F2 /* App */ = {
isa = PBXGroup;
children = (
9FB183232AE926BB00BBB692 /* Main */,
9F654BF0299AC46200D27FA5 /* Report */,
9FAE4AC9293783A200772766 /* Tabs */,
9FBFE63C292A715500C250E9 /* IceCubesApp.swift */,
9F398AA52935FE8A00A889F2 /* AppRegistry.swift */,
639CDF9B296AC82F00C35E58 /* SafariRouter.swift */,
9FAD85CE2975B68900496AB1 /* SideBarView.swift */,
@ -418,6 +426,18 @@
path = Tabs;
sourceTree = "<group>";
};
9FB183232AE926BB00BBB692 /* Main */ = {
isa = PBXGroup;
children = (
9FBFE63C292A715500C250E9 /* IceCubesApp.swift */,
9FB183212AE9268800BBB692 /* IceCubesApp+Menu.swift */,
9FB183242AE926E900BBB692 /* IceCubesApp+Sidebar.swift */,
9FB183262AE9279F00BBB692 /* IceCubesApp+Tabbar.swift */,
9FB183282AE9449100BBB692 /* IceCubesApp+Scene.swift */,
);
path = Main;
sourceTree = "<group>";
};
9FBFE630292A715500C250E9 = {
isa = PBXGroup;
children = (
@ -791,16 +811,20 @@
buildActionMask = 2147483647;
files = (
9FE151A6293C90F900E9683D /* IconSelectorView.swift in Sources */,
9FB183222AE9268800BBB692 /* IceCubesApp+Menu.swift in Sources */,
9FB183252AE926E900BBB692 /* IceCubesApp+Sidebar.swift in Sources */,
9F7D939A29805DBD00EE6B7A /* AccountSettingView.swift in Sources */,
069709AA298C9AD7006E4CB5 /* AboutView.swift in Sources */,
9F2B92FC295DA94500DE16D0 /* InstanceInfoView.swift in Sources */,
C9B22677297F6C2E001F9EFE /* ContentSettingsView.swift in Sources */,
9FA6FD6229C04A8800E2312C /* TranslationSettingsView.swift in Sources */,
9FB183272AE9279F00BBB692 /* IceCubesApp+Tabbar.swift in Sources */,
9F35DB4C2952005C00B3281A /* MessagesTab.swift in Sources */,
9FAD85CF2975B68900496AB1 /* SideBarView.swift in Sources */,
9FAE4ACB293783B000772766 /* SettingsTab.swift in Sources */,
9F7335F92968576500AFF0BA /* DisplaySettingsView.swift in Sources */,
9F2A540729699698009B2D7C /* SupportAppView.swift in Sources */,
9FB183292AE9449100BBB692 /* IceCubesApp+Scene.swift in Sources */,
9F2B92F6295AE04800DE16D0 /* Tabs.swift in Sources */,
FA31A9AB2A66BF7C00D5F662 /* EditTagGroupView.swift in Sources */,
FAD203D02A66D8A80030A7FD /* Symbols.swift in Sources */,
@ -891,7 +915,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.9.3;
MARKETING_VERSION = 1.9.4;
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).IceCubesApp.IceCubesNotifications";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@ -925,7 +949,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.9.3;
MARKETING_VERSION = 1.9.4;
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).IceCubesApp.IceCubesNotifications";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@ -960,7 +984,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.9.3;
MARKETING_VERSION = 1.9.4;
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).IceCubesApp.IceCubesShareExtension";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@ -994,7 +1018,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.9.3;
MARKETING_VERSION = 1.9.4;
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).IceCubesApp.IceCubesShareExtension";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@ -1174,7 +1198,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.9.3;
MARKETING_VERSION = 1.9.4;
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).IceCubesApp";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
@ -1228,7 +1252,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.9.3;
MARKETING_VERSION = 1.9.4;
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).IceCubesApp";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
@ -1263,7 +1287,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.9.3;
MARKETING_VERSION = 1.9.4;
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).IceCubesApp.IceCubesActionExtension";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@ -1298,7 +1322,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.9.3;
MARKETING_VERSION = 1.9.4;
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).IceCubesApp.IceCubesActionExtension";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;

View file

@ -10,6 +10,7 @@ import Models
import Status
import SwiftUI
import Timeline
import MediaUI
@MainActor
extension View {
@ -130,6 +131,7 @@ extension View {
.environment(AppAccountsManager.shared)
.environment(PushNotificationsService.shared)
.environment(AppAccountsManager.shared.currentClient)
.environment(QuickLook.shared)
}
func withModelContainer() -> some View {

View file

@ -1,339 +0,0 @@
import Account
import AppAccount
import AVFoundation
import DesignSystem
import Env
import KeychainSwift
import Network
import RevenueCat
import Status
import SwiftUI
import Timeline
import MediaUI
@main
struct IceCubesApp: App {
@UIApplicationDelegateAdaptor private var appDelegate: AppDelegate
@Environment(\.scenePhase) private var scenePhase
@Environment(\.openWindow) private var openWindow
@State private var appAccountsManager = AppAccountsManager.shared
@State private var currentInstance = CurrentInstance.shared
@State private var currentAccount = CurrentAccount.shared
@State private var userPreferences = UserPreferences.shared
@State private var pushNotificationsService = PushNotificationsService.shared
@State private var watcher = StreamWatcher()
@State private var quickLook = QuickLook()
@State private var theme = Theme.shared
@State private var sidebarRouterPath = RouterPath()
@State private var selectedTab: Tab = .timeline
@State private var popToRootTab: Tab = .other
@State private var sideBarLoadedTabs: Set<Tab> = Set()
@State private var isSupporter: Bool = false
private var availableTabs: [Tab] {
appAccountsManager.currentClient.isAuth ? Tab.loggedInTabs() : Tab.loggedOutTab()
}
var body: some Scene {
appScene
otherScenes
}
private var appScene: some Scene {
WindowGroup(id: "MainWindow") {
appView
.applyTheme(theme)
.onAppear {
setNewClientsInEnv(client: appAccountsManager.currentClient)
setupRevenueCat()
refreshPushSubs()
}
.environment(appAccountsManager)
.environment(appAccountsManager.currentClient)
.environment(quickLook)
.environment(currentAccount)
.environment(currentInstance)
.environment(userPreferences)
.environment(theme)
.environment(watcher)
.environment(pushNotificationsService)
.environment(\.isSupporter, isSupporter)
.sheet(item: $quickLook.selectedMediaAttachment) { selectedMediaAttachment in
MediaUIView(selectedAttachment: selectedMediaAttachment,
attachments: quickLook.mediaAttachments)
.presentationBackground(.ultraThinMaterial)
.presentationCornerRadius(16)
.withEnvironments()
}
.onChange(of: pushNotificationsService.handledNotification) { _, newValue in
if newValue != nil {
pushNotificationsService.handledNotification = nil
if appAccountsManager.currentAccount.oauthToken?.accessToken != newValue?.account.token.accessToken,
let account = appAccountsManager.availableAccounts.first(where:
{ $0.oauthToken?.accessToken == newValue?.account.token.accessToken })
{
appAccountsManager.currentAccount = account
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
selectedTab = .notifications
pushNotificationsService.handledNotification = newValue
}
} else {
selectedTab = .notifications
}
}
}
.withModelContainer()
}
.commands {
appMenu
}
.onChange(of: scenePhase) { _, newValue in
handleScenePhase(scenePhase: newValue)
}
.onChange(of: appAccountsManager.currentClient) { _, newValue in
setNewClientsInEnv(client: newValue)
if newValue.isAuth {
watcher.watch(streams: [.user, .direct])
}
}
}
@ViewBuilder
private var appView: some View {
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
sidebarView
} else {
tabBarView
}
}
private func badgeFor(tab: Tab) -> Int {
if tab == .notifications, selectedTab != tab,
let token = appAccountsManager.currentAccount.oauthToken
{
return watcher.unreadNotificationsCount + (userPreferences.notificationsCount[token] ?? 0)
}
return 0
}
private var sidebarView: some View {
SideBarView(selectedTab: $selectedTab,
popToRootTab: $popToRootTab,
tabs: availableTabs)
{
HStack(spacing: 0) {
ZStack {
if selectedTab == .profile {
ProfileTab(popToRootTab: $popToRootTab)
}
ForEach(availableTabs) { tab in
if tab == selectedTab || sideBarLoadedTabs.contains(tab) {
tab
.makeContentView(popToRootTab: $popToRootTab)
.opacity(tab == selectedTab ? 1 : 0)
.transition(.opacity)
.id("\(tab)\(appAccountsManager.currentAccount.id)")
.onAppear {
sideBarLoadedTabs.insert(tab)
}
} else {
EmptyView()
}
}
}
if appAccountsManager.currentClient.isAuth,
userPreferences.showiPadSecondaryColumn
{
Divider().edgesIgnoringSafeArea(.all)
notificationsSecondaryColumn
}
}
}.onChange(of: $appAccountsManager.currentAccount.id) {
sideBarLoadedTabs.removeAll()
}
.environment(sidebarRouterPath)
}
private var notificationsSecondaryColumn: some View {
NotificationsTab(popToRootTab: $popToRootTab, lockedType: nil)
.environment(\.isSecondaryColumn, true)
.frame(maxWidth: .secondaryColumnWidth)
.id(appAccountsManager.currentAccount.id)
}
private var tabBarView: some View {
TabView(selection: .init(get: {
selectedTab
}, set: { newTab in
if newTab == selectedTab {
/// Stupid hack to trigger onChange binding in tab views.
popToRootTab = .other
DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {
popToRootTab = selectedTab
}
}
HapticManager.shared.fireHaptic(of: .tabSelection)
SoundEffectManager.shared.playSound(of: .tabSelection)
selectedTab = newTab
DispatchQueue.main.async {
if selectedTab == .notifications,
let token = appAccountsManager.currentAccount.oauthToken
{
userPreferences.notificationsCount[token] = 0
watcher.unreadNotificationsCount = 0
}
}
})) {
ForEach(availableTabs) { tab in
tab.makeContentView(popToRootTab: $popToRootTab)
.tabItem {
if userPreferences.showiPhoneTabLabel {
tab.label
} else {
Image(systemName: tab.iconName)
}
}
.tag(tab)
.badge(badgeFor(tab: tab))
.toolbarBackground(theme.primaryBackgroundColor.opacity(0.50), for: .tabBar)
}
}
.id(appAccountsManager.currentClient.id)
}
private var otherScenes: some Scene {
WindowGroup(for: WindowDestination.self) { destination in
Group {
switch destination.wrappedValue {
case let .newStatusEditor(visibility):
StatusEditorView(mode: .new(visibility: visibility))
case let .mediaViewer(attachments, selectedAttachment):
MediaUIView(selectedAttachment: selectedAttachment,
attachments: attachments)
case .none:
EmptyView()
}
}
.withEnvironments()
.withModelContainer()
.applyTheme(theme)
}
.defaultSize(width: 600, height: 800)
.windowResizability(.automatic)
}
private func setNewClientsInEnv(client: Client) {
currentAccount.setClient(client: client)
currentInstance.setClient(client: client)
userPreferences.setClient(client: client)
Task {
await currentInstance.fetchCurrentInstance()
watcher.setClient(client: client, instanceStreamingURL: currentInstance.instance?.urls?.streamingApi)
watcher.watch(streams: [.user, .direct])
}
}
private func handleScenePhase(scenePhase: ScenePhase) {
switch scenePhase {
case .background:
watcher.stopWatching()
case .active:
watcher.watch(streams: [.user, .direct])
UNUserNotificationCenter.current().setBadgeCount(0)
userPreferences.reloadNotificationsCount(tokens: appAccountsManager.availableAccounts.compactMap(\.oauthToken))
Task {
await userPreferences.refreshServerPreferences()
}
default:
break
}
}
private func setupRevenueCat() {
Purchases.logLevel = .error
Purchases.configure(withAPIKey: "appl_JXmiRckOzXXTsHKitQiicXCvMQi")
Purchases.shared.getCustomerInfo { info, _ in
if info?.entitlements["Supporter"]?.isActive == true {
isSupporter = true
}
}
}
private func refreshPushSubs() {
PushNotificationsService.shared.requestPushNotifications()
}
@CommandsBuilder
private var appMenu: some Commands {
CommandGroup(replacing: .newItem) {
Button("menu.new-window") {
openWindow(id: "MainWindow")
}
.keyboardShortcut("n", modifiers: .shift)
Button("menu.new-post") {
if ProcessInfo.processInfo.isMacCatalystApp {
openWindow(value: WindowDestination.newStatusEditor(visibility: userPreferences.postVisibility))
} else {
sidebarRouterPath.presentedSheet = .newStatusEditor(visibility: userPreferences.postVisibility)
}
}
.keyboardShortcut("n", modifiers: .command)
}
CommandGroup(replacing: .textFormatting) {
Menu("menu.font") {
Button("menu.font.bigger") {
if theme.fontSizeScale < 1.5 {
theme.fontSizeScale += 0.1
}
}
Button("menu.font.smaller") {
if theme.fontSizeScale > 0.5 {
theme.fontSizeScale -= 0.1
}
}
}
}
}
}
class AppDelegate: NSObject, UIApplicationDelegate {
func application(_: UIApplication,
didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool
{
try? AVAudioSession.sharedInstance().setCategory(.ambient)
PushNotificationsService.shared.setAccounts(accounts: AppAccountsManager.shared.pushAccounts)
return true
}
func application(_: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data)
{
PushNotificationsService.shared.pushToken = deviceToken
Task {
PushNotificationsService.shared.setAccounts(accounts: AppAccountsManager.shared.pushAccounts)
await PushNotificationsService.shared.updateSubscriptions(forceCreate: false)
}
}
func application(_: UIApplication, didFailToRegisterForRemoteNotificationsWithError _: Error) {}
func application(_: UIApplication, didReceiveRemoteNotification _: [AnyHashable: Any]) async -> UIBackgroundFetchResult {
UserPreferences.shared.reloadNotificationsCount(tokens: AppAccountsManager.shared.availableAccounts.compactMap(\.oauthToken))
return .noData
}
func application(_: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options _: UIScene.ConnectionOptions) -> UISceneConfiguration {
let configuration = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role)
if connectingSceneSession.role == .windowApplication {
configuration.delegateClass = SceneDelegate.self
}
return configuration
}
}

View file

@ -0,0 +1,58 @@
import SwiftUI
import Env
extension IceCubesApp {
@CommandsBuilder
var appMenu: some Commands {
CommandGroup(replacing: .newItem) {
Button("menu.new-window") {
openWindow(id: "MainWindow")
}
.keyboardShortcut("n", modifiers: .shift)
Button("menu.new-post") {
if ProcessInfo.processInfo.isMacCatalystApp {
openWindow(value: WindowDestination.newStatusEditor(visibility: userPreferences.postVisibility))
} else {
sidebarRouterPath.presentedSheet = .newStatusEditor(visibility: userPreferences.postVisibility)
}
}
.keyboardShortcut("n", modifiers: .command)
}
CommandGroup(replacing: .textFormatting) {
Menu("menu.font") {
Button("menu.font.bigger") {
if theme.fontSizeScale < 1.5 {
theme.fontSizeScale += 0.1
}
}
Button("menu.font.smaller") {
if theme.fontSizeScale > 0.5 {
theme.fontSizeScale -= 0.1
}
}
}
}
CommandMenu("tab.timeline") {
Button("timeline.latest") {
NotificationCenter.default.post(name: .refreshTimeline, object: nil)
}
.keyboardShortcut("r", modifiers: .command)
Button("timeline.home") {
NotificationCenter.default.post(name: .homeTimeline, object: nil)
}
.keyboardShortcut("h", modifiers: .shift)
Button("timeline.trending") {
NotificationCenter.default.post(name: .trendingTimeline, object: nil)
}
.keyboardShortcut("t", modifiers: .shift)
Button("timeline.federated") {
NotificationCenter.default.post(name: .federatedTimeline, object: nil)
}
.keyboardShortcut("f", modifiers: .shift)
Button("timeline.local") {
NotificationCenter.default.post(name: .localTimeline, object: nil)
}
.keyboardShortcut("l", modifiers: .shift)
}
}
}

View file

@ -0,0 +1,102 @@
import SwiftUI
import Env
import Status
import MediaUI
extension IceCubesApp {
var appScene: some Scene {
WindowGroup(id: "MainWindow") {
appView
.applyTheme(theme)
.onAppear {
setNewClientsInEnv(client: appAccountsManager.currentClient)
setupRevenueCat()
refreshPushSubs()
}
.environment(appAccountsManager)
.environment(appAccountsManager.currentClient)
.environment(quickLook)
.environment(currentAccount)
.environment(currentInstance)
.environment(userPreferences)
.environment(theme)
.environment(watcher)
.environment(pushNotificationsService)
.environment(\.isSupporter, isSupporter)
.sheet(item: $quickLook.selectedMediaAttachment) { selectedMediaAttachment in
MediaUIView(selectedAttachment: selectedMediaAttachment,
attachments: quickLook.mediaAttachments)
.presentationBackground(.ultraThinMaterial)
.presentationCornerRadius(16)
.withEnvironments()
}
.onChange(of: pushNotificationsService.handledNotification) { _, newValue in
if newValue != nil {
pushNotificationsService.handledNotification = nil
if appAccountsManager.currentAccount.oauthToken?.accessToken != newValue?.account.token.accessToken,
let account = appAccountsManager.availableAccounts.first(where:
{ $0.oauthToken?.accessToken == newValue?.account.token.accessToken })
{
appAccountsManager.currentAccount = account
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
selectedTab = .notifications
pushNotificationsService.handledNotification = newValue
}
} else {
selectedTab = .notifications
}
}
}
.withModelContainer()
}
.commands {
appMenu
}
.onChange(of: scenePhase) { _, newValue in
handleScenePhase(scenePhase: newValue)
}
.onChange(of: appAccountsManager.currentClient) { _, newValue in
setNewClientsInEnv(client: newValue)
if newValue.isAuth {
watcher.watch(streams: [.user, .direct])
}
}
}
@ViewBuilder
private var appView: some View {
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
sidebarView
} else {
tabBarView
}
}
var otherScenes: some Scene {
WindowGroup(for: WindowDestination.self) { destination in
Group {
switch destination.wrappedValue {
case let .newStatusEditor(visibility):
StatusEditorView(mode: .new(visibility: visibility))
case let .editStatusEditor(status):
StatusEditorView(mode: .edit(status: status))
case let .quoteStatusEditor(status):
StatusEditorView(mode: .quote(status: status))
case let .replyToStatusEditor(status):
StatusEditorView(mode: .replyTo(status: status))
case let .mediaViewer(attachments, selectedAttachment):
MediaUIView(selectedAttachment: selectedAttachment,
attachments: attachments)
case .none:
EmptyView()
}
}
.withEnvironments()
.withModelContainer()
.applyTheme(theme)
}
.defaultSize(width: 600, height: 800)
.windowResizability(.automatic)
}
}

View file

@ -0,0 +1,50 @@
import SwiftUI
import Env
extension IceCubesApp {
var sidebarView: some View {
SideBarView(selectedTab: $selectedTab,
popToRootTab: $popToRootTab,
tabs: availableTabs)
{
HStack(spacing: 0) {
ZStack {
if selectedTab == .profile {
ProfileTab(popToRootTab: $popToRootTab)
}
ForEach(availableTabs) { tab in
if tab == selectedTab || sideBarLoadedTabs.contains(tab) {
tab
.makeContentView(popToRootTab: $popToRootTab)
.opacity(tab == selectedTab ? 1 : 0)
.transition(.opacity)
.id("\(tab)\(appAccountsManager.currentAccount.id)")
.onAppear {
sideBarLoadedTabs.insert(tab)
}
} else {
EmptyView()
}
}
}
if appAccountsManager.currentClient.isAuth,
userPreferences.showiPadSecondaryColumn
{
Divider().edgesIgnoringSafeArea(.all)
notificationsSecondaryColumn
}
}
}.onChange(of: $appAccountsManager.currentAccount.id) {
sideBarLoadedTabs.removeAll()
}
.environment(sidebarRouterPath)
}
var notificationsSecondaryColumn: some View {
NotificationsTab(popToRootTab: $popToRootTab, lockedType: nil)
.environment(\.isSecondaryColumn, true)
.frame(maxWidth: .secondaryColumnWidth)
.id(appAccountsManager.currentAccount.id)
}
}

View file

@ -0,0 +1,58 @@
import SwiftUI
import Env
extension IceCubesApp {
var tabBarView: some View {
TabView(selection: .init(get: {
selectedTab
}, set: { newTab in
if newTab == selectedTab {
/// Stupid hack to trigger onChange binding in tab views.
popToRootTab = .other
DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {
popToRootTab = selectedTab
}
}
HapticManager.shared.fireHaptic(of: .tabSelection)
SoundEffectManager.shared.playSound(of: .tabSelection)
selectedTab = newTab
DispatchQueue.main.async {
if selectedTab == .notifications,
let token = appAccountsManager.currentAccount.oauthToken
{
userPreferences.notificationsCount[token] = 0
watcher.unreadNotificationsCount = 0
}
}
})) {
ForEach(availableTabs) { tab in
tab.makeContentView(popToRootTab: $popToRootTab)
.tabItem {
if userPreferences.showiPhoneTabLabel {
tab.label
} else {
Image(systemName: tab.iconName)
}
}
.tag(tab)
.badge(badgeFor(tab: tab))
.toolbarBackground(theme.primaryBackgroundColor.opacity(0.50), for: .tabBar)
}
}
.id(appAccountsManager.currentClient.id)
}
private func badgeFor(tab: Tab) -> Int {
if tab == .notifications, selectedTab != tab,
let token = appAccountsManager.currentAccount.oauthToken
{
return watcher.unreadNotificationsCount + (userPreferences.notificationsCount[token] ?? 0)
}
return 0
}
}

View file

@ -0,0 +1,120 @@
import Account
import AppAccount
import AVFoundation
import DesignSystem
import Env
import KeychainSwift
import Network
import RevenueCat
import Status
import SwiftUI
import Timeline
import MediaUI
@main
struct IceCubesApp: App {
@UIApplicationDelegateAdaptor private var appDelegate: AppDelegate
@Environment(\.scenePhase) var scenePhase
@Environment(\.openWindow) var openWindow
@State var appAccountsManager = AppAccountsManager.shared
@State var currentInstance = CurrentInstance.shared
@State var currentAccount = CurrentAccount.shared
@State var userPreferences = UserPreferences.shared
@State var pushNotificationsService = PushNotificationsService.shared
@State var watcher = StreamWatcher()
@State var quickLook = QuickLook.shared
@State var theme = Theme.shared
@State var sidebarRouterPath = RouterPath()
@State var selectedTab: Tab = .timeline
@State var popToRootTab: Tab = .other
@State var sideBarLoadedTabs: Set<Tab> = Set()
@State var isSupporter: Bool = false
var availableTabs: [Tab] {
appAccountsManager.currentClient.isAuth ? Tab.loggedInTabs() : Tab.loggedOutTab()
}
var body: some Scene {
appScene
otherScenes
}
func setNewClientsInEnv(client: Client) {
currentAccount.setClient(client: client)
currentInstance.setClient(client: client)
userPreferences.setClient(client: client)
Task {
await currentInstance.fetchCurrentInstance()
watcher.setClient(client: client, instanceStreamingURL: currentInstance.instance?.urls?.streamingApi)
watcher.watch(streams: [.user, .direct])
}
}
func handleScenePhase(scenePhase: ScenePhase) {
switch scenePhase {
case .background:
watcher.stopWatching()
case .active:
watcher.watch(streams: [.user, .direct])
UNUserNotificationCenter.current().setBadgeCount(0)
userPreferences.reloadNotificationsCount(tokens: appAccountsManager.availableAccounts.compactMap(\.oauthToken))
Task {
await userPreferences.refreshServerPreferences()
}
default:
break
}
}
func setupRevenueCat() {
Purchases.logLevel = .error
Purchases.configure(withAPIKey: "appl_JXmiRckOzXXTsHKitQiicXCvMQi")
Purchases.shared.getCustomerInfo { info, _ in
if info?.entitlements["Supporter"]?.isActive == true {
isSupporter = true
}
}
}
func refreshPushSubs() {
PushNotificationsService.shared.requestPushNotifications()
}
}
class AppDelegate: NSObject, UIApplicationDelegate {
func application(_: UIApplication,
didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool
{
try? AVAudioSession.sharedInstance().setCategory(.ambient)
PushNotificationsService.shared.setAccounts(accounts: AppAccountsManager.shared.pushAccounts)
return true
}
func application(_: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data)
{
PushNotificationsService.shared.pushToken = deviceToken
Task {
PushNotificationsService.shared.setAccounts(accounts: AppAccountsManager.shared.pushAccounts)
await PushNotificationsService.shared.updateSubscriptions(forceCreate: false)
}
}
func application(_: UIApplication, didFailToRegisterForRemoteNotificationsWithError _: Error) {}
func application(_: UIApplication, didReceiveRemoteNotification _: [AnyHashable: Any]) async -> UIBackgroundFetchResult {
UserPreferences.shared.reloadNotificationsCount(tokens: AppAccountsManager.shared.availableAccounts.compactMap(\.oauthToken))
return .noData
}
func application(_: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options _: UIScene.ConnectionOptions) -> UISceneConfiguration {
let configuration = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role)
if connectingSceneSession.role == .windowApplication {
configuration.delegateClass = SceneDelegate.self
}
return configuration
}
}

View file

@ -98,6 +98,21 @@ struct TimelineTab: View {
selectedTagGroup = nil
}
}
.onReceive(NotificationCenter.default.publisher(for: .refreshTimeline)) { _ in
timeline = .latest
}
.onReceive(NotificationCenter.default.publisher(for: .trendingTimeline)) { _ in
timeline = .trending
}
.onReceive(NotificationCenter.default.publisher(for: .localTimeline)) { _ in
timeline = .local
}
.onReceive(NotificationCenter.default.publisher(for: .federatedTimeline)) { _ in
timeline = .federated
}
.onReceive(NotificationCenter.default.publisher(for: .homeTimeline)) { _ in
timeline = .home
}
.withSafariRouter()
.environment(routerPath)
}
@ -110,7 +125,6 @@ struct TimelineTab: View {
} label: {
Label(TimelineFilter.latest.localizedTitle(), systemImage: TimelineFilter.latest.iconName() ?? "")
}
.keyboardShortcut("r", modifiers: .command)
Divider()
}
ForEach(TimelineFilter.availableTimeline(client: client), id: \.self) { timeline in

View file

@ -30068,6 +30068,28 @@
}
}
},
"menu.timeline" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Timeline"
}
}
}
},
"menu.timeline.refresh" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Jump to Latest"
}
}
}
},
"notifications-others-count %lld" : {
"extractionState" : "manual",
"localizations" : {

View file

@ -52,7 +52,7 @@ class ShareViewController: UIViewController {
}
}
NotificationCenter.default.addObserver(forName: NotificationsName.shareSheetClose,
NotificationCenter.default.addObserver(forName: .shareSheetClose,
object: nil,
queue: nil)
{ [weak self] _ in

View file

@ -26,6 +26,7 @@ public struct AccountsListRow: View {
@Environment(CurrentAccount.self) private var currentAccount
@Environment(RouterPath.self) private var routerPath
@Environment(Client.self) private var client
@Environment(QuickLook.self) private var quickLook
@State var viewModel: AccountsListRowViewModel
@ -122,7 +123,7 @@ public struct AccountsListRow: View {
.environment(theme)
.environment(currentAccount)
.environment(client)
.environment(QuickLook())
.environment(quickLook)
.environment(routerPath)
}
}

View file

@ -1,5 +1,10 @@
import UIKit
public enum NotificationsName {
public static let shareSheetClose = NSNotification.Name("shareSheetClose")
public extension Notification.Name {
static let shareSheetClose = NSNotification.Name("shareSheetClose")
static let refreshTimeline = Notification.Name("refreshTimeline")
static let homeTimeline = Notification.Name("homeTimeline")
static let trendingTimeline = Notification.Name("trendingTimeline")
static let federatedTimeline = Notification.Name("federatedTimeline")
static let localTimeline = Notification.Name("localTimeline")
}

View file

@ -7,7 +7,9 @@ import QuickLook
public var selectedMediaAttachment: MediaAttachment?
public var mediaAttachments: [MediaAttachment] = []
public init() {}
public static let shared = QuickLook()
private init() {}
public func prepareFor(selectedMediaAttachment: MediaAttachment, mediaAttachments: [MediaAttachment]) {
self.selectedMediaAttachment = selectedMediaAttachment

View file

@ -28,15 +28,9 @@ public enum RouterDestination: Hashable {
public enum WindowDestination: Hashable, Codable {
case newStatusEditor(visibility: Models.Visibility)
case mediaViewer(attachments: [MediaAttachment], selectedAttachment: MediaAttachment)
var initialSize: CGSize {
switch self {
case .newStatusEditor:
return .init(width: 500, height: 700)
case .mediaViewer:
return .init(width: 800, height: 600)
}
}
case editStatusEditor(status: Status)
case replyToStatusEditor(status: Status)
case quoteStatusEditor(status: Status)
}
public enum SheetDestination: Identifiable {

View file

@ -89,7 +89,7 @@ public struct StatusEditorView: View {
viewModel.prepareStatusText()
if !client.isAuth {
dismiss()
NotificationCenter.default.post(name: NotificationsName.shareSheetClose,
NotificationCenter.default.post(name: .shareSheetClose,
object: nil)
}
@ -141,7 +141,7 @@ public struct StatusEditorView: View {
isDismissAlertPresented = true
} else {
dismiss()
NotificationCenter.default.post(name: NotificationsName.shareSheetClose,
NotificationCenter.default.post(name: .shareSheetClose,
object: nil)
}
} label: {
@ -154,13 +154,13 @@ public struct StatusEditorView: View {
actions: {
Button("status.draft.delete", role: .destructive) {
dismiss()
NotificationCenter.default.post(name: NotificationsName.shareSheetClose,
NotificationCenter.default.post(name: .shareSheetClose,
object: nil)
}
Button("status.draft.save") {
context.insert(Draft(content: viewModel.statusText.string))
dismiss()
NotificationCenter.default.post(name: NotificationsName.shareSheetClose,
NotificationCenter.default.post(name: .shareSheetClose,
object: nil)
}
Button("action.cancel", role: .cancel) {}
@ -213,7 +213,7 @@ public struct StatusEditorView: View {
if status != nil {
dismiss()
SoundEffectManager.shared.playSound(of: .tootSent)
NotificationCenter.default.post(name: NotificationsName.shareSheetClose,
NotificationCenter.default.post(name: .shareSheetClose,
object: nil)
if !viewModel.mode.isInShareExtension, !preferences.requestedReview, !ProcessInfo.processInfo.isMacCatalystApp {
if let scene = UIApplication.shared.connectedScenes.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene {

View file

@ -11,6 +11,7 @@ struct StatusRowActionsView: View {
@Environment(StatusDataController.self) private var statusDataController
@Environment(UserPreferences.self) private var userPreferences
@Environment(\.openWindow) private var openWindow
@Environment(\.isStatusFocused) private var isFocused
var viewModel: StatusRowViewModel
@ -204,7 +205,11 @@ struct StatusRowActionsView: View {
switch action {
case .respond:
SoundEffectManager.shared.playSound(of: .share)
viewModel.routerPath.presentedSheet = .replyToStatusEditor(status: viewModel.localStatus ?? viewModel.status)
if ProcessInfo.processInfo.isMacCatalystApp {
openWindow(value: WindowDestination.replyToStatusEditor(status: viewModel.localStatus ?? viewModel.status))
} else {
viewModel.routerPath.presentedSheet = .replyToStatusEditor(status: viewModel.localStatus ?? viewModel.status)
}
case .favorite:
SoundEffectManager.shared.playSound(of: .favorite)
await statusDataController.toggleFavorite(remoteStatus: viewModel.localStatusId)

View file

@ -7,6 +7,7 @@ import SwiftUI
@MainActor
struct StatusRowContextMenu: View {
@Environment(\.displayScale) var displayScale
@Environment(\.openWindow) var openWindow
@Environment(Client.self) private var client
@Environment(SceneDelegate.self) private var sceneDelegate
@ -14,6 +15,7 @@ struct StatusRowContextMenu: View {
@Environment(CurrentAccount.self) private var account
@Environment(CurrentInstance.self) private var currentInstance
@Environment(StatusDataController.self) private var statusDataController
@Environment(QuickLook.self) private var quickLook
var viewModel: StatusRowViewModel
@ -51,12 +53,20 @@ struct StatusRowContextMenu: View {
systemImage: "bookmark")
}
Button {
viewModel.routerPath.presentedSheet = .replyToStatusEditor(status: viewModel.status)
if ProcessInfo.processInfo.isMacCatalystApp {
openWindow(value: WindowDestination.replyToStatusEditor(status: viewModel.status))
} else {
viewModel.routerPath.presentedSheet = .replyToStatusEditor(status: viewModel.status)
}
} label: {
Label("status.action.reply", systemImage: "arrowshape.turn.up.left")
}
Button {
viewModel.routerPath.presentedSheet = .quoteStatusEditor(status: viewModel.status)
if ProcessInfo.processInfo.isMacCatalystApp {
openWindow(value: WindowDestination.quoteStatusEditor(status: viewModel.status))
} else {
viewModel.routerPath.presentedSheet = .quoteStatusEditor(status: viewModel.status)
}
} label: {
Label("status.action.quote", systemImage: "quote.bubble")
}
@ -91,7 +101,7 @@ struct StatusRowContextMenu: View {
.environment(account)
.environment(currentInstance)
.environment(SceneDelegate())
.environment(QuickLook())
.environment(quickLook)
.environment(viewModel.client)
.preferredColorScheme(Theme.shared.selectedScheme == .dark ? .dark : .light)
.foregroundColor(Theme.shared.labelColor)