From 247bb31c5624dbfe2d697e09dd0d477beef9670e Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sun, 5 Mar 2023 14:35:25 -0500 Subject: [PATCH 01/11] Move local user accounts to separate package --- .../contents.xcworkspacedata | 7 ++ Packages/UserAccounts/.gitignore | 9 ++ Packages/UserAccounts/Package.resolved | 23 ++++ Packages/UserAccounts/Package.swift | 31 ++++++ Packages/UserAccounts/README.md | 3 + .../UserAccounts/UserAccountInfo.swift | 80 +++++++++++++ .../UserAccounts/UserAccountsManager.swift | 105 +++--------------- Tusker.xcodeproj/project.pbxproj | 13 ++- Tusker/API/LogoutService.swift | 7 +- Tusker/API/MastodonController.swift | 9 +- Tusker/AppDelegate.swift | 3 +- Tusker/CoreData/AccountPreferences.swift | 5 +- .../MastodonCachePersistentStore.swift | 5 +- Tusker/CoreData/SavedHashtag.swift | 7 +- Tusker/CoreData/SavedInstance.swift | 7 +- Tusker/CoreData/TimelinePosition.swift | 5 +- Tusker/Scenes/AuxiliarySceneDelegate.swift | 5 +- Tusker/Scenes/ComposeSceneDelegate.swift | 9 +- Tusker/Scenes/MainSceneDelegate.swift | 23 ++-- .../FastAccountSwitcherViewController.swift | 9 +- .../FastSwitchingAccountView.swift | 5 +- ...ountSwitchingContainerViewController.swift | 5 +- ...inSidebarMyProfileCollectionViewCell.swift | 3 +- .../Onboarding/OnboardingViewController.swift | 7 +- .../Preferences/AdvancedPrefsView.swift | 9 +- .../Preferences/LocalAccountAvatarView.swift | 3 +- .../PreferencesNavigationController.swift | 9 +- .../Screens/Preferences/PreferencesView.swift | 9 +- Tusker/Shortcuts/UserActivityManager.swift | 5 +- 29 files changed, 262 insertions(+), 158 deletions(-) create mode 100644 Packages/ComposeUI/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata create mode 100644 Packages/UserAccounts/.gitignore create mode 100644 Packages/UserAccounts/Package.resolved create mode 100644 Packages/UserAccounts/Package.swift create mode 100644 Packages/UserAccounts/README.md create mode 100644 Packages/UserAccounts/Sources/UserAccounts/UserAccountInfo.swift rename Tusker/LocalData.swift => Packages/UserAccounts/Sources/UserAccounts/UserAccountsManager.swift (55%) diff --git a/Packages/ComposeUI/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/Packages/ComposeUI/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/Packages/ComposeUI/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Packages/UserAccounts/.gitignore b/Packages/UserAccounts/.gitignore new file mode 100644 index 00000000..3b298120 --- /dev/null +++ b/Packages/UserAccounts/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/Packages/UserAccounts/Package.resolved b/Packages/UserAccounts/Package.resolved new file mode 100644 index 00000000..a9ae5ab1 --- /dev/null +++ b/Packages/UserAccounts/Package.resolved @@ -0,0 +1,23 @@ +{ + "pins" : [ + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "025bcb1165deab2e20d4eaba79967ce73013f496", + "version" : "1.2.1" + } + }, + { + "identity" : "swift-url", + "kind" : "remoteSourceControl", + "location" : "https://github.com/karwa/swift-url.git", + "state" : { + "branch" : "main", + "revision" : "220f6ab9d8a7e0742f85eb9f21b745942e001ae6" + } + } + ], + "version" : 2 +} diff --git a/Packages/UserAccounts/Package.swift b/Packages/UserAccounts/Package.swift new file mode 100644 index 00000000..cc7656a8 --- /dev/null +++ b/Packages/UserAccounts/Package.swift @@ -0,0 +1,31 @@ +// swift-tools-version: 5.7 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "UserAccounts", + platforms: [ + .iOS(.v15), + ], + products: [ + // Products define the executables and libraries a package produces, and make them visible to other packages. + .library( + name: "UserAccounts", + targets: ["UserAccounts"]), + ], + dependencies: [ + // Dependencies declare other packages that this package depends on. + .package(path: "../Pachyderm"), + ], + targets: [ + // Targets are the basic building blocks of a package. A target can define a module or a test suite. + // Targets can depend on other targets in this package, and on products in packages this package depends on. + .target( + name: "UserAccounts", + dependencies: ["Pachyderm"]), + .testTarget( + name: "UserAccountsTests", + dependencies: ["UserAccounts"]), + ] +) diff --git a/Packages/UserAccounts/README.md b/Packages/UserAccounts/README.md new file mode 100644 index 00000000..c2ecdca8 --- /dev/null +++ b/Packages/UserAccounts/README.md @@ -0,0 +1,3 @@ +# UserAccounts + +A description of this package. diff --git a/Packages/UserAccounts/Sources/UserAccounts/UserAccountInfo.swift b/Packages/UserAccounts/Sources/UserAccounts/UserAccountInfo.swift new file mode 100644 index 00000000..2fac2f8a --- /dev/null +++ b/Packages/UserAccounts/Sources/UserAccounts/UserAccountInfo.swift @@ -0,0 +1,80 @@ +// +// UserAccountInfo.swift +// UserAccounts +// +// Created by Shadowfacts on 3/5/23. +// + +import Foundation +import CryptoKit + +public struct UserAccountInfo: Equatable, Hashable { + public let id: String + public let instanceURL: URL + public let clientID: String + public let clientSecret: String + public private(set) var username: String! + public let accessToken: String + + fileprivate static let tempAccountID = "temp" + + static func id(instanceURL: URL, username: String?) -> String { + // We hash the instance host and username to form the account ID + // so that account IDs will match across devices, allowing for data syncing and handoff. + var hasher = SHA256() + hasher.update(data: instanceURL.host!.data(using: .utf8)!) + if let username { + hasher.update(data: username.data(using: .utf8)!) + } + return Data(hasher.finalize()).base64EncodedString() + } + + /// Only to be used for temporary MastodonController needed to fetch own account info and create final UserAccountInfo with real username + public init(tempInstanceURL instanceURL: URL, clientID: String, clientSecret: String, accessToken: String) { + self.id = UserAccountInfo.tempAccountID + self.instanceURL = instanceURL + self.clientID = clientID + self.clientSecret = clientSecret + self.accessToken = accessToken + } + + init(instanceURL: URL, clientID: String, clientSecret: String, username: String? = nil, accessToken: String) { + self.id = UserAccountInfo.id(instanceURL: instanceURL, username: username) + self.instanceURL = instanceURL + self.clientID = clientID + self.clientSecret = clientSecret + self.username = username + self.accessToken = accessToken + } + + init?(userDefaultsDict dict: [String: String]) { + guard let id = dict["id"], + let instanceURL = dict["instanceURL"], + let url = URL(string: instanceURL), + let clientID = dict["clientID"], + let secret = dict["clientSecret"], + let accessToken = dict["accessToken"] else { + return nil + } + self.id = id + self.instanceURL = url + self.clientID = clientID + self.clientSecret = secret + self.username = dict["username"] + self.accessToken = accessToken + } + + /// A filename-safe string for this account + public var persistenceKey: String { + // slashes are not allowed in the persistent store coordinator name + id.replacingOccurrences(of: "/", with: "_") + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + public static func ==(lhs: UserAccountInfo, rhs: UserAccountInfo) -> Bool { + return lhs.id == rhs.id + } +} diff --git a/Tusker/LocalData.swift b/Packages/UserAccounts/Sources/UserAccounts/UserAccountsManager.swift similarity index 55% rename from Tusker/LocalData.swift rename to Packages/UserAccounts/Sources/UserAccounts/UserAccountsManager.swift index 4da80d77..e69973ae 100644 --- a/Tusker/LocalData.swift +++ b/Packages/UserAccounts/Sources/UserAccounts/UserAccountsManager.swift @@ -1,18 +1,16 @@ // -// LocalData.swift -// Tusker +// UserAccountsManager.swift +// UserAccounts // -// Created by Shadowfacts on 8/18/18. -// Copyright © 2018 Shadowfacts. All rights reserved. +// Created by Shadowfacts on 3/5/23. // import Foundation import Combine -import CryptoKit -class LocalData: ObservableObject { +public class UserAccountsManager: ObservableObject { - static let shared = LocalData() + public static let shared = UserAccountsManager() let defaults: UserDefaults @@ -38,7 +36,7 @@ class LocalData: ObservableObject { } private let accountsKey = "accounts" - private(set) var accounts: [UserAccountInfo] { + public private(set) var accounts: [UserAccountInfo] { get { if let array = defaults.array(forKey: accountsKey) as? [[String: String]] { return array.compactMap(UserAccountInfo.init(userDefaultsDict:)) @@ -66,7 +64,7 @@ class LocalData: ObservableObject { } private let mostRecentAccountKey = "mostRecentAccount" - private(set) var mostRecentAccountID: String? { + public private(set) var mostRecentAccountID: String? { get { return defaults.string(forKey: mostRecentAccountKey) } @@ -109,13 +107,13 @@ class LocalData: ObservableObject { usesAccountIDHashes = true } - // MARK: - Account Management + // MARK: Account Management - var onboardingComplete: Bool { + public var onboardingComplete: Bool { return !accounts.isEmpty } - func addAccount(instanceURL url: URL, clientID: String, clientSecret secret: String, username: String?, accessToken: String) -> UserAccountInfo { + public func addAccount(instanceURL url: URL, clientID: String, clientSecret secret: String, username: String?, accessToken: String) -> UserAccountInfo { var accounts = self.accounts if let index = accounts.firstIndex(where: { $0.instanceURL == url && $0.username == username }) { accounts.remove(at: index) @@ -126,15 +124,15 @@ class LocalData: ObservableObject { return info } - func removeAccount(_ info: UserAccountInfo) { + public func removeAccount(_ info: UserAccountInfo) { accounts.removeAll(where: { $0.id == info.id }) } - func getAccount(id: String) -> UserAccountInfo? { + public func getAccount(id: String) -> UserAccountInfo? { return accounts.first(where: { $0.id == id }) } - func getMostRecentAccount() -> UserAccountInfo? { + public func getMostRecentAccount() -> UserAccountInfo? { guard onboardingComplete else { return nil } let mostRecent: UserAccountInfo? if let id = mostRecentAccountID { @@ -145,86 +143,13 @@ class LocalData: ObservableObject { return mostRecent ?? accounts.first! } - func setMostRecentAccount(_ account: UserAccountInfo?) { + public func setMostRecentAccount(_ account: UserAccountInfo?) { mostRecentAccountID = account?.id } } -extension LocalData { - struct UserAccountInfo: Equatable, Hashable { - let id: String - let instanceURL: URL - let clientID: String - let clientSecret: String - private(set) var username: String! - let accessToken: String - - fileprivate static let tempAccountID = "temp" - - fileprivate static func id(instanceURL: URL, username: String?) -> String { - // We hash the instance host and username to form the account ID - // so that account IDs will match across devices, allowing for data syncing and handoff. - var hasher = SHA256() - hasher.update(data: instanceURL.host!.data(using: .utf8)!) - if let username { - hasher.update(data: username.data(using: .utf8)!) - } - return Data(hasher.finalize()).base64EncodedString() - } - - /// Only to be used for temporary MastodonController needed to fetch own account info and create final UserAccountInfo with real username - init(tempInstanceURL instanceURL: URL, clientID: String, clientSecret: String, accessToken: String) { - self.id = UserAccountInfo.tempAccountID - self.instanceURL = instanceURL - self.clientID = clientID - self.clientSecret = clientSecret - self.accessToken = accessToken - } - - fileprivate init(instanceURL: URL, clientID: String, clientSecret: String, username: String? = nil, accessToken: String) { - self.id = UserAccountInfo.id(instanceURL: instanceURL, username: username) - self.instanceURL = instanceURL - self.clientID = clientID - self.clientSecret = clientSecret - self.username = username - self.accessToken = accessToken - } - - fileprivate init?(userDefaultsDict dict: [String: String]) { - guard let id = dict["id"], - let instanceURL = dict["instanceURL"], - let url = URL(string: instanceURL), - let clientID = dict["clientID"], - let secret = dict["clientSecret"], - let accessToken = dict["accessToken"] else { - return nil - } - self.id = id - self.instanceURL = url - self.clientID = clientID - self.clientSecret = secret - self.username = dict["username"] - self.accessToken = accessToken - } - - /// A filename-safe string for this account - var persistenceKey: String { - // slashes are not allowed in the persistent store coordinator name - id.replacingOccurrences(of: "/", with: "_") - } - - func hash(into hasher: inout Hasher) { - hasher.combine(id) - } - - static func ==(lhs: UserAccountInfo, rhs: UserAccountInfo) -> Bool { - return lhs.id == rhs.id - } - } -} - -extension Notification.Name { +public extension Notification.Name { static let userLoggedOut = Notification.Name("Tusker.userLoggedOut") static let addAccount = Notification.Name("Tusker.addAccount") static let activateAccount = Notification.Name("Tusker.activateAccount") diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index d7f04697..cd4275d7 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -142,7 +142,6 @@ D64BC18623C1253A000D0238 /* AssetPreviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64BC18523C1253A000D0238 /* AssetPreviewViewController.swift */; }; D64BC18F23C18B9D000D0238 /* FollowRequestNotificationTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64BC18D23C18B9D000D0238 /* FollowRequestNotificationTableViewCell.swift */; }; D64BC19023C18B9D000D0238 /* FollowRequestNotificationTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D64BC18E23C18B9D000D0238 /* FollowRequestNotificationTableViewCell.xib */; }; - D64D0AAD2128D88B005A6F37 /* LocalData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AAC2128D88B005A6F37 /* LocalData.swift */; }; D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */; }; D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */; }; D651C5B42915B00400236EF6 /* ProfileFieldsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D651C5B32915B00400236EF6 /* ProfileFieldsView.swift */; }; @@ -268,6 +267,7 @@ D6ADB6F028ED1F25009924AB /* CachedImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6ADB6EF28ED1F25009924AB /* CachedImageView.swift */; }; D6AEBB3E2321638100E5038B /* UIActivity+Types.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6AEBB3D2321638100E5038B /* UIActivity+Types.swift */; }; D6AEBB432321685E00E5038B /* OpenInSafariActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6AEBB422321685E00E5038B /* OpenInSafariActivity.swift */; }; + D6B0026E29B5248800C70BE2 /* UserAccounts in Frameworks */ = {isa = PBXBuildFile; productRef = D6B0026D29B5248800C70BE2 /* UserAccounts */; }; D6B053A223BD2C0600A066FA /* AssetPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B053A123BD2C0600A066FA /* AssetPickerViewController.swift */; }; D6B053A423BD2C8100A066FA /* AssetCollectionsListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B053A323BD2C8100A066FA /* AssetCollectionsListViewController.swift */; }; D6B053A623BD2D0C00A066FA /* AssetCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B053A523BD2D0C00A066FA /* AssetCollectionViewController.swift */; }; @@ -559,7 +559,6 @@ D64BC18523C1253A000D0238 /* AssetPreviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetPreviewViewController.swift; sourceTree = ""; }; D64BC18D23C18B9D000D0238 /* FollowRequestNotificationTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowRequestNotificationTableViewCell.swift; sourceTree = ""; }; D64BC18E23C18B9D000D0238 /* FollowRequestNotificationTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = FollowRequestNotificationTableViewCell.xib; sourceTree = ""; }; - D64D0AAC2128D88B005A6F37 /* LocalData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalData.swift; sourceTree = ""; }; D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewController.swift; sourceTree = ""; }; D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiThreadDictionary.swift; sourceTree = ""; }; D651C5B32915B00400236EF6 /* ProfileFieldsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldsView.swift; sourceTree = ""; }; @@ -688,6 +687,7 @@ D6ADB6EF28ED1F25009924AB /* CachedImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CachedImageView.swift; sourceTree = ""; }; D6AEBB3D2321638100E5038B /* UIActivity+Types.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIActivity+Types.swift"; sourceTree = ""; }; D6AEBB422321685E00E5038B /* OpenInSafariActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenInSafariActivity.swift; sourceTree = ""; }; + D6B0026C29B5245400C70BE2 /* UserAccounts */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = UserAccounts; path = Packages/UserAccounts; sourceTree = ""; }; D6B053A123BD2C0600A066FA /* AssetPickerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetPickerViewController.swift; sourceTree = ""; }; D6B053A323BD2C8100A066FA /* AssetCollectionsListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetCollectionsListViewController.swift; sourceTree = ""; }; D6B053A523BD2D0C00A066FA /* AssetCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetCollectionViewController.swift; sourceTree = ""; }; @@ -813,6 +813,7 @@ files = ( D674A50927F9128D00BA03AC /* Pachyderm in Frameworks */, D659F35E2953A212002D944A /* TTTKit in Frameworks */, + D6B0026E29B5248800C70BE2 /* UserAccounts in Frameworks */, D60CFFDB24A290BA00D00083 /* SwiftSoup in Frameworks */, D60088EF2980D8B5005B4D00 /* StoreKit.framework in Frameworks */, D6552367289870790048A653 /* ScreenCorners in Frameworks */, @@ -1537,6 +1538,7 @@ D674A50727F910F300BA03AC /* Pachyderm */, D6BEA243291A0C83002F4D01 /* Duckable */, D68A76F22953915C001DA1B3 /* TTTKit */, + D6B0026C29B5245400C70BE2 /* UserAccounts */, D6D4DDCE212518A000E1C4BB /* Tusker */, D6D4DDE3212518A200E1C4BB /* TuskerTests */, D6D4DDEE212518A200E1C4BB /* TuskerUITests */, @@ -1573,7 +1575,6 @@ D6B30E08254BAF63009CAEE5 /* ImageGrayscalifier.swift */, D61F75BC293D099600C0B37F /* Lazy.swift */, D60E2F2B24423EAD005F8713 /* LazilyDecoding.swift */, - D64D0AAC2128D88B005A6F37 /* LocalData.swift */, D61DC84528F498F200B82C6E /* Logging.swift */, D6B81F432560390300F6E31D /* MenuController.swift */, D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */, @@ -1741,6 +1742,7 @@ D63CC701290EC0B8000E19DE /* Sentry */, D6BEA244291A0EDE002F4D01 /* Duckable */, D659F35D2953A212002D944A /* TTTKit */, + D6B0026D29B5248800C70BE2 /* UserAccounts */, ); productName = Tusker; productReference = D6D4DDCC212518A000E1C4BB /* Tusker.app */; @@ -2147,7 +2149,6 @@ D6DD2A45273D6C5700386A6C /* GIFImageView.swift in Sources */, D61F759029353B4300C0B37F /* FileManager+Size.swift in Sources */, 0427033822B30F5F000D31B6 /* BehaviorPrefsView.swift in Sources */, - D64D0AAD2128D88B005A6F37 /* LocalData.swift in Sources */, D6945C2F23AC47C3005C403C /* SavedDataManager.swift in Sources */, D6C94D892139E6EC00CB5196 /* AttachmentView.swift in Sources */, D6C693EF216192C2007D6A6D /* TuskerNavigationDelegate.swift in Sources */, @@ -2966,6 +2967,10 @@ isa = XCSwiftPackageProductDependency; productName = Pachyderm; }; + D6B0026D29B5248800C70BE2 /* UserAccounts */ = { + isa = XCSwiftPackageProductDependency; + productName = UserAccounts; + }; D6BEA244291A0EDE002F4D01 /* Duckable */ = { isa = XCSwiftPackageProductDependency; productName = Duckable; diff --git a/Tusker/API/LogoutService.swift b/Tusker/API/LogoutService.swift index 42b76255..e5837ee8 100644 --- a/Tusker/API/LogoutService.swift +++ b/Tusker/API/LogoutService.swift @@ -7,13 +7,14 @@ // import Foundation +import UserAccounts @MainActor class LogoutService { - let accountInfo: LocalData.UserAccountInfo + let accountInfo: UserAccountInfo private let mastodonController: MastodonController - init(accountInfo: LocalData.UserAccountInfo) { + init(accountInfo: UserAccountInfo) { self.accountInfo = accountInfo self.mastodonController = MastodonController.getForAccount(accountInfo) } @@ -23,7 +24,7 @@ class LogoutService { try? await self.mastodonController.client.revokeAccessToken() } MastodonController.removeForAccount(accountInfo) - LocalData.shared.removeAccount(accountInfo) + UserAccountsManager.shared.removeAccount(accountInfo) let psc = mastodonController.persistentContainer.persistentStoreCoordinator for store in psc.persistentStores { guard let url = store.url else { diff --git a/Tusker/API/MastodonController.swift b/Tusker/API/MastodonController.swift index a2b8e60a..489118fd 100644 --- a/Tusker/API/MastodonController.swift +++ b/Tusker/API/MastodonController.swift @@ -9,15 +9,16 @@ import Foundation import Pachyderm import Combine +import UserAccounts class MastodonController: ObservableObject { - static private(set) var all = [LocalData.UserAccountInfo: MastodonController]() + static private(set) var all = [UserAccountInfo: MastodonController]() @available(*, message: "do something less dumb") static var first: MastodonController { all.first!.value } - static func getForAccount(_ account: LocalData.UserAccountInfo) -> MastodonController { + static func getForAccount(_ account: UserAccountInfo) -> MastodonController { if let controller = all[account] { return controller } else { @@ -31,7 +32,7 @@ class MastodonController: ObservableObject { } } - static func removeForAccount(_ account: LocalData.UserAccountInfo) { + static func removeForAccount(_ account: UserAccountInfo) { all.removeValue(forKey: account) } @@ -43,7 +44,7 @@ class MastodonController: ObservableObject { private(set) nonisolated lazy var persistentContainer = MastodonCachePersistentStore(for: accountInfo, transient: transient) let instanceURL: URL - var accountInfo: LocalData.UserAccountInfo? + var accountInfo: UserAccountInfo? var accountPreferences: AccountPreferences! let client: Client! diff --git a/Tusker/AppDelegate.swift b/Tusker/AppDelegate.swift index 5ffd44e7..d4141592 100644 --- a/Tusker/AppDelegate.swift +++ b/Tusker/AppDelegate.swift @@ -10,6 +10,7 @@ import UIKit import CoreData import OSLog import Sentry +import UserAccounts let stateRestorationLogger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "StateRestoration") @@ -32,7 +33,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { if let oldSavedData = SavedDataManager.load() { do { for account in oldSavedData.accountIDs { - guard let account = LocalData.shared.getAccount(id: account) else { + guard let account = UserAccountsManager.shared.getAccount(id: account) else { continue } let controller = MastodonController.getForAccount(account) diff --git a/Tusker/CoreData/AccountPreferences.swift b/Tusker/CoreData/AccountPreferences.swift index 53f990d1..c8c0b388 100644 --- a/Tusker/CoreData/AccountPreferences.swift +++ b/Tusker/CoreData/AccountPreferences.swift @@ -9,11 +9,12 @@ import Foundation import CoreData import Pachyderm +import UserAccounts @objc(AccountPreferences) public final class AccountPreferences: NSManagedObject { - @nonobjc class func fetchRequest(account: LocalData.UserAccountInfo) -> NSFetchRequest { + @nonobjc class func fetchRequest(account: UserAccountInfo) -> NSFetchRequest { let req = NSFetchRequest(entityName: "AccountPreferences") req.predicate = NSPredicate(format: "accountID = %@", account.id) req.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: true)] @@ -27,7 +28,7 @@ public final class AccountPreferences: NSManagedObject { @LazilyDecoding(from: \AccountPreferences.pinnedTimelinesData, fallback: AccountPreferences.defaultPinnedTimelines) var pinnedTimelines: [PinnedTimeline] - static func `default`(account: LocalData.UserAccountInfo, context: NSManagedObjectContext) -> AccountPreferences { + static func `default`(account: UserAccountInfo, context: NSManagedObjectContext) -> AccountPreferences { let prefs = AccountPreferences(context: context) prefs.accountID = account.id prefs.createdAt = Date() diff --git a/Tusker/CoreData/MastodonCachePersistentStore.swift b/Tusker/CoreData/MastodonCachePersistentStore.swift index 1c6b78d7..05e4560f 100644 --- a/Tusker/CoreData/MastodonCachePersistentStore.swift +++ b/Tusker/CoreData/MastodonCachePersistentStore.swift @@ -13,12 +13,13 @@ import Combine import OSLog import Sentry import CloudKit +import UserAccounts fileprivate let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "PersistentStore") class MastodonCachePersistentStore: NSPersistentCloudKitContainer { - private let accountInfo: LocalData.UserAccountInfo? + private let accountInfo: UserAccountInfo? private static let managedObjectModel: NSManagedObjectModel = { let url = Bundle.main.url(forResource: "Tusker", withExtension: "momd")! @@ -51,7 +52,7 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer { let accountSubject = PassthroughSubject() let relationshipSubject = PassthroughSubject() - init(for accountInfo: LocalData.UserAccountInfo?, transient: Bool = false) { + init(for accountInfo: UserAccountInfo?, transient: Bool = false) { self.accountInfo = accountInfo let group = DispatchGroup() diff --git a/Tusker/CoreData/SavedHashtag.swift b/Tusker/CoreData/SavedHashtag.swift index 265513b9..f206d456 100644 --- a/Tusker/CoreData/SavedHashtag.swift +++ b/Tusker/CoreData/SavedHashtag.swift @@ -10,6 +10,7 @@ import Foundation import CoreData import Pachyderm import WebURLFoundationExtras +import UserAccounts @objc(SavedHashtag) public final class SavedHashtag: NSManagedObject { @@ -18,13 +19,13 @@ public final class SavedHashtag: NSManagedObject { return NSFetchRequest(entityName: "SavedHashtag") } - @nonobjc class func fetchRequest(account: LocalData.UserAccountInfo) -> NSFetchRequest { + @nonobjc class func fetchRequest(account: UserAccountInfo) -> NSFetchRequest { let req = NSFetchRequest(entityName: "SavedHashtag") req.predicate = NSPredicate(format: "accountID = %@", account.id) return req } - @nonobjc class func fetchRequest(name: String, account: LocalData.UserAccountInfo) -> NSFetchRequest { + @nonobjc class func fetchRequest(name: String, account: UserAccountInfo) -> NSFetchRequest { let req = NSFetchRequest(entityName: "SavedHashtag") req.predicate = NSPredicate(format: "name LIKE[cd] %@ AND accountID = %@", name, account.id) return req @@ -37,7 +38,7 @@ public final class SavedHashtag: NSManagedObject { } extension SavedHashtag { - convenience init(hashtag: Hashtag, account: LocalData.UserAccountInfo, context: NSManagedObjectContext) { + convenience init(hashtag: Hashtag, account: UserAccountInfo, context: NSManagedObjectContext) { self.init(context: context) self.accountID = account.id self.name = hashtag.name diff --git a/Tusker/CoreData/SavedInstance.swift b/Tusker/CoreData/SavedInstance.swift index f2c612e8..d6e938b7 100644 --- a/Tusker/CoreData/SavedInstance.swift +++ b/Tusker/CoreData/SavedInstance.swift @@ -8,6 +8,7 @@ import Foundation import CoreData +import UserAccounts @objc(SavedInstance) public final class SavedInstance: NSManagedObject { @@ -16,13 +17,13 @@ public final class SavedInstance: NSManagedObject { return NSFetchRequest(entityName: "SavedInstance") } - @nonobjc class func fetchRequest(account: LocalData.UserAccountInfo) -> NSFetchRequest { + @nonobjc class func fetchRequest(account: UserAccountInfo) -> NSFetchRequest { let req = NSFetchRequest(entityName: "SavedInstance") req.predicate = NSPredicate(format: "accountID = %@", account.id) return req } - @nonobjc class func fetchRequest(url: URL, account: LocalData.UserAccountInfo) -> NSFetchRequest { + @nonobjc class func fetchRequest(url: URL, account: UserAccountInfo) -> NSFetchRequest { let req = NSFetchRequest(entityName: "SavedInstance") req.predicate = NSPredicate(format: "url = %@ AND accountID = %@", url as NSURL, account.id) return req @@ -34,7 +35,7 @@ public final class SavedInstance: NSManagedObject { } extension SavedInstance { - convenience init(url: URL, account: LocalData.UserAccountInfo, context: NSManagedObjectContext) { + convenience init(url: URL, account: UserAccountInfo, context: NSManagedObjectContext) { self.init(context: context) self.accountID = account.id self.url = url diff --git a/Tusker/CoreData/TimelinePosition.swift b/Tusker/CoreData/TimelinePosition.swift index 444bae4a..99d164a6 100644 --- a/Tusker/CoreData/TimelinePosition.swift +++ b/Tusker/CoreData/TimelinePosition.swift @@ -9,11 +9,12 @@ import Foundation import CoreData import Pachyderm +import UserAccounts @objc(TimelinePosition) public final class TimelinePosition: NSManagedObject { - @nonobjc class func fetchRequest(timeline: Timeline, account: LocalData.UserAccountInfo) -> NSFetchRequest { + @nonobjc class func fetchRequest(timeline: Timeline, account: UserAccountInfo) -> NSFetchRequest { let req = NSFetchRequest(entityName: "TimelinePosition") req.predicate = NSPredicate(format: "accountID = %@ AND timelineKind = %@", account.id, toTimelineKind(timeline)) req.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: true)] @@ -34,7 +35,7 @@ public final class TimelinePosition: NSManagedObject { set { timelineKind = toTimelineKind(newValue) } } - convenience init(timeline: Timeline, account: LocalData.UserAccountInfo, context: NSManagedObjectContext) { + convenience init(timeline: Timeline, account: UserAccountInfo, context: NSManagedObjectContext) { self.init(context: context) self.timeline = timeline self.accountID = account.id diff --git a/Tusker/Scenes/AuxiliarySceneDelegate.swift b/Tusker/Scenes/AuxiliarySceneDelegate.swift index 211f7c63..7480865f 100644 --- a/Tusker/Scenes/AuxiliarySceneDelegate.swift +++ b/Tusker/Scenes/AuxiliarySceneDelegate.swift @@ -8,6 +8,7 @@ import UIKit import Pachyderm +import UserAccounts class AuxiliarySceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate { @@ -31,11 +32,11 @@ class AuxiliarySceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDel } launchActivity = activity - let account: LocalData.UserAccountInfo + let account: UserAccountInfo if let activityAccount = UserActivityManager.getAccount(from: activity) { account = activityAccount - } else if let mostRecent = LocalData.shared.getMostRecentAccount() { + } else if let mostRecent = UserAccountsManager.shared.getMostRecentAccount() { account = mostRecent } else { // without an account, we can't do anything so we just destroy the scene diff --git a/Tusker/Scenes/ComposeSceneDelegate.swift b/Tusker/Scenes/ComposeSceneDelegate.swift index 61f2de1c..c97a36b9 100644 --- a/Tusker/Scenes/ComposeSceneDelegate.swift +++ b/Tusker/Scenes/ComposeSceneDelegate.swift @@ -8,6 +8,7 @@ import UIKit import Combine +import UserAccounts class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate { @@ -22,12 +23,12 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDeleg return } - guard LocalData.shared.onboardingComplete else { + guard UserAccountsManager.shared.onboardingComplete else { UIApplication.shared.requestSceneSessionDestruction(session, options: nil, errorHandler: nil) return } - let account: LocalData.UserAccountInfo + let account: UserAccountInfo let controller: MastodonController let draft: Draft? @@ -36,7 +37,7 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDeleg account = activityAccount } else { // todo: this potentially changes the account for the draft, should show the same warning to user as in the drafts selection screen - account = LocalData.shared.getMostRecentAccount()! + account = UserAccountsManager.shared.getMostRecentAccount()! } controller = MastodonController.getForAccount(account) @@ -49,7 +50,7 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDeleg draft = nil } } else { - account = LocalData.shared.getMostRecentAccount()! + account = UserAccountsManager.shared.getMostRecentAccount()! controller = MastodonController.getForAccount(account) draft = nil } diff --git a/Tusker/Scenes/MainSceneDelegate.swift b/Tusker/Scenes/MainSceneDelegate.swift index 7ca55ae0..8b6f5547 100644 --- a/Tusker/Scenes/MainSceneDelegate.swift +++ b/Tusker/Scenes/MainSceneDelegate.swift @@ -11,6 +11,7 @@ import Pachyderm import MessageUI import CoreData import Duckable +import UserAccounts class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate { @@ -161,13 +162,13 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate func showAppOrOnboardingUI(session: UISceneSession? = nil) { let session = session ?? window!.windowScene!.session - if LocalData.shared.onboardingComplete { - let account: LocalData.UserAccountInfo + if UserAccountsManager.shared.onboardingComplete { + let account: UserAccountInfo if let activity = launchActivity, let activityAccount = UserActivityManager.getAccount(from: activity) { account = activityAccount } else { - account = LocalData.shared.getMostRecentAccount()! + account = UserAccountsManager.shared.getMostRecentAccount()! } if session.mastodonController == nil { @@ -194,9 +195,9 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate } } - func activateAccount(_ account: LocalData.UserAccountInfo, animated: Bool) { - let oldMostRecentAccount = LocalData.shared.mostRecentAccountID - LocalData.shared.setMostRecentAccount(account) + func activateAccount(_ account: UserAccountInfo, animated: Bool) { + let oldMostRecentAccount = UserAccountsManager.shared.mostRecentAccountID + UserAccountsManager.shared.setMostRecentAccount(account) window!.windowScene!.session.mastodonController = MastodonController.getForAccount(account) // iPadOS shows the title below the App Name @@ -212,8 +213,8 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate if let container = window?.rootViewController as? AccountSwitchingContainerViewController { let direction: AccountSwitchingContainerViewController.AnimationDirection if animated, - let oldIndex = LocalData.shared.accounts.firstIndex(where: { $0.id == oldMostRecentAccount }), - let newIndex = LocalData.shared.accounts.firstIndex(of: account) { + let oldIndex = UserAccountsManager.shared.accounts.firstIndex(where: { $0.id == oldMostRecentAccount }), + let newIndex = UserAccountsManager.shared.accounts.firstIndex(of: account) { direction = newIndex > oldIndex ? .upwards : .downwards } else { direction = .none @@ -229,8 +230,8 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate return } LogoutService(accountInfo: account).run() - if LocalData.shared.onboardingComplete { - activateAccount(LocalData.shared.accounts.first!, animated: false) + if UserAccountsManager.shared.onboardingComplete { + activateAccount(UserAccountsManager.shared.accounts.first!, animated: false) } else { window!.rootViewController = createOnboardingUI() } @@ -269,7 +270,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate } extension MainSceneDelegate: OnboardingViewControllerDelegate { - func didFinishOnboarding(account: LocalData.UserAccountInfo) { + func didFinishOnboarding(account: UserAccountInfo) { activateAccount(account, animated: false) } } diff --git a/Tusker/Screens/Fast Account Switcher/FastAccountSwitcherViewController.swift b/Tusker/Screens/Fast Account Switcher/FastAccountSwitcherViewController.swift index 408e10ce..0104cfab 100644 --- a/Tusker/Screens/Fast Account Switcher/FastAccountSwitcherViewController.swift +++ b/Tusker/Screens/Fast Account Switcher/FastAccountSwitcherViewController.swift @@ -7,6 +7,7 @@ // import UIKit +import UserAccounts protocol FastAccountSwitcherViewControllerDelegate: AnyObject { func fastAccountSwitcherAddToViewHierarchy(_ fastAccountSwitcher: FastAccountSwitcherViewController) @@ -139,9 +140,9 @@ class FastAccountSwitcherViewController: UIViewController { addAccountPlaceholder ] - for account in LocalData.shared.accounts { + for account in UserAccountsManager.shared.accounts { let accountView = FastSwitchingAccountView(account: account, orientation: itemOrientation) - accountView.isCurrent = account.id == LocalData.shared.mostRecentAccountID + accountView.isCurrent = account.id == UserAccountsManager.shared.mostRecentAccountID accountsStack.addArrangedSubview(accountView) accountViews.append(accountView) } @@ -168,9 +169,9 @@ class FastAccountSwitcherViewController: UIViewController { (self.view.window!.windowScene!.delegate as! MainSceneDelegate).showAddAccount() } } else { - let account = LocalData.shared.accounts[newIndex - 1] + let account = UserAccountsManager.shared.accounts[newIndex - 1] - if account.id != LocalData.shared.mostRecentAccountID { + if account.id != UserAccountsManager.shared.mostRecentAccountID { if hapticFeedback { selectionChangedFeedbackGenerator?.selectionChanged() } diff --git a/Tusker/Screens/Fast Account Switcher/FastSwitchingAccountView.swift b/Tusker/Screens/Fast Account Switcher/FastSwitchingAccountView.swift index e5930cf7..7e04fbc3 100644 --- a/Tusker/Screens/Fast Account Switcher/FastSwitchingAccountView.swift +++ b/Tusker/Screens/Fast Account Switcher/FastSwitchingAccountView.swift @@ -7,6 +7,7 @@ // import UIKit +import UserAccounts class FastSwitchingAccountView: UIView { @@ -49,7 +50,7 @@ class FastSwitchingAccountView: UIView { private var avatarRequest: ImageCache.Request? - init(account: LocalData.UserAccountInfo, orientation: FastAccountSwitcherViewController.ItemOrientation) { + init(account: UserAccountInfo, orientation: FastAccountSwitcherViewController.ItemOrientation) { self.orientation = orientation super.init(frame: .zero) commonInit() @@ -121,7 +122,7 @@ class FastSwitchingAccountView: UIView { isAccessibilityElement = true } - private func setupAccount(account: LocalData.UserAccountInfo) { + private func setupAccount(account: UserAccountInfo) { usernameLabel.text = account.username instanceLabel.text = account.instanceURL.host! let controller = MastodonController.getForAccount(account) diff --git a/Tusker/Screens/Main/AccountSwitchingContainerViewController.swift b/Tusker/Screens/Main/AccountSwitchingContainerViewController.swift index eb35c7ea..29a0b767 100644 --- a/Tusker/Screens/Main/AccountSwitchingContainerViewController.swift +++ b/Tusker/Screens/Main/AccountSwitchingContainerViewController.swift @@ -8,6 +8,7 @@ import UIKit import ScreenCorners +import UserAccounts class AccountSwitchingContainerViewController: UIViewController { @@ -16,7 +17,7 @@ class AccountSwitchingContainerViewController: UIViewController { private var userActivities: [String: NSUserActivity] = [:] - init(root: TuskerRootViewController, for account: LocalData.UserAccountInfo) { + init(root: TuskerRootViewController, for account: UserAccountInfo) { self.currentAccountID = account.id self.root = root @@ -33,7 +34,7 @@ class AccountSwitchingContainerViewController: UIViewController { embedChild(root) } - func setRoot(_ newRoot: TuskerRootViewController, for account: LocalData.UserAccountInfo, animating direction: AnimationDirection) { + func setRoot(_ newRoot: TuskerRootViewController, for account: UserAccountInfo, animating direction: AnimationDirection) { let oldRoot = self.root if direction == .none { oldRoot.removeViewAndController() diff --git a/Tusker/Screens/Main/MainSidebarMyProfileCollectionViewCell.swift b/Tusker/Screens/Main/MainSidebarMyProfileCollectionViewCell.swift index dd7e5961..0de63d85 100644 --- a/Tusker/Screens/Main/MainSidebarMyProfileCollectionViewCell.swift +++ b/Tusker/Screens/Main/MainSidebarMyProfileCollectionViewCell.swift @@ -7,6 +7,7 @@ // import UIKit +import UserAccounts class MainSidebarMyProfileCollectionViewCell: UICollectionViewListCell { @@ -35,7 +36,7 @@ class MainSidebarMyProfileCollectionViewCell: UICollectionViewListCell { fatalError("init(coder:) has not been implemented") } - func updateUI(item: MainSidebarViewController.Item, account: LocalData.UserAccountInfo) async { + func updateUI(item: MainSidebarViewController.Item, account: UserAccountInfo) async { var config = defaultContentConfiguration() config.text = item.title config.image = UIImage(systemName: item.imageName!) diff --git a/Tusker/Screens/Onboarding/OnboardingViewController.swift b/Tusker/Screens/Onboarding/OnboardingViewController.swift index f3331181..402c783c 100644 --- a/Tusker/Screens/Onboarding/OnboardingViewController.swift +++ b/Tusker/Screens/Onboarding/OnboardingViewController.swift @@ -10,10 +10,11 @@ import UIKit import AuthenticationServices import Pachyderm import OSLog +import UserAccounts protocol OnboardingViewControllerDelegate { @MainActor - func didFinishOnboarding(account: LocalData.UserAccountInfo) + func didFinishOnboarding(account: UserAccountInfo) } private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "OnboardingViewController") @@ -145,7 +146,7 @@ class OnboardingViewController: UINavigationController { } // construct a temporary UserAccountInfo instance for the MastodonController to use to fetch its own account - let tempAccountInfo = LocalData.UserAccountInfo(tempInstanceURL: instanceURL, clientID: clientID, clientSecret: clientSecret, accessToken: accessToken) + let tempAccountInfo = UserAccountInfo(tempInstanceURL: instanceURL, clientID: clientID, clientSecret: clientSecret, accessToken: accessToken) mastodonController.accountInfo = tempAccountInfo updateStatus("Checking Credentials") @@ -158,7 +159,7 @@ class OnboardingViewController: UINavigationController { throw Error.gettingOwnAccount(error) } - let accountInfo = LocalData.shared.addAccount(instanceURL: instanceURL, clientID: clientID, clientSecret: clientSecret, username: ownAccount.username, accessToken: accessToken) + let accountInfo = UserAccountsManager.shared.addAccount(instanceURL: instanceURL, clientID: clientID, clientSecret: clientSecret, username: ownAccount.username, accessToken: accessToken) mastodonController.accountInfo = accountInfo self.onboardingDelegate?.didFinishOnboarding(account: accountInfo) diff --git a/Tusker/Screens/Preferences/AdvancedPrefsView.swift b/Tusker/Screens/Preferences/AdvancedPrefsView.swift index d5aed245..d698f14c 100644 --- a/Tusker/Screens/Preferences/AdvancedPrefsView.swift +++ b/Tusker/Screens/Preferences/AdvancedPrefsView.swift @@ -9,6 +9,7 @@ import SwiftUI import Pachyderm import CoreData import CloudKit +import UserAccounts struct AdvancedPrefsView : View { @ObservedObject var preferences = Preferences.shared @@ -30,7 +31,7 @@ struct AdvancedPrefsView : View { var formattingFooter: some View { var s: AttributedString = "This option is only supported with Pleroma and some compatible Mastodon instances (such as Glitch).\n" - if let account = LocalData.shared.getMostRecentAccount() { + if let account = UserAccountsManager.shared.getMostRecentAccount() { let mastodonController = MastodonController.getForAccount(account) // shouldn't need to load the instance here, because loading it is kicked off my the scene delegate if !mastodonController.instanceFeatures.probablySupportsMarkdown { @@ -135,7 +136,7 @@ struct AdvancedPrefsView : View { ].map { $0.getDiskSizeInBytes() ?? 0 }.reduce(0, +) - mastodonCacheSize = LocalData.shared.accounts.map { + mastodonCacheSize = UserAccountsManager.shared.accounts.map { let descriptions = MastodonController.getForAccount($0).persistentContainer.persistentStoreDescriptions return descriptions.map { guard let url = $0.url else { @@ -148,7 +149,7 @@ struct AdvancedPrefsView : View { } private func clearCache() { - for account in LocalData.shared.accounts { + for account in UserAccountsManager.shared.accounts { let controller = MastodonController.getForAccount(account) let container = controller.persistentContainer do { @@ -178,7 +179,7 @@ struct AdvancedPrefsView : View { } private func resetUI() { - let mostRecent = LocalData.shared.getMostRecentAccount()! + let mostRecent = UserAccountsManager.shared.getMostRecentAccount()! NotificationCenter.default.post(name: .activateAccount, object: nil, userInfo: ["account": mostRecent]) } } diff --git a/Tusker/Screens/Preferences/LocalAccountAvatarView.swift b/Tusker/Screens/Preferences/LocalAccountAvatarView.swift index 6d17804e..b586e528 100644 --- a/Tusker/Screens/Preferences/LocalAccountAvatarView.swift +++ b/Tusker/Screens/Preferences/LocalAccountAvatarView.swift @@ -7,9 +7,10 @@ // import SwiftUI +import UserAccounts struct LocalAccountAvatarView: View { - let localAccountInfo: LocalData.UserAccountInfo + let localAccountInfo: UserAccountInfo @State var avatarImage: UIImage? = nil @ObservedObject var preferences = Preferences.shared diff --git a/Tusker/Screens/Preferences/PreferencesNavigationController.swift b/Tusker/Screens/Preferences/PreferencesNavigationController.swift index 5514a635..a36aee6d 100644 --- a/Tusker/Screens/Preferences/PreferencesNavigationController.swift +++ b/Tusker/Screens/Preferences/PreferencesNavigationController.swift @@ -8,6 +8,7 @@ import UIKit import SwiftUI +import UserAccounts class PreferencesNavigationController: UINavigationController { @@ -64,7 +65,7 @@ class PreferencesNavigationController: UINavigationController { guard let windowScene = self.view.window?.windowScene else { return } - let account = notification.userInfo!["account"] as! LocalData.UserAccountInfo + let account = notification.userInfo!["account"] as! UserAccountInfo if let sceneDelegate = windowScene.delegate as? MainSceneDelegate { isSwitchingAccounts = true dismiss(animated: true) { // dismiss preferences @@ -85,8 +86,8 @@ class PreferencesNavigationController: UINavigationController { sceneDelegate.logoutCurrent() } } else { - LogoutService(accountInfo: LocalData.shared.getMostRecentAccount()!).run() - let accountID = LocalData.shared.getMostRecentAccount()?.id + LogoutService(accountInfo: UserAccountsManager.shared.getMostRecentAccount()!).run() + let accountID = UserAccountsManager.shared.getMostRecentAccount()?.id UIApplication.shared.requestSceneSessionActivation(nil, userActivity: UserActivityManager.mainSceneActivity(accountID: accountID), options: nil) UIApplication.shared.requestSceneSessionDestruction(windowScene.session, options: nil) } @@ -95,7 +96,7 @@ class PreferencesNavigationController: UINavigationController { } extension PreferencesNavigationController: OnboardingViewControllerDelegate { - func didFinishOnboarding(account: LocalData.UserAccountInfo) { + func didFinishOnboarding(account: UserAccountInfo) { guard let windowScene = self.view.window?.windowScene else { return } diff --git a/Tusker/Screens/Preferences/PreferencesView.swift b/Tusker/Screens/Preferences/PreferencesView.swift index 049002e8..6ee2f61a 100644 --- a/Tusker/Screens/Preferences/PreferencesView.swift +++ b/Tusker/Screens/Preferences/PreferencesView.swift @@ -6,11 +6,12 @@ // import SwiftUI +import UserAccounts struct PreferencesView: View { let mastodonController: MastodonController - @ObservedObject private var localData = LocalData.shared + @ObservedObject private var userAccounts = UserAccountsManager.shared @State private var showingLogoutConfirmation = false init(mastodonController: MastodonController) { @@ -31,7 +32,7 @@ struct PreferencesView: View { private var accountsSection: some View { Section { - ForEach(localData.accounts, id: \.accessToken) { (account) in + ForEach(userAccounts.accounts, id: \.accessToken) { (account) in Button(action: { NotificationCenter.default.post(name: .activateAccount, object: nil, userInfo: ["account": account]) }) { @@ -58,12 +59,12 @@ struct PreferencesView: View { }.onDelete { (indices: IndexSet) in var indices = indices var logoutFromCurrent = false - if let index = indices.first(where: { localData.accounts[$0] == mastodonController.accountInfo! }) { + if let index = indices.first(where: { userAccounts.accounts[$0] == mastodonController.accountInfo! }) { logoutFromCurrent = true indices.remove(index) } - indices.forEach { LogoutService(accountInfo: localData.accounts[$0]).run() } + indices.forEach { LogoutService(accountInfo: userAccounts.accounts[$0]).run() } if logoutFromCurrent { self.logoutPressed() diff --git a/Tusker/Shortcuts/UserActivityManager.swift b/Tusker/Shortcuts/UserActivityManager.swift index 48fd88f1..eaa3eff0 100644 --- a/Tusker/Shortcuts/UserActivityManager.swift +++ b/Tusker/Shortcuts/UserActivityManager.swift @@ -10,6 +10,7 @@ import UIKit import Intents import Pachyderm import OSLog +import UserAccounts private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "UserActivityManager") @@ -32,11 +33,11 @@ class UserActivityManager { scene.session.mastodonController! } - static func getAccount(from activity: NSUserActivity) -> LocalData.UserAccountInfo? { + static func getAccount(from activity: NSUserActivity) -> UserAccountInfo? { guard let id = activity.userInfo?["accountID"] as? String else { return nil } - return LocalData.shared.getAccount(id: id) + return UserAccountsManager.shared.getAccount(id: id) } // MARK: - Main Scene From 391ea1b46a5d8888540ed5dbc8133cc6536d037f Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sun, 5 Mar 2023 14:52:19 -0500 Subject: [PATCH 02/11] Move InstanceFeatures to separate package --- Packages/InstanceFeatures/.gitignore | 9 +++ Packages/InstanceFeatures/Package.resolved | 23 +++++++ Packages/InstanceFeatures/Package.swift | 31 +++++++++ Packages/InstanceFeatures/README.md | 3 + .../InstanceFeatures}/InstanceFeatures.swift | 68 ++++++++----------- .../InstanceFeaturesTests}/VersionTests.swift | 4 +- Tusker.xcodeproj/project.pbxproj | 13 ++-- Tusker/API/MastodonController.swift | 25 ++++++- Tusker/Models/CompositionAttachmentData.swift | 1 + 9 files changed, 129 insertions(+), 48 deletions(-) create mode 100644 Packages/InstanceFeatures/.gitignore create mode 100644 Packages/InstanceFeatures/Package.resolved create mode 100644 Packages/InstanceFeatures/Package.swift create mode 100644 Packages/InstanceFeatures/README.md rename {Tusker/API => Packages/InstanceFeatures/Sources/InstanceFeatures}/InstanceFeatures.swift (84%) rename {TuskerTests => Packages/InstanceFeatures/Tests/InstanceFeaturesTests}/VersionTests.swift (91%) diff --git a/Packages/InstanceFeatures/.gitignore b/Packages/InstanceFeatures/.gitignore new file mode 100644 index 00000000..3b298120 --- /dev/null +++ b/Packages/InstanceFeatures/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/Packages/InstanceFeatures/Package.resolved b/Packages/InstanceFeatures/Package.resolved new file mode 100644 index 00000000..a9ae5ab1 --- /dev/null +++ b/Packages/InstanceFeatures/Package.resolved @@ -0,0 +1,23 @@ +{ + "pins" : [ + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "025bcb1165deab2e20d4eaba79967ce73013f496", + "version" : "1.2.1" + } + }, + { + "identity" : "swift-url", + "kind" : "remoteSourceControl", + "location" : "https://github.com/karwa/swift-url.git", + "state" : { + "branch" : "main", + "revision" : "220f6ab9d8a7e0742f85eb9f21b745942e001ae6" + } + } + ], + "version" : 2 +} diff --git a/Packages/InstanceFeatures/Package.swift b/Packages/InstanceFeatures/Package.swift new file mode 100644 index 00000000..1927a00b --- /dev/null +++ b/Packages/InstanceFeatures/Package.swift @@ -0,0 +1,31 @@ +// swift-tools-version: 5.7 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "InstanceFeatures", + platforms: [ + .iOS(.v15), + ], + products: [ + // Products define the executables and libraries a package produces, and make them visible to other packages. + .library( + name: "InstanceFeatures", + targets: ["InstanceFeatures"]), + ], + dependencies: [ + // Dependencies declare other packages that this package depends on. + .package(path: "../Pachyderm"), + ], + targets: [ + // Targets are the basic building blocks of a package. A target can define a module or a test suite. + // Targets can depend on other targets in this package, and on products in packages this package depends on. + .target( + name: "InstanceFeatures", + dependencies: ["Pachyderm"]), + .testTarget( + name: "InstanceFeaturesTests", + dependencies: ["InstanceFeatures"]), + ] +) diff --git a/Packages/InstanceFeatures/README.md b/Packages/InstanceFeatures/README.md new file mode 100644 index 00000000..d9dc107e --- /dev/null +++ b/Packages/InstanceFeatures/README.md @@ -0,0 +1,3 @@ +# InstanceFeatures + +A description of this package. diff --git a/Tusker/API/InstanceFeatures.swift b/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceFeatures.swift similarity index 84% rename from Tusker/API/InstanceFeatures.swift rename to Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceFeatures.swift index 23e3700e..c8760bc8 100644 --- a/Tusker/API/InstanceFeatures.swift +++ b/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceFeatures.swift @@ -7,17 +7,20 @@ // import Foundation +import Combine import Pachyderm -import Sentry -struct InstanceFeatures { +public class InstanceFeatures: ObservableObject { private static let pleromaVersionRegex = try! NSRegularExpression(pattern: "\\(compatible; pleroma (.*)\\)", options: .caseInsensitive) private static let akkomaVersionRegex = try! NSRegularExpression(pattern: "\\(compatible; akkoma (.*)\\)", options: .caseInsensitive) - private var instanceType: InstanceType = .mastodon(.vanilla, nil) - private(set) var maxStatusChars = 500 + private let _featuresUpdated = PassthroughSubject() + public var featuresUpdated: some Publisher { _featuresUpdated } - var localOnlyPosts: Bool { + @Published private var instanceType: InstanceType = .mastodon(.vanilla, nil) + @Published public private(set) var maxStatusChars = 500 + + public var localOnlyPosts: Bool { switch instanceType { case .mastodon(.hometown(_), _), .mastodon(.glitch, _): return true @@ -26,19 +29,19 @@ struct InstanceFeatures { } } - var mastodonAttachmentRestrictions: Bool { + public var mastodonAttachmentRestrictions: Bool { instanceType.isMastodon } - var pollsAndAttachments: Bool { + public var pollsAndAttachments: Bool { instanceType.isPleroma } - var boostToOriginalAudience: Bool { + public var boostToOriginalAudience: Bool { instanceType.isPleroma || instanceType.isMastodon } - var profilePinnedStatuses: Bool { + public var profilePinnedStatuses: Bool { switch instanceType { case .pixelfed: return false @@ -47,24 +50,24 @@ struct InstanceFeatures { } } - var trends: Bool { + public var trends: Bool { instanceType.isMastodon } - var profileSuggestions: Bool { + public var profileSuggestions: Bool { instanceType.isMastodon && hasMastodonVersion(3, 4, 0) } - var trendingStatusesAndLinks: Bool { + public var trendingStatusesAndLinks: Bool { instanceType.isMastodon && hasMastodonVersion(3, 5, 0) } - var reblogVisibility: Bool { + public var reblogVisibility: Bool { (instanceType.isMastodon && hasMastodonVersion(2, 8, 0)) || (instanceType.isPleroma && hasPleromaVersion(2, 0, 0)) } - var probablySupportsMarkdown: Bool { + public var probablySupportsMarkdown: Bool { switch instanceType { case .pleroma(_), .mastodon(.glitch, _), .mastodon(.hometown(_), _): return true @@ -73,7 +76,7 @@ struct InstanceFeatures { } } - var needsLocalOnlyEmojiHack: Bool { + public var needsLocalOnlyEmojiHack: Bool { if case .mastodon(.glitch, _) = instanceType { return true } else { @@ -81,7 +84,7 @@ struct InstanceFeatures { } } - var needsWideColorGamutHack: Bool { + public var needsWideColorGamutHack: Bool { if case .mastodon(_, .some(let version)) = instanceType { return version < Version(4, 0, 0) } else { @@ -89,23 +92,26 @@ struct InstanceFeatures { } } - var canFollowHashtags: Bool { + public var canFollowHashtags: Bool { hasMastodonVersion(4, 0, 0) } - var filtersV2: Bool { + public var filtersV2: Bool { hasMastodonVersion(4, 0, 0) } - var notificationsAllowedTypes: Bool { + public var notificationsAllowedTypes: Bool { hasMastodonVersion(3, 5, 0) } - var pollVotersCount: Bool { + public var pollVotersCount: Bool { instanceType.isMastodon } - mutating func update(instance: Instance, nodeInfo: NodeInfo?) { + public init() { + } + + public func update(instance: Instance, nodeInfo: NodeInfo?) { let ver = instance.version.lowercased() if ver.contains("glitch") { instanceType = .mastodon(.glitch, Version(string: ver)) @@ -150,10 +156,10 @@ struct InstanceFeatures { maxStatusChars = instance.maxStatusCharacters ?? 500 - setInstanceBreadcrumb(instance: instance, nodeInfo: nodeInfo) + _featuresUpdated.send() } - func hasMastodonVersion(_ major: Int, _ minor: Int, _ patch: Int) -> Bool { + public func hasMastodonVersion(_ major: Int, _ minor: Int, _ patch: Int) -> Bool { if case .mastodon(_, .some(let version)) = instanceType { return version >= Version(major, minor, patch) } else { @@ -259,19 +265,3 @@ extension InstanceFeatures { } } } - -private func setInstanceBreadcrumb(instance: Instance, nodeInfo: NodeInfo?) { - let crumb = Breadcrumb(level: .info, category: "MastodonController") - crumb.data = [ - "instance": [ - "version": instance.version - ], - ] - if let nodeInfo { - crumb.data!["nodeInfo"] = [ - "software": nodeInfo.software.name, - "version": nodeInfo.software.version, - ] - } - SentrySDK.addBreadcrumb(crumb) -} diff --git a/TuskerTests/VersionTests.swift b/Packages/InstanceFeatures/Tests/InstanceFeaturesTests/VersionTests.swift similarity index 91% rename from TuskerTests/VersionTests.swift rename to Packages/InstanceFeatures/Tests/InstanceFeaturesTests/VersionTests.swift index a8f726f9..bdfcfde1 100644 --- a/TuskerTests/VersionTests.swift +++ b/Packages/InstanceFeatures/Tests/InstanceFeaturesTests/VersionTests.swift @@ -1,13 +1,13 @@ // // VersionTests.swift -// TuskerTests +// InstanceFeaturesTests // // Created by Shadowfacts on 4/2/22. // Copyright © 2022 Shadowfacts. All rights reserved. // import XCTest -@testable import Tusker +@testable import InstanceFeatures class VersionTests: XCTestCase { diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index cd4275d7..dcf4216a 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -113,7 +113,6 @@ D62D2426217ABF63005076CC /* UserActivityType.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62D2425217ABF63005076CC /* UserActivityType.swift */; }; D62E9985279CA23900C26176 /* URLSession+Development.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62E9984279CA23900C26176 /* URLSession+Development.swift */; }; D62E9987279D094F00C26176 /* StatusMetaIndicatorsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62E9986279D094F00C26176 /* StatusMetaIndicatorsView.swift */; }; - D62E9989279DB2D100C26176 /* InstanceFeatures.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62E9988279DB2D100C26176 /* InstanceFeatures.swift */; }; D62FF04823D7CDD700909D6E /* AttributedStringHelperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62FF04723D7CDD700909D6E /* AttributedStringHelperTests.swift */; }; D6311C5025B3765B00B27539 /* ImageDataCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6311C4F25B3765B00B27539 /* ImageDataCache.swift */; }; D6333B372137838300CE884A /* AttributedString+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6333B362137838300CE884A /* AttributedString+Helpers.swift */; }; @@ -373,6 +372,7 @@ D6F6A557291F4F1600F496A8 /* MuteAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A556291F4F1600F496A8 /* MuteAccountView.swift */; }; D6F6A5592920676800F496A8 /* ComposeToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A5582920676800F496A8 /* ComposeToolbar.swift */; }; D6F953F021251A2900CF0F2B /* MastodonController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F953EF21251A2900CF0F2B /* MastodonController.swift */; }; + D6FA94E129B52898006AAC51 /* InstanceFeatures in Frameworks */ = {isa = PBXBuildFile; productRef = D6FA94E029B52898006AAC51 /* InstanceFeatures */; }; D6FF9860255C717400845181 /* AccountSwitchingContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6FF985F255C717400845181 /* AccountSwitchingContainerViewController.swift */; }; /* End PBXBuildFile section */ @@ -530,7 +530,6 @@ D62D2425217ABF63005076CC /* UserActivityType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserActivityType.swift; sourceTree = ""; }; D62E9984279CA23900C26176 /* URLSession+Development.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URLSession+Development.swift"; sourceTree = ""; }; D62E9986279D094F00C26176 /* StatusMetaIndicatorsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusMetaIndicatorsView.swift; sourceTree = ""; }; - D62E9988279DB2D100C26176 /* InstanceFeatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceFeatures.swift; sourceTree = ""; }; D62FF04723D7CDD700909D6E /* AttributedStringHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedStringHelperTests.swift; sourceTree = ""; }; D6311C4F25B3765B00B27539 /* ImageDataCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDataCache.swift; sourceTree = ""; }; D6333B362137838300CE884A /* AttributedString+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AttributedString+Helpers.swift"; sourceTree = ""; }; @@ -803,6 +802,7 @@ D6F6A556291F4F1600F496A8 /* MuteAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MuteAccountView.swift; sourceTree = ""; }; D6F6A5582920676800F496A8 /* ComposeToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeToolbar.swift; sourceTree = ""; }; D6F953EF21251A2900CF0F2B /* MastodonController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonController.swift; sourceTree = ""; }; + D6FA94DF29B52891006AAC51 /* InstanceFeatures */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = InstanceFeatures; path = Packages/InstanceFeatures; sourceTree = ""; }; D6FF985F255C717400845181 /* AccountSwitchingContainerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSwitchingContainerViewController.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -811,6 +811,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + D6FA94E129B52898006AAC51 /* InstanceFeatures in Frameworks */, D674A50927F9128D00BA03AC /* Pachyderm in Frameworks */, D659F35E2953A212002D944A /* TTTKit in Frameworks */, D6B0026E29B5248800C70BE2 /* UserAccounts in Frameworks */, @@ -1539,6 +1540,7 @@ D6BEA243291A0C83002F4D01 /* Duckable */, D68A76F22953915C001DA1B3 /* TTTKit */, D6B0026C29B5245400C70BE2 /* UserAccounts */, + D6FA94DF29B52891006AAC51 /* InstanceFeatures */, D6D4DDCE212518A000E1C4BB /* Tusker */, D6D4DDE3212518A200E1C4BB /* TuskerTests */, D6D4DDEE212518A200E1C4BB /* TuskerUITests */, @@ -1694,7 +1696,6 @@ D6F953F121251A2F00CF0F2B /* API */ = { isa = PBXGroup; children = ( - D62E9988279DB2D100C26176 /* InstanceFeatures.swift */, D6F953EF21251A2900CF0F2B /* MastodonController.swift */, D6E9CDA7281A427800BBC98E /* PostService.swift */, D61ABEFD28F1C92600B29151 /* FavoriteService.swift */, @@ -1743,6 +1744,7 @@ D6BEA244291A0EDE002F4D01 /* Duckable */, D659F35D2953A212002D944A /* TTTKit */, D6B0026D29B5248800C70BE2 /* UserAccounts */, + D6FA94E029B52898006AAC51 /* InstanceFeatures */, ); productName = Tusker; productReference = D6D4DDCC212518A000E1C4BB /* Tusker.app */; @@ -1999,7 +2001,6 @@ D6969E9E240C81B9002843CE /* NSTextAttachment+Emoji.swift in Sources */, D6BC9DB3232D4C07002CA326 /* WellnessPrefsView.swift in Sources */, D64BC18F23C18B9D000D0238 /* FollowRequestNotificationTableViewCell.swift in Sources */, - D62E9989279DB2D100C26176 /* InstanceFeatures.swift in Sources */, D6ADB6F028ED1F25009924AB /* CachedImageView.swift in Sources */, D693DE5923FE24310061E07D /* InteractivePushTransition.swift in Sources */, D69693FA25859A8000F4E116 /* ComposeSceneDelegate.swift in Sources */, @@ -2975,6 +2976,10 @@ isa = XCSwiftPackageProductDependency; productName = Duckable; }; + D6FA94E029B52898006AAC51 /* InstanceFeatures */ = { + isa = XCSwiftPackageProductDependency; + productName = InstanceFeatures; + }; /* End XCSwiftPackageProductDependency section */ /* Begin XCVersionGroup section */ diff --git a/Tusker/API/MastodonController.swift b/Tusker/API/MastodonController.swift index 489118fd..76d80669 100644 --- a/Tusker/API/MastodonController.swift +++ b/Tusker/API/MastodonController.swift @@ -10,6 +10,8 @@ import Foundation import Pachyderm import Combine import UserAccounts +import InstanceFeatures +import Sentry class MastodonController: ObservableObject { @@ -48,11 +50,11 @@ class MastodonController: ObservableObject { var accountPreferences: AccountPreferences! let client: Client! + let instanceFeatures = InstanceFeatures() @Published private(set) var account: Account! @Published private(set) var instance: Instance! @Published private(set) var nodeInfo: NodeInfo! - @Published private(set) var instanceFeatures = InstanceFeatures() @Published private(set) var lists: [List] = [] @Published private(set) var customEmojis: [Emoji]? @Published private(set) var followedHashtags: [FollowedHashtag] = [] @@ -84,11 +86,12 @@ class MastodonController: ObservableObject { } .sink { [unowned self] (instance, nodeInfo) in self.instanceFeatures.update(instance: instance, nodeInfo: nodeInfo) + setInstanceBreadcrumb(instance: instance, nodeInfo: nodeInfo) } .store(in: &cancellables) - $instanceFeatures - .filter { [unowned self] in $0.canFollowHashtags && self.followedHashtags.isEmpty } + instanceFeatures.featuresUpdated + .filter { [unowned self] _ in self.instanceFeatures.canFollowHashtags && self.followedHashtags.isEmpty } .sink { [unowned self] _ in Task { await self.loadFollowedHashtags() @@ -454,3 +457,19 @@ class MastodonController: ObservableObject { } } + +private func setInstanceBreadcrumb(instance: Instance, nodeInfo: NodeInfo?) { + let crumb = Breadcrumb(level: .info, category: "MastodonController") + crumb.data = [ + "instance": [ + "version": instance.version + ], + ] + if let nodeInfo { + crumb.data!["nodeInfo"] = [ + "software": nodeInfo.software.name, + "version": nodeInfo.software.version, + ] + } + SentrySDK.addBreadcrumb(crumb) +} diff --git a/Tusker/Models/CompositionAttachmentData.swift b/Tusker/Models/CompositionAttachmentData.swift index 10fec3d8..112b974a 100644 --- a/Tusker/Models/CompositionAttachmentData.swift +++ b/Tusker/Models/CompositionAttachmentData.swift @@ -10,6 +10,7 @@ import UIKit import Photos import UniformTypeIdentifiers import PencilKit +import InstanceFeatures enum CompositionAttachmentData { case asset(PHAsset) From 850a0e90ceeb5cc62afb4b4bd2597a4347e8d6bc Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Tue, 7 Mar 2023 10:07:45 -0500 Subject: [PATCH 03/11] Move MenuPicker to separate package --- Packages/TuskerComponents/.gitignore | 9 +++++ Packages/TuskerComponents/Package.swift | 31 +++++++++++++++++ Packages/TuskerComponents/README.md | 3 ++ .../TuskerComponents}/MenuPicker.swift | 34 +++++++++++-------- Tusker.xcodeproj/project.pbxproj | 13 ++++--- Tusker/Screens/Compose/ComposePollView.swift | 1 + Tusker/Screens/Compose/ComposeToolbar.swift | 1 + .../Customize Timelines/EditFilterView.swift | 1 + Tusker/Screens/Mute/MuteAccountView.swift | 1 + 9 files changed, 76 insertions(+), 18 deletions(-) create mode 100644 Packages/TuskerComponents/.gitignore create mode 100644 Packages/TuskerComponents/Package.swift create mode 100644 Packages/TuskerComponents/README.md rename {Tusker/Views => Packages/TuskerComponents/Sources/TuskerComponents}/MenuPicker.swift (73%) diff --git a/Packages/TuskerComponents/.gitignore b/Packages/TuskerComponents/.gitignore new file mode 100644 index 00000000..3b298120 --- /dev/null +++ b/Packages/TuskerComponents/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/Packages/TuskerComponents/Package.swift b/Packages/TuskerComponents/Package.swift new file mode 100644 index 00000000..a0bd020b --- /dev/null +++ b/Packages/TuskerComponents/Package.swift @@ -0,0 +1,31 @@ +// swift-tools-version: 5.7 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "TuskerComponents", + platforms: [ + .iOS(.v15), + ], + products: [ + // Products define the executables and libraries a package produces, and make them visible to other packages. + .library( + name: "TuskerComponents", + targets: ["TuskerComponents"]), + ], + dependencies: [ + // Dependencies declare other packages that this package depends on. + // .package(url: /* package url */, from: "1.0.0"), + ], + targets: [ + // Targets are the basic building blocks of a package. A target can define a module or a test suite. + // Targets can depend on other targets in this package, and on products in packages this package depends on. + .target( + name: "TuskerComponents", + dependencies: []), + .testTarget( + name: "TuskerComponentsTests", + dependencies: ["TuskerComponents"]), + ] +) diff --git a/Packages/TuskerComponents/README.md b/Packages/TuskerComponents/README.md new file mode 100644 index 00000000..11206816 --- /dev/null +++ b/Packages/TuskerComponents/README.md @@ -0,0 +1,3 @@ +# TuskerComponents + +A description of this package. diff --git a/Tusker/Views/MenuPicker.swift b/Packages/TuskerComponents/Sources/TuskerComponents/MenuPicker.swift similarity index 73% rename from Tusker/Views/MenuPicker.swift rename to Packages/TuskerComponents/Sources/TuskerComponents/MenuPicker.swift index 03dc3c44..21c0c8e9 100644 --- a/Tusker/Views/MenuPicker.swift +++ b/Packages/TuskerComponents/Sources/TuskerComponents/MenuPicker.swift @@ -1,6 +1,6 @@ // // MenuPicker.swift -// Tusker +// TuskerComponents // // Created by Shadowfacts on 11/7/22. // Copyright © 2022 Shadowfacts. All rights reserved. @@ -8,25 +8,31 @@ import SwiftUI -struct MenuPicker: UIViewRepresentable { - typealias UIViewType = UIButton +public struct MenuPicker: UIViewRepresentable { + public typealias UIViewType = UIButton @Binding var selection: Value let options: [Option] - var buttonStyle: ButtonStyle = .labelAndIcon + var buttonStyle: ButtonStyle private var selectedOption: Option { options.first(where: { $0.value == selection })! } - func makeUIView(context: Context) -> UIButton { + public init(selection: Binding, options: [Option], buttonStyle: ButtonStyle = .labelAndIcon) { + self._selection = selection + self.options = options + self.buttonStyle = buttonStyle + } + + public func makeUIView(context: Context) -> UIButton { let button = UIButton() button.showsMenuAsPrimaryAction = true button.setContentHuggingPriority(.required, for: .horizontal) return button } - func updateUIView(_ button: UIButton, context: Context) { + public func updateUIView(_ button: UIButton, context: Context) { var config = UIButton.Configuration.borderless() if #available(iOS 16.0, *) { config.indicator = .popup @@ -49,14 +55,14 @@ struct MenuPicker: UIViewRepresentable { button.isPointerInteractionEnabled = buttonStyle == .iconOnly } - struct Option { - let value: Value - let title: String - let subtitle: String? - let image: UIImage? - let accessibilityLabel: String? + public struct Option { + public let value: Value + public let title: String + public let subtitle: String? + public let image: UIImage? + public let accessibilityLabel: String? - init(value: Value, title: String, subtitle: String? = nil, image: UIImage? = nil, accessibilityLabel: String? = nil) { + public init(value: Value, title: String, subtitle: String? = nil, image: UIImage? = nil, accessibilityLabel: String? = nil) { self.value = value self.title = title self.subtitle = subtitle @@ -65,7 +71,7 @@ struct MenuPicker: UIViewRepresentable { } } - enum ButtonStyle { + public enum ButtonStyle { case labelAndIcon, labelOnly, iconOnly var hasLabel: Bool { diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index dcf4216a..a967bb6e 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -117,6 +117,7 @@ D6311C5025B3765B00B27539 /* ImageDataCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6311C4F25B3765B00B27539 /* ImageDataCache.swift */; }; D6333B372137838300CE884A /* AttributedString+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6333B362137838300CE884A /* AttributedString+Helpers.swift */; }; D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6333B782139AEFD00CE884A /* Date+TimeAgo.swift */; }; + D635237129B78A7D009ED5E7 /* TuskerComponents in Frameworks */ = {isa = PBXBuildFile; productRef = D635237029B78A7D009ED5E7 /* TuskerComponents */; }; D63661C02381C144004B9E16 /* PreferencesNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63661BF2381C144004B9E16 /* PreferencesNavigationController.swift */; }; D6370B9C24421FF30092A7FF /* Tusker.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = D6370B9A24421FF30092A7FF /* Tusker.xcdatamodeld */; }; D63CC702290EC0B8000E19DE /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = D63CC701290EC0B8000E19DE /* Sentry */; }; @@ -180,7 +181,6 @@ D667E5F82135C3040057A976 /* Mastodon+Equatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D667E5F72135C3040057A976 /* Mastodon+Equatable.swift */; }; D66A77BB233838DC0058F1EC /* UIFont+Traits.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66A77BA233838DC0058F1EC /* UIFont+Traits.swift */; }; D66C900B28DAB7FD00217BF2 /* TimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */; }; - D673ACCE2919E74200D6F8B0 /* MenuPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = D673ACCD2919E74200D6F8B0 /* MenuPicker.swift */; }; D674A50927F9128D00BA03AC /* Pachyderm in Frameworks */ = {isa = PBXBuildFile; productRef = D674A50827F9128D00BA03AC /* Pachyderm */; }; D677284824ECBCB100C732D3 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284724ECBCB100C732D3 /* ComposeView.swift */; }; D677284A24ECBDF400C732D3 /* ComposeCurrentAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */; }; @@ -599,7 +599,6 @@ D66A77BA233838DC0058F1EC /* UIFont+Traits.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFont+Traits.swift"; sourceTree = ""; }; D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineViewController.swift; sourceTree = ""; }; D671A6BE299DA96100A81FEA /* Tusker-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Tusker-Bridging-Header.h"; sourceTree = ""; }; - D673ACCD2919E74200D6F8B0 /* MenuPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuPicker.swift; sourceTree = ""; }; D674A50727F910F300BA03AC /* Pachyderm */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Pachyderm; path = Packages/Pachyderm; sourceTree = ""; }; D677284724ECBCB100C732D3 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = ""; }; D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeCurrentAccount.swift; sourceTree = ""; }; @@ -710,6 +709,7 @@ D6BC9DB4232D4CE3002CA326 /* NotificationsMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsMode.swift; sourceTree = ""; }; D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinesPageViewController.swift; sourceTree = ""; }; D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsViewController.swift; sourceTree = ""; }; + D6BD395C29B789D5005FFD2B /* TuskerComponents */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = TuskerComponents; path = Packages/TuskerComponents; sourceTree = ""; }; D6BEA243291A0C83002F4D01 /* Duckable */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Duckable; path = Packages/Duckable; sourceTree = ""; }; D6BEA246291A0F2D002F4D01 /* Duckable+Root.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Duckable+Root.swift"; sourceTree = ""; }; D6BEA248291C6118002F4D01 /* DraftsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftsView.swift; sourceTree = ""; }; @@ -812,6 +812,7 @@ buildActionMask = 2147483647; files = ( D6FA94E129B52898006AAC51 /* InstanceFeatures in Frameworks */, + D635237129B78A7D009ED5E7 /* TuskerComponents in Frameworks */, D674A50927F9128D00BA03AC /* Pachyderm in Frameworks */, D659F35E2953A212002D944A /* TTTKit in Frameworks */, D6B0026E29B5248800C70BE2 /* UserAccounts in Frameworks */, @@ -1466,7 +1467,6 @@ D620483323D3801D008A63EF /* LinkTextView.swift */, D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */, D6262C9928D01C4B00390C1F /* LoadingTableViewCell.swift */, - D673ACCD2919E74200D6F8B0 /* MenuPicker.swift */, D68E6F58253C9969001A1B4C /* MultiSourceEmojiLabel.swift */, D627943A23A55BA600D38C68 /* NavigableTableViewCell.swift */, D6D7069F29466649000827ED /* ScrollingSegmentedControl.swift */, @@ -1541,6 +1541,7 @@ D68A76F22953915C001DA1B3 /* TTTKit */, D6B0026C29B5245400C70BE2 /* UserAccounts */, D6FA94DF29B52891006AAC51 /* InstanceFeatures */, + D6BD395C29B789D5005FFD2B /* TuskerComponents */, D6D4DDCE212518A000E1C4BB /* Tusker */, D6D4DDE3212518A200E1C4BB /* TuskerTests */, D6D4DDEE212518A200E1C4BB /* TuskerUITests */, @@ -1745,6 +1746,7 @@ D659F35D2953A212002D944A /* TTTKit */, D6B0026D29B5248800C70BE2 /* UserAccounts */, D6FA94E029B52898006AAC51 /* InstanceFeatures */, + D635237029B78A7D009ED5E7 /* TuskerComponents */, ); productName = Tusker; productReference = D6D4DDCC212518A000E1C4BB /* Tusker.app */; @@ -2081,7 +2083,6 @@ D6BEA249291C6118002F4D01 /* DraftsView.swift in Sources */, D61F75AD293AF39000C0B37F /* Filter+Helpers.swift in Sources */, D6531DEE246B81C9000F9538 /* GifvAttachmentView.swift in Sources */, - D673ACCE2919E74200D6F8B0 /* MenuPicker.swift in Sources */, D6370B9C24421FF30092A7FF /* Tusker.xcdatamodeld in Sources */, D601FA83297EEC3F00A8E8B5 /* SuggestedProfileCardCollectionViewCell.swift in Sources */, D691772E29AA5D420054D7EF /* UserActivityHandlingContext.swift in Sources */, @@ -2945,6 +2946,10 @@ isa = XCSwiftPackageProductDependency; productName = Pachyderm; }; + D635237029B78A7D009ED5E7 /* TuskerComponents */ = { + isa = XCSwiftPackageProductDependency; + productName = TuskerComponents; + }; D63CC701290EC0B8000E19DE /* Sentry */ = { isa = XCSwiftPackageProductDependency; package = D63CC700290EC0B8000E19DE /* XCRemoteSwiftPackageReference "sentry-cocoa" */; diff --git a/Tusker/Screens/Compose/ComposePollView.swift b/Tusker/Screens/Compose/ComposePollView.swift index 6fb187b6..467c8c21 100644 --- a/Tusker/Screens/Compose/ComposePollView.swift +++ b/Tusker/Screens/Compose/ComposePollView.swift @@ -7,6 +7,7 @@ // import SwiftUI +import TuskerComponents struct ComposePollView: View { private static let formatter: DateComponentsFormatter = { diff --git a/Tusker/Screens/Compose/ComposeToolbar.swift b/Tusker/Screens/Compose/ComposeToolbar.swift index f3b7ba8f..d16bd943 100644 --- a/Tusker/Screens/Compose/ComposeToolbar.swift +++ b/Tusker/Screens/Compose/ComposeToolbar.swift @@ -8,6 +8,7 @@ import SwiftUI import Pachyderm +import TuskerComponents struct ComposeToolbar: View { static let height: CGFloat = 44 diff --git a/Tusker/Screens/Customize Timelines/EditFilterView.swift b/Tusker/Screens/Customize Timelines/EditFilterView.swift index 12e3972f..26cdb64f 100644 --- a/Tusker/Screens/Customize Timelines/EditFilterView.swift +++ b/Tusker/Screens/Customize Timelines/EditFilterView.swift @@ -8,6 +8,7 @@ import SwiftUI import Pachyderm +import TuskerComponents struct EditFilterView: View { private static let expiresInOptions: [MenuPicker.Option] = { diff --git a/Tusker/Screens/Mute/MuteAccountView.swift b/Tusker/Screens/Mute/MuteAccountView.swift index af33fb12..e6903b97 100644 --- a/Tusker/Screens/Mute/MuteAccountView.swift +++ b/Tusker/Screens/Mute/MuteAccountView.swift @@ -8,6 +8,7 @@ import SwiftUI import Pachyderm +import TuskerComponents struct MuteAccountView: View { private static let durationOptions: [MenuPicker.Option] = { From b2fe2fdf9a59676932312a87284d3166a55b6935 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Tue, 7 Mar 2023 10:14:35 -0500 Subject: [PATCH 04/11] Move Visibility to top-level type and move extensions to Pachyderm --- .../Pachyderm/Sources/Pachyderm/Client.swift | 2 +- .../Model/Protocols/StatusProtocol.swift | 2 +- .../Sources/Pachyderm/Model/Status.swift | 11 +---- .../Sources/Pachyderm/Model/Visibility.swift | 47 ++++++++++--------- Tusker.xcodeproj/project.pbxproj | 4 -- Tusker/API/ReblogService.swift | 6 +-- Tusker/CoreData/StatusMO.swift | 4 +- Tusker/Models/Draft.swift | 4 +- Tusker/Preferences/Preferences.swift | 10 ++-- Tusker/Screens/Compose/ComposeToolbar.swift | 2 +- .../Screens/Compose/MainComposeTextView.swift | 2 +- .../Preferences/ComposingPrefsView.swift | 2 +- .../Profile/ProfileViewController.swift | 2 +- .../UIAlertController+Visibility.swift | 4 +- 14 files changed, 45 insertions(+), 57 deletions(-) rename Tusker/Extensions/Visibility+Helpers.swift => Packages/Pachyderm/Sources/Pachyderm/Model/Visibility.swift (81%) diff --git a/Packages/Pachyderm/Sources/Pachyderm/Client.swift b/Packages/Pachyderm/Sources/Pachyderm/Client.swift index 02bd0955..ba171feb 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Client.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Client.swift @@ -383,7 +383,7 @@ public class Client { media: [Attachment]? = nil, sensitive: Bool? = nil, spoilerText: String? = nil, - visibility: Status.Visibility? = nil, + visibility: Visibility? = nil, language: String? = nil, pollOptions: [String]? = nil, pollExpiresIn: Int? = nil, diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Protocols/StatusProtocol.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Protocols/StatusProtocol.swift index afd49fde..e0a41264 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Model/Protocols/StatusProtocol.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Protocols/StatusProtocol.swift @@ -25,7 +25,7 @@ public protocol StatusProtocol { // var favourited: Bool { get } var sensitive: Bool { get } var spoilerText: String { get } - var visibility: Pachyderm.Status.Visibility { get } + var visibility: Visibility { get } var applicationName: String? { get } var pinned: Bool? { get } var bookmarked: Bool? { get } diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Status.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Status.swift index 6941e09a..133a5d47 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Model/Status.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Status.swift @@ -63,7 +63,7 @@ public final class Status: StatusProtocol, Decodable, Sendable { self.muted = try container.decodeIfPresent(Bool.self, forKey: .muted) self.sensitive = try container.decode(Bool.self, forKey: .sensitive) self.spoilerText = try container.decode(String.self, forKey: .spoilerText) - if let visibility = try? container.decode(Status.Visibility.self, forKey: .visibility) { + if let visibility = try? container.decode(Visibility.self, forKey: .visibility) { self.visibility = visibility self.localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly) } else if let s = try? container.decode(String.self, forKey: .visibility), @@ -187,13 +187,4 @@ public final class Status: StatusProtocol, Decodable, Sendable { } } -extension Status { - public enum Visibility: String, Codable, CaseIterable, Sendable { - case `public` - case unlisted - case `private` - case direct - } -} - extension Status: Identifiable {} diff --git a/Tusker/Extensions/Visibility+Helpers.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Visibility.swift similarity index 81% rename from Tusker/Extensions/Visibility+Helpers.swift rename to Packages/Pachyderm/Sources/Pachyderm/Model/Visibility.swift index 0c8d5b94..489b1f2e 100644 --- a/Tusker/Extensions/Visibility+Helpers.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Visibility.swift @@ -1,16 +1,33 @@ // -// Visibility+String.swift -// Tusker +// Visibility.swift +// Pachyderm // -// Created by Shadowfacts on 8/29/18. -// Copyright © 2018 Shadowfacts. All rights reserved. +// Created by Shadowfacts on 3/7/23. // -import Pachyderm -import UIKit +import Foundation -extension Status.Visibility { +public enum Visibility: String, Sendable, Codable, CaseIterable, Comparable { + case `public` + case unlisted + case `private` + case direct + public static func < (lhs: Visibility, rhs: Visibility) -> Bool { + switch (lhs, rhs) { + case (.direct, .public), (.private, .public), (.unlisted, .public): + return true + case (.direct, .unlisted), (.private, .unlisted): + return true + case (.direct, .private): + return true + default: + return false + } + } +} + +public extension Visibility { var displayName: String { switch self { case .public: @@ -62,20 +79,4 @@ extension Status.Visibility { return "envelope" } } - -} - -extension Status.Visibility: Comparable { - public static func < (lhs: Pachyderm.Status.Visibility, rhs: Pachyderm.Status.Visibility) -> Bool { - switch (lhs, rhs) { - case (.direct, .public), (.private, .public), (.unlisted, .public): - return true - case (.direct, .unlisted), (.private, .unlisted): - return true - case (.direct, .private): - return true - default: - return false - } - } } diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index a967bb6e..aa6674c7 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -173,7 +173,6 @@ D663626221360B1900C9CBA2 /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = D663626121360B1900C9CBA2 /* Preferences.swift */; }; D663626421360D2300C9CBA2 /* AvatarStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = D663626321360D2300C9CBA2 /* AvatarStyle.swift */; }; D663626C21361C6700C9CBA2 /* Account+Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = D663626B21361C6700C9CBA2 /* Account+Preferences.swift */; }; - D66362752137068A00C9CBA2 /* Visibility+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66362742137068A00C9CBA2 /* Visibility+Helpers.swift */; }; D6674AEA23341F7600E8DF94 /* AppShortcutItems.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6674AE923341F7600E8DF94 /* AppShortcutItems.swift */; }; D6676CA527A8D0020052936B /* WebURLFoundationExtras in Frameworks */ = {isa = PBXBuildFile; productRef = D6676CA427A8D0020052936B /* WebURLFoundationExtras */; }; D667E5E12134937B0057A976 /* TimelineStatusTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D667E5E02134937B0057A976 /* TimelineStatusTableViewCell.xib */; }; @@ -591,7 +590,6 @@ D663626121360B1900C9CBA2 /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = ""; }; D663626321360D2300C9CBA2 /* AvatarStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarStyle.swift; sourceTree = ""; }; D663626B21361C6700C9CBA2 /* Account+Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Account+Preferences.swift"; sourceTree = ""; }; - D66362742137068A00C9CBA2 /* Visibility+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Visibility+Helpers.swift"; sourceTree = ""; }; D6674AE923341F7600E8DF94 /* AppShortcutItems.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppShortcutItems.swift; sourceTree = ""; }; D667E5E02134937B0057A976 /* TimelineStatusTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TimelineStatusTableViewCell.xib; sourceTree = ""; }; D667E5F02134D5050057A976 /* UIViewController+Delegates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Delegates.swift"; sourceTree = ""; }; @@ -1310,7 +1308,6 @@ D667E5F02134D5050057A976 /* UIViewController+Delegates.swift */, D667E5F72135C3040057A976 /* Mastodon+Equatable.swift */, D663626B21361C6700C9CBA2 /* Account+Preferences.swift */, - D66362742137068A00C9CBA2 /* Visibility+Helpers.swift */, D6333B362137838300CE884A /* AttributedString+Helpers.swift */, D6333B782139AEFD00CE884A /* Date+TimeAgo.swift */, D67C57AE21E28EAD00C3118B /* Array+Uniques.swift */, @@ -2227,7 +2224,6 @@ D6945C3223AC4D36005C403C /* HashtagTimelineViewController.swift in Sources */, D647D92824257BEB0005044F /* AttachmentPreviewViewController.swift in Sources */, D61F75B5293BD97400C0B37F /* DeleteFilterService.swift in Sources */, - D66362752137068A00C9CBA2 /* Visibility+Helpers.swift in Sources */, D6DFC6A0242C4CCC00ACC392 /* Weak.swift in Sources */, D6C693FC2162FE6F007D6A6D /* LoadingViewController.swift in Sources */, D61ABEF828EFC3F900B29151 /* ProfileStatusesViewController.swift in Sources */, diff --git a/Tusker/API/ReblogService.swift b/Tusker/API/ReblogService.swift index ca331aed..3b928979 100644 --- a/Tusker/API/ReblogService.swift +++ b/Tusker/API/ReblogService.swift @@ -17,7 +17,7 @@ class ReblogService { private let status: StatusMO var hapticFeedback = true - var visibility: Status.Visibility? = nil + var visibility: Visibility? = nil var requireConfirmation = Preferences.shared.confirmBeforeReblog init(status: StatusMO, mastodonController: MastodonController, presenter: any TuskerNavigationDelegate) { @@ -39,8 +39,8 @@ class ReblogService { let image: UIImage? let reblogVisibilityActions: [CustomAlertController.MenuAction]? if mastodonController.instanceFeatures.reblogVisibility { - image = UIImage(systemName: Status.Visibility.public.unfilledImageName) - reblogVisibilityActions = [Status.Visibility.unlisted, .private].map { visibility in + image = UIImage(systemName: Visibility.public.unfilledImageName) + reblogVisibilityActions = [Visibility.unlisted, .private].map { visibility in CustomAlertController.MenuAction(title: "Reblog as \(visibility.displayName)", subtitle: visibility.subtitle, image: UIImage(systemName: visibility.unfilledImageName)) { // deliberately retain a strong reference to self Task { diff --git a/Tusker/CoreData/StatusMO.swift b/Tusker/CoreData/StatusMO.swift index 6d910991..cdb14745 100644 --- a/Tusker/CoreData/StatusMO.swift +++ b/Tusker/CoreData/StatusMO.swift @@ -75,9 +75,9 @@ public final class StatusMO: NSManagedObject, StatusProtocol { public var pinned: Bool? { pinnedInternal } public var bookmarked: Bool? { bookmarkedInternal } - public var visibility: Pachyderm.Status.Visibility { + public var visibility: Pachyderm.Visibility { get { - Pachyderm.Status.Visibility(rawValue: visibilityString) ?? .public + Pachyderm.Visibility(rawValue: visibilityString) ?? .public } set { visibilityString = newValue.rawValue diff --git a/Tusker/Models/Draft.swift b/Tusker/Models/Draft.swift index 909d109c..6cfab936 100644 --- a/Tusker/Models/Draft.swift +++ b/Tusker/Models/Draft.swift @@ -19,7 +19,7 @@ class Draft: Codable, ObservableObject { @Published var contentWarning: String @Published var attachments: [CompositionAttachment] @Published var inReplyToID: String? - @Published var visibility: Status.Visibility + @Published var visibility: Visibility @Published var poll: Poll? @Published var localOnly: Bool @@ -61,7 +61,7 @@ class Draft: Codable, ObservableObject { self.contentWarning = try container.decode(String.self, forKey: .contentWarning) self.attachments = try container.decode([CompositionAttachment].self, forKey: .attachments) self.inReplyToID = try container.decode(String?.self, forKey: .inReplyToID) - self.visibility = try container.decode(Status.Visibility.self, forKey: .visibility) + self.visibility = try container.decode(Visibility.self, forKey: .visibility) self.poll = try container.decode(Poll?.self, forKey: .poll) self.localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly) ?? false diff --git a/Tusker/Preferences/Preferences.swift b/Tusker/Preferences/Preferences.swift index 1cf03c4e..b5e8654e 100644 --- a/Tusker/Preferences/Preferences.swift +++ b/Tusker/Preferences/Preferences.swift @@ -49,7 +49,7 @@ class Preferences: Codable, ObservableObject { self.leadingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .leadingStatusSwipeActions) ?? leadingStatusSwipeActions self.trailingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .trailingStatusSwipeActions) ?? trailingStatusSwipeActions - self.defaultPostVisibility = try container.decode(Status.Visibility.self, forKey: .defaultPostVisibility) + self.defaultPostVisibility = try container.decode(Visibility.self, forKey: .defaultPostVisibility) self.defaultReplyVisibility = try container.decodeIfPresent(ReplyVisibility.self, forKey: .defaultReplyVisibility) ?? .sameAsPost self.automaticallySaveDrafts = try container.decode(Bool.self, forKey: .automaticallySaveDrafts) self.requireAttachmentDescriptions = try container.decode(Bool.self, forKey: .requireAttachmentDescriptions) @@ -158,7 +158,7 @@ class Preferences: Codable, ObservableObject { @Published var trailingStatusSwipeActions: [StatusSwipeAction] = [.reply, .share] // MARK: Composing - @Published var defaultPostVisibility = Status.Visibility.public + @Published var defaultPostVisibility = Visibility.public @Published var defaultReplyVisibility = ReplyVisibility.sameAsPost @Published var automaticallySaveDrafts = true @Published var requireAttachmentDescriptions = false @@ -266,11 +266,11 @@ class Preferences: Codable, ObservableObject { extension Preferences { enum ReplyVisibility: Codable, Hashable, CaseIterable { case sameAsPost - case visibility(Status.Visibility) + case visibility(Visibility) - static var allCases: [Preferences.ReplyVisibility] = [.sameAsPost] + Status.Visibility.allCases.map { .visibility($0) } + static var allCases: [Preferences.ReplyVisibility] = [.sameAsPost] + Visibility.allCases.map { .visibility($0) } - var resolved: Status.Visibility { + var resolved: Visibility { switch self { case .sameAsPost: return Preferences.shared.defaultPostVisibility diff --git a/Tusker/Screens/Compose/ComposeToolbar.swift b/Tusker/Screens/Compose/ComposeToolbar.swift index d16bd943..f5574053 100644 --- a/Tusker/Screens/Compose/ComposeToolbar.swift +++ b/Tusker/Screens/Compose/ComposeToolbar.swift @@ -12,7 +12,7 @@ import TuskerComponents struct ComposeToolbar: View { static let height: CGFloat = 44 - private static let visibilityOptions: [MenuPicker.Option] = Status.Visibility.allCases.map { vis in + private static let visibilityOptions: [MenuPicker.Option] = Visibility.allCases.map { vis in .init(value: vis, title: vis.displayName, subtitle: vis.subtitle, image: UIImage(systemName: vis.unfilledImageName), accessibilityLabel: "Visibility: \(vis.displayName)") } diff --git a/Tusker/Screens/Compose/MainComposeTextView.swift b/Tusker/Screens/Compose/MainComposeTextView.swift index 2ba9dae4..1ef34ea1 100644 --- a/Tusker/Screens/Compose/MainComposeTextView.swift +++ b/Tusker/Screens/Compose/MainComposeTextView.swift @@ -86,7 +86,7 @@ struct MainComposeWrappedTextView: UIViewRepresentable { typealias UIViewType = UITextView @Binding var text: String - let visibility: Status.Visibility + let visibility: Pachyderm.Visibility @Binding var becomeFirstResponder: Bool var textDidChange: (UITextView) -> Void diff --git a/Tusker/Screens/Preferences/ComposingPrefsView.swift b/Tusker/Screens/Preferences/ComposingPrefsView.swift index d1e91fda..59635f5d 100644 --- a/Tusker/Screens/Preferences/ComposingPrefsView.swift +++ b/Tusker/Screens/Preferences/ComposingPrefsView.swift @@ -27,7 +27,7 @@ struct ComposingPrefsView: View { var visibilitySection: some View { Section { Picker(selection: $preferences.defaultPostVisibility, label: Text("Default Visibility")) { - ForEach(Status.Visibility.allCases, id: \.self) { visibility in + ForEach(Visibility.allCases, id: \.self) { visibility in HStack { Image(systemName: visibility.imageName) Text(visibility.displayName) diff --git a/Tusker/Screens/Profile/ProfileViewController.swift b/Tusker/Screens/Profile/ProfileViewController.swift index bdfd765b..2579372a 100644 --- a/Tusker/Screens/Profile/ProfileViewController.swift +++ b/Tusker/Screens/Profile/ProfileViewController.swift @@ -78,7 +78,7 @@ class ProfileViewController: UIViewController, StateRestorableViewController { let composeButton = UIBarButtonItem(barButtonSystemItem: .compose, target: self, action: #selector(composeMentioning)) composeButton.menu = UIMenu(children: [ - UIAction(title: "Direct Message", image: UIImage(systemName: Status.Visibility.direct.unfilledImageName), handler: { [unowned self] _ in + UIAction(title: "Direct Message", image: UIImage(systemName: Visibility.direct.unfilledImageName), handler: { [unowned self] _ in self.composeDirectMentioning() }) ]) diff --git a/Tusker/Screens/Utilities/UIAlertController+Visibility.swift b/Tusker/Screens/Utilities/UIAlertController+Visibility.swift index 6f4d8040..ad5c27aa 100644 --- a/Tusker/Screens/Utilities/UIAlertController+Visibility.swift +++ b/Tusker/Screens/Utilities/UIAlertController+Visibility.swift @@ -11,10 +11,10 @@ import Pachyderm extension UIAlertController { - convenience init(currentVisibility: Status.Visibility?, completion: @escaping (Status.Visibility?) -> Void) { + convenience init(currentVisibility: Visibility?, completion: @escaping (Visibility?) -> Void) { self.init(title: "Post Visibility", message: nil, preferredStyle: .actionSheet) - for visibility in Status.Visibility.allCases { + for visibility in Visibility.allCases { let action = UIAlertAction(title: visibility.displayName, style: .default) { (_) in completion(visibility) } From 6bd2eacb88af72b95e6c0b2e353c95ac735ffab9 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Thu, 13 Apr 2023 10:02:05 -0400 Subject: [PATCH 05/11] Fix replied-to account not being first mention --- Tusker/Extensions/Array+Uniques.swift | 7 ++++--- TuskerTests/ArrayUniqueTests.swift | 6 ++++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/Tusker/Extensions/Array+Uniques.swift b/Tusker/Extensions/Array+Uniques.swift index e2e77eba..b5490ad5 100644 --- a/Tusker/Extensions/Array+Uniques.swift +++ b/Tusker/Extensions/Array+Uniques.swift @@ -11,10 +11,10 @@ import Foundation extension Array { func uniques(by identify: (Element) -> ID) -> [Element] { var uniques = Set>() - for elem in self { - uniques.insert(Hashed(element: elem, id: identify(elem))) + for (index, elem) in self.enumerated() { + uniques.insert(Hashed(element: elem, id: identify(elem), origIndex: index)) } - return uniques.map(\.element) + return uniques.sorted(by: { $0.origIndex < $1.origIndex }).map(\.element) } } @@ -27,6 +27,7 @@ extension Array where Element: Hashable { fileprivate struct Hashed: Hashable { let element: Element let id: ID + let origIndex: Int static func ==(lhs: Self, rhs: Self) -> Bool { return lhs.id == rhs.id diff --git a/TuskerTests/ArrayUniqueTests.swift b/TuskerTests/ArrayUniqueTests.swift index 3d6cc23b..6755e590 100644 --- a/TuskerTests/ArrayUniqueTests.swift +++ b/TuskerTests/ArrayUniqueTests.swift @@ -19,6 +19,12 @@ final class ArrayUniqueTests: XCTestCase { XCTAssertEqual([a, b].uniques(by: \.string), [a]) } + func testUniquesOrder() { + let a = Test(string: "a") + let b = Test(string: "b") + XCTAssertEqual([a, b].uniques(), [a, b]) + } + class Test: NSObject { let id = UUID() let string: String From bb3f353dbc02ac5dedb2cdf64f11f8db0d1241cf Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Thu, 13 Apr 2023 10:04:48 -0400 Subject: [PATCH 06/11] Fix Compose window title not being set initially --- Tusker/Scenes/ComposeSceneDelegate.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Tusker/Scenes/ComposeSceneDelegate.swift b/Tusker/Scenes/ComposeSceneDelegate.swift index c97a36b9..2ae505a5 100644 --- a/Tusker/Scenes/ComposeSceneDelegate.swift +++ b/Tusker/Scenes/ComposeSceneDelegate.swift @@ -62,15 +62,15 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDeleg composeVC.delegate = self let nav = EnhancedNavigationViewController(rootViewController: composeVC) - updateTitle(draft: composeVC.draft) - composeVC.uiState.$draft - .sink { [unowned self] in self.updateTitle(draft: $0) } - .store(in: &cancellables) - window = UIWindow(windowScene: windowScene) window!.rootViewController = nav window!.makeKeyAndVisible() + updateTitle(draft: composeVC.draft) + composeVC.controller.$draft + .sink { [unowned self] in self.updateTitle(draft: $0) } + .store(in: &cancellables) + NotificationCenter.default.addObserver(self, selector: #selector(themePrefChanged), name: .themePreferenceChanged, object: nil) themePrefChanged() } From 350e331eb20f10bf97a88b1390aa42f79ddfe86e Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sun, 16 Apr 2023 13:17:39 -0400 Subject: [PATCH 07/11] Move GIFImageView to TuskerComponents --- .../TuskerComponents}/GIFImageView.swift | 70 +++++++++++++++---- Tusker.xcodeproj/project.pbxproj | 4 -- .../AttachmentPreviewViewController.swift | 1 + .../Compose/ComposeAttachmentImage.swift | 1 + .../Large Image/LargeImageContentView.swift | 1 + .../LoadingLargeImageViewController.swift | 1 + .../LargeImageExpandAnimationController.swift | 1 + .../LargeImageShrinkAnimationController.swift | 1 + Tusker/Views/Attachments/AttachmentView.swift | 1 + 9 files changed, 64 insertions(+), 17 deletions(-) rename {Tusker/Views => Packages/TuskerComponents/Sources/TuskerComponents}/GIFImageView.swift (58%) diff --git a/Tusker/Views/GIFImageView.swift b/Packages/TuskerComponents/Sources/TuskerComponents/GIFImageView.swift similarity index 58% rename from Tusker/Views/GIFImageView.swift rename to Packages/TuskerComponents/Sources/TuskerComponents/GIFImageView.swift index 2b43f816..294e8492 100644 --- a/Tusker/Views/GIFImageView.swift +++ b/Packages/TuskerComponents/Sources/TuskerComponents/GIFImageView.swift @@ -1,6 +1,6 @@ // // GIFImageView.swift -// Tusker +// TuskerComponents // // Created by Shadowfacts on 11/11/21. // Copyright © 2021 Shadowfacts. All rights reserved. @@ -8,36 +8,36 @@ import UIKit -class GIFImageView: UIImageView { +open class GIFImageView: UIImageView { - fileprivate(set) var gifController: GIFController? = nil - var isAnimatingGIF: Bool { gifController?.state == .playing } + public fileprivate(set) var gifController: GIFController? = nil + public var isAnimatingGIF: Bool { gifController?.state == .playing } /// Detaches the current GIF controller from this view. /// If this view is the GIF controller's only one, it will stop itself. - func detachGIFController() { + public func detachGIFController() { gifController?.detach(from: self) } } /// A `GIFController` controls the animation of one or more `GIFImageView`s. -class GIFController { +public class GIFController { // GIFImageView strongly holds the controller so that when the last view detaches, the controller is freed private var imageViews = WeakArray() - private(set) var gifData: Data + public let gifData: Data private(set) var state: State = .stopped - private(set) var lastFrame: (image: UIImage, index: Int)? = nil + public private(set) var lastFrame: (image: UIImage, index: Int)? = nil - init(gifData: Data) { + public init(gifData: Data) { self.gifData = gifData } /// Attaches another view to this controller, letting it play back alongside the others. /// Immediately brings it into sync with the others, setting the last frame if there was one. - func attach(to view: GIFImageView) { + public func attach(to view: GIFImageView) { imageViews.append(view) view.gifController = self @@ -49,13 +49,13 @@ class GIFController { /// Detaches the given view from this controller. /// If no views attached views remain, the last strong reference to this controller is nilled out /// and image animation will stop at the next CGAnimateImageDataWithBlock callback. - func detach(from view: GIFImageView) { + public func detach(from view: GIFImageView) { // todo: does === work the way i want here imageViews.removeAll(where: { $0 === view }) view.gifController = nil } - func startAnimating() { + public func startAnimating() { guard state.shouldStop else { return } state = .playing @@ -74,7 +74,7 @@ class GIFController { } } - func stopAnimating() { + public func stopAnimating() { guard state == .playing else { return } state = .stopping @@ -89,3 +89,47 @@ class GIFController { } } + +private class WeakHolder { + weak var object: T? + + init(_ object: T?) { + self.object = object + } +} + +private struct WeakArray: MutableCollection, RangeReplaceableCollection { + private var array: [WeakHolder] + + var startIndex: Int { array.startIndex } + var endIndex: Int { array.endIndex } + + init() { + array = [] + } + + init(_ elements: [Element]) { + array = elements.map { WeakHolder($0) } + } + + init(_ elements: [Element?]) { + array = elements.map { WeakHolder($0) } + } + + subscript(position: Int) -> Element? { + get { + array[position].object + } + set(newValue) { + array[position] = WeakHolder(newValue) + } + } + + func index(after i: Int) -> Int { + return array.index(after: i) + } + + mutating func replaceSubrange(_ subrange: Range, with newElements: C) where C : Collection, Self.Element == C.Element { + array.replaceSubrange(subrange, with: newElements.map { WeakHolder($0) }) + } +} diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index aa6674c7..376e2314 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -332,7 +332,6 @@ D6D9498D298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */; }; D6D9498F298EB79400C59229 /* CopyableLable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498E298EB79400C59229 /* CopyableLable.swift */; }; D6DD2A3F273C1F4900386A6C /* ComposeAttachmentImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD2A3E273C1F4900386A6C /* ComposeAttachmentImage.swift */; }; - D6DD2A45273D6C5700386A6C /* GIFImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD2A44273D6C5700386A6C /* GIFImageView.swift */; }; D6DD353D22F28CD000A9563A /* ContentWarningCopyMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */; }; D6DD353F22F502EC00A9563A /* Preferences+Notification.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD353E22F502EC00A9563A /* Preferences+Notification.swift */; }; D6DD8FFD298495A8002AD3FD /* LogoutService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD8FFC298495A8002AD3FD /* LogoutService.swift */; }; @@ -758,7 +757,6 @@ D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingStatusCollectionViewCell.swift; sourceTree = ""; }; D6D9498E298EB79400C59229 /* CopyableLable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyableLable.swift; sourceTree = ""; }; D6DD2A3E273C1F4900386A6C /* ComposeAttachmentImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAttachmentImage.swift; sourceTree = ""; }; - D6DD2A44273D6C5700386A6C /* GIFImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GIFImageView.swift; sourceTree = ""; }; D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentWarningCopyMode.swift; sourceTree = ""; }; D6DD353E22F502EC00A9563A /* Preferences+Notification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Preferences+Notification.swift"; sourceTree = ""; }; D6DD8FFC298495A8002AD3FD /* LogoutService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogoutService.swift; sourceTree = ""; }; @@ -1459,7 +1457,6 @@ D61F75B8293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift */, D67C1794266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift */, D6EAE0DA2550CC8A002DB0AC /* FocusableTextField.swift */, - D6DD2A44273D6C5700386A6C /* GIFImageView.swift */, D641C77E213DC78A004B4513 /* InlineTextAttachment.swift */, D620483323D3801D008A63EF /* LinkTextView.swift */, D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */, @@ -2145,7 +2142,6 @@ D6C143DA253510F4007DC240 /* ComposeEmojiTextField.swift in Sources */, D6DD2A3F273C1F4900386A6C /* ComposeAttachmentImage.swift in Sources */, D65B4B6229771A3F00DABDFB /* FetchStatusService.swift in Sources */, - D6DD2A45273D6C5700386A6C /* GIFImageView.swift in Sources */, D61F759029353B4300C0B37F /* FileManager+Size.swift in Sources */, 0427033822B30F5F000D31B6 /* BehaviorPrefsView.swift in Sources */, D6945C2F23AC47C3005C403C /* SavedDataManager.swift in Sources */, diff --git a/Tusker/Screens/Attachment Gallery/AttachmentPreviewViewController.swift b/Tusker/Screens/Attachment Gallery/AttachmentPreviewViewController.swift index bf0f7cc2..92e5f504 100644 --- a/Tusker/Screens/Attachment Gallery/AttachmentPreviewViewController.swift +++ b/Tusker/Screens/Attachment Gallery/AttachmentPreviewViewController.swift @@ -8,6 +8,7 @@ import UIKit import Pachyderm +import TuskerComponents class AttachmentPreviewViewController: UIViewController { diff --git a/Tusker/Screens/Compose/ComposeAttachmentImage.swift b/Tusker/Screens/Compose/ComposeAttachmentImage.swift index a0ffd2c6..dd439d68 100644 --- a/Tusker/Screens/Compose/ComposeAttachmentImage.swift +++ b/Tusker/Screens/Compose/ComposeAttachmentImage.swift @@ -8,6 +8,7 @@ import SwiftUI import Photos +import TuskerComponents struct ComposeAttachmentImage: View { let attachment: CompositionAttachment diff --git a/Tusker/Screens/Large Image/LargeImageContentView.swift b/Tusker/Screens/Large Image/LargeImageContentView.swift index 197d2bc8..f36515ce 100644 --- a/Tusker/Screens/Large Image/LargeImageContentView.swift +++ b/Tusker/Screens/Large Image/LargeImageContentView.swift @@ -10,6 +10,7 @@ import UIKit import Pachyderm @preconcurrency import AVFoundation @preconcurrency import VisionKit +import TuskerComponents protocol LargeImageContentView: UIView { var animationImage: UIImage? { get } diff --git a/Tusker/Screens/Large Image/LoadingLargeImageViewController.swift b/Tusker/Screens/Large Image/LoadingLargeImageViewController.swift index 0aa8397a..86da2597 100644 --- a/Tusker/Screens/Large Image/LoadingLargeImageViewController.swift +++ b/Tusker/Screens/Large Image/LoadingLargeImageViewController.swift @@ -7,6 +7,7 @@ import UIKit import Pachyderm +import TuskerComponents class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableViewController { diff --git a/Tusker/Screens/Large Image/Transitions/LargeImageExpandAnimationController.swift b/Tusker/Screens/Large Image/Transitions/LargeImageExpandAnimationController.swift index f59689c8..20d0a793 100644 --- a/Tusker/Screens/Large Image/Transitions/LargeImageExpandAnimationController.swift +++ b/Tusker/Screens/Large Image/Transitions/LargeImageExpandAnimationController.swift @@ -7,6 +7,7 @@ // import UIKit +import TuskerComponents protocol LargeImageAnimatableViewController: UIViewController { var animationSourceView: UIImageView? { get } diff --git a/Tusker/Screens/Large Image/Transitions/LargeImageShrinkAnimationController.swift b/Tusker/Screens/Large Image/Transitions/LargeImageShrinkAnimationController.swift index dab88290..e7366beb 100644 --- a/Tusker/Screens/Large Image/Transitions/LargeImageShrinkAnimationController.swift +++ b/Tusker/Screens/Large Image/Transitions/LargeImageShrinkAnimationController.swift @@ -7,6 +7,7 @@ // import UIKit +import TuskerComponents class LargeImageShrinkAnimationController: NSObject, UIViewControllerAnimatedTransitioning { diff --git a/Tusker/Views/Attachments/AttachmentView.swift b/Tusker/Views/Attachments/AttachmentView.swift index ec81c0aa..75e727a2 100644 --- a/Tusker/Views/Attachments/AttachmentView.swift +++ b/Tusker/Views/Attachments/AttachmentView.swift @@ -9,6 +9,7 @@ import UIKit import Pachyderm import AVFoundation +import TuskerComponents protocol AttachmentViewDelegate: AnyObject { func attachmentViewGallery(startingAt index: Int) -> GalleryViewController? From 0746e127376ca52e5a3c390a18e11b3044fd86cb Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sun, 16 Apr 2023 13:23:13 -0400 Subject: [PATCH 08/11] Extract compose UI into separate package --- Packages/ComposeUI/.gitignore | 9 + Packages/ComposeUI/Package.resolved | 23 ++ Packages/ComposeUI/Package.swift | 33 ++ Packages/ComposeUI/README.md | 3 + .../Sources/ComposeUI/API/PostService.swift | 139 +++++++ .../Sources/ComposeUI}/CharacterCounter.swift | 11 +- .../Sources/ComposeUI/ComposeInput.swift | 27 ++ .../ComposeUI/ComposeMastodonContext.swift | 26 ++ .../Sources/ComposeUI/ComposeUIConfig.swift | 46 +++ .../Controllers/AttachmentRowController.swift | 162 ++++++++ .../AttachmentsListController.swift | 233 +++++++++++ .../Controllers/AutocompleteController.swift | 83 ++++ .../AutocompleteEmojisController.swift | 196 +++++++++ .../AutocompleteHashtagsController.swift | 124 ++++++ .../AutocompleteMentionsController.swift | 171 ++++++++ .../Controllers/ComposeController.swift | 379 ++++++++++++++++++ .../Controllers/DraftsController.swift | 165 ++++++++ .../Controllers/PlaceholderController.swift | 48 +++ .../Controllers/PollController.swift | 182 +++++++++ .../Controllers/ToolbarController.swift | 160 ++++++++ .../Sources/ComposeUI/FuzzyMatcher.swift | 62 +++ .../Sources/ComposeUI/KeyboardReader.swift | 29 ++ .../ComposeUI/Model/AttachmentData.swift | 278 +++++++++++++ .../Sources/ComposeUI/Model/DismissMode.swift | 12 + .../Sources/ComposeUI/Model/Draft.swift | 177 ++++++++ .../ComposeUI/Model/DraftAttachment.swift | 117 ++++++ .../ComposeUI/Model/DraftsManager.swift | 93 +++++ .../ComposeUI/Model/StatusFormat.swift | 95 +++++ .../ComposeUI/OptionalObservedObject.swift | 33 ++ .../Sources/ComposeUI/PKDrawing+Render.swift | 33 ++ .../ComposeUI/TextViewCaretScrolling.swift | 60 +++ .../ComposeUI/UITextInput+Autocomplete.swift | 183 +++++++++ .../ComposeUI/View+ForwardsCompat.swift | 20 + .../Sources/ComposeUI/ViewController.swift | 29 ++ .../Views/AttachmentDescriptionTextView.swift | 112 ++++++ .../Views/AttachmentThumbnailView.swift | 117 ++++++ .../ComposeUI/Views/AvatarImageView.swift | 42 ++ .../ComposeUI/Views/CurrentAccountView.swift | 35 ++ .../ComposeUI/Views/EmojiTextField.swift | 137 +++++++ .../Sources/ComposeUI/Views/HeaderView.swift | 34 ++ .../ComposeUI/Views/MainTextView.swift | 293 ++++++++++++++ .../ComposeUI/Views/PollOptionView.swift | 75 ++++ .../ComposeUI/Views/ReplyStatusView.swift | 90 +++++ .../ComposeUI/Views/WrappedProgressView.swift | 29 ++ .../ComposeUITests/FuzzyMatcherTests.swift | 25 ++ .../InstanceFeatures/InstanceFeatures.swift | 8 + .../Sources/Pachyderm/Model/Hashtag.swift | 2 +- .../Model/Protocols/AccountProtocol.swift | 3 +- .../Protocols/RelationshipProtocol.swift | 21 + .../Pachyderm/Model/Relationship.swift | 20 +- .../AbbreviatedTimeAgoFormatStyle.swift | 70 ++++ .../TuskerComponents}/AlertWithData.swift | 4 +- Tusker.xcodeproj/project.pbxproj | 29 +- Tusker/API/MastodonController.swift | 50 +++ Tusker/API/PostService.swift | 8 +- .../MastodonCachePersistentStore.swift | 4 +- Tusker/CoreData/RelationshipMO.swift | 10 +- Tusker/Extensions/Date+TimeAgo.swift | 26 +- Tusker/Models/CompositionAttachment.swift | 4 +- Tusker/Models/DraftsManager.swift | 56 +-- Tusker/Models/{Draft.swift => OldDraft.swift} | 20 +- Tusker/Scenes/ComposeSceneDelegate.swift | 11 +- Tusker/Scenes/MainSceneDelegate.swift | 4 +- .../Screens/Compose/ComposeAssetPicker.swift | 3 +- .../Compose/ComposeAttachmentRow.swift | 2 +- .../Compose/ComposeAttachmentsList.swift | 16 +- .../Compose/ComposeHostingController.swift | 26 +- Tusker/Screens/Compose/ComposePollView.swift | 12 +- .../Compose/ComposeReplyContentView.swift | 15 +- Tusker/Screens/Compose/ComposeReplyView.swift | 3 +- Tusker/Screens/Compose/ComposeToolbar.swift | 4 +- Tusker/Screens/Compose/ComposeUIState.swift | 6 +- Tusker/Screens/Compose/ComposeView.swift | 9 +- Tusker/Screens/Compose/DraftsView.swift | 117 +++--- .../Screens/Compose/MainComposeTextView.swift | 2 +- .../Compose/NewComposeHostingController.swift | 223 +++++++++++ ...ountSwitchingContainerViewController.swift | 1 + Tusker/Screens/Main/Duckable+Root.swift | 7 +- .../Main/MainTabBarViewController.swift | 1 + .../Main/TuskerRootViewController.swift | 1 + Tusker/Screens/Utilities/Previewing.swift | 14 +- .../UserActivityHandlingContext.swift | 1 + Tusker/Shortcuts/UserActivityManager.swift | 1 + Tusker/TuskerNavigationDelegate.swift | 17 +- Tusker/Views/StatusContentTextView.swift | 2 +- TuskerUITests/ComposeTests.swift | 44 +- 86 files changed, 5062 insertions(+), 245 deletions(-) create mode 100644 Packages/ComposeUI/.gitignore create mode 100644 Packages/ComposeUI/Package.resolved create mode 100644 Packages/ComposeUI/Package.swift create mode 100644 Packages/ComposeUI/README.md create mode 100644 Packages/ComposeUI/Sources/ComposeUI/API/PostService.swift rename Packages/{Pachyderm/Sources/Pachyderm/Utilities => ComposeUI/Sources/ComposeUI}/CharacterCounter.swift (67%) create mode 100644 Packages/ComposeUI/Sources/ComposeUI/ComposeInput.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/ComposeMastodonContext.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/ComposeUIConfig.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentRowController.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentsListController.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteController.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteEmojisController.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteHashtagsController.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteMentionsController.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Controllers/DraftsController.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Controllers/PlaceholderController.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Controllers/PollController.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Controllers/ToolbarController.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/FuzzyMatcher.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/KeyboardReader.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Model/AttachmentData.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Model/DismissMode.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Model/Draft.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Model/DraftAttachment.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Model/DraftsManager.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Model/StatusFormat.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/OptionalObservedObject.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/PKDrawing+Render.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/TextViewCaretScrolling.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/UITextInput+Autocomplete.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/View+ForwardsCompat.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/ViewController.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentDescriptionTextView.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentThumbnailView.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Views/AvatarImageView.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Views/CurrentAccountView.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Views/EmojiTextField.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Views/HeaderView.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Views/MainTextView.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Views/PollOptionView.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Views/ReplyStatusView.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Views/WrappedProgressView.swift create mode 100644 Packages/ComposeUI/Tests/ComposeUITests/FuzzyMatcherTests.swift create mode 100644 Packages/Pachyderm/Sources/Pachyderm/Model/Protocols/RelationshipProtocol.swift create mode 100644 Packages/TuskerComponents/Sources/TuskerComponents/AbbreviatedTimeAgoFormatStyle.swift rename {Tusker/Views => Packages/TuskerComponents/Sources/TuskerComponents}/AlertWithData.swift (83%) rename Tusker/Models/{Draft.swift => OldDraft.swift} (94%) create mode 100644 Tusker/Screens/Compose/NewComposeHostingController.swift diff --git a/Packages/ComposeUI/.gitignore b/Packages/ComposeUI/.gitignore new file mode 100644 index 00000000..3b298120 --- /dev/null +++ b/Packages/ComposeUI/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/Packages/ComposeUI/Package.resolved b/Packages/ComposeUI/Package.resolved new file mode 100644 index 00000000..944ec2a7 --- /dev/null +++ b/Packages/ComposeUI/Package.resolved @@ -0,0 +1,23 @@ +{ + "pins" : [ + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "025bcb1165deab2e20d4eaba79967ce73013f496", + "version" : "1.2.1" + } + }, + { + "identity" : "swift-url", + "kind" : "remoteSourceControl", + "location" : "https://github.com/karwa/swift-url.git", + "state" : { + "branch" : "main", + "revision" : "6f45f3cd6606f39c3753b302fe30aea980067b30" + } + } + ], + "version" : 2 +} diff --git a/Packages/ComposeUI/Package.swift b/Packages/ComposeUI/Package.swift new file mode 100644 index 00000000..1de4d548 --- /dev/null +++ b/Packages/ComposeUI/Package.swift @@ -0,0 +1,33 @@ +// swift-tools-version: 5.7 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "ComposeUI", + platforms: [ + .iOS(.v15), + ], + products: [ + // Products define the executables and libraries a package produces, and make them visible to other packages. + .library( + name: "ComposeUI", + targets: ["ComposeUI"]), + ], + dependencies: [ + // Dependencies declare other packages that this package depends on. + .package(path: "../Pachyderm"), + .package(path: "../InstanceFeatures"), + .package(path: "../TuskerComponents"), + ], + targets: [ + // Targets are the basic building blocks of a package. A target can define a module or a test suite. + // Targets can depend on other targets in this package, and on products in packages this package depends on. + .target( + name: "ComposeUI", + dependencies: ["Pachyderm", "InstanceFeatures", "TuskerComponents"]), + .testTarget( + name: "ComposeUITests", + dependencies: ["ComposeUI"]), + ] +) diff --git a/Packages/ComposeUI/README.md b/Packages/ComposeUI/README.md new file mode 100644 index 00000000..99e51ad0 --- /dev/null +++ b/Packages/ComposeUI/README.md @@ -0,0 +1,3 @@ +# ComposeUI + +A description of this package. diff --git a/Packages/ComposeUI/Sources/ComposeUI/API/PostService.swift b/Packages/ComposeUI/Sources/ComposeUI/API/PostService.swift new file mode 100644 index 00000000..7d823359 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/API/PostService.swift @@ -0,0 +1,139 @@ +// +// PostService.swift +// Tusker +// +// Created by Shadowfacts on 4/27/22. +// Copyright © 2022 Shadowfacts. All rights reserved. +// + +import Foundation +import Pachyderm +import UniformTypeIdentifiers + +class PostService: ObservableObject { + private let mastodonController: ComposeMastodonContext + private let config: ComposeUIConfig + private let draft: Draft + let totalSteps: Int + + @Published var currentStep = 1 + + init(mastodonController: ComposeMastodonContext, config: ComposeUIConfig, draft: Draft) { + self.mastodonController = mastodonController + self.config = config + self.draft = draft + // 2 steps (request data, then upload) for each attachment + self.totalSteps = 2 + (draft.attachments.count * 2) + } + + @MainActor + func post() async throws { + guard draft.hasContent else { + return + } + + // save before posting, so if a crash occurs during network request, the status won't be lost + DraftsManager.save() + + let uploadedAttachments = try await uploadAttachments() + + let contentWarning = draft.contentWarningEnabled ? draft.contentWarning : nil + let sensitive = contentWarning != nil + + let request = Client.createStatus( + text: textForPosting(), + contentType: config.contentType, + inReplyTo: draft.inReplyToID, + media: uploadedAttachments, + sensitive: sensitive, + spoilerText: contentWarning, + visibility: draft.visibility, + language: nil, + pollOptions: draft.poll?.options.map(\.text), + pollExpiresIn: draft.poll == nil ? nil : Int(draft.poll!.duration), + pollMultiple: draft.poll?.multiple, + localOnly: mastodonController.instanceFeatures.localOnlyPosts ? draft.localOnly : nil + ) + do { + let (_, _) = try await mastodonController.run(request) + currentStep += 1 + + DraftsManager.shared.remove(self.draft) + DraftsManager.save() + } catch let error as Client.Error { + throw Error.posting(error) + } + } + + private func uploadAttachments() async throws -> [Attachment] { + var attachments: [Attachment] = [] + attachments.reserveCapacity(draft.attachments.count) + for (index, attachment) in draft.attachments.enumerated() { + let data: Data + let utType: UTType + do { + (data, utType) = try await getData(for: attachment) + currentStep += 1 + } catch let error as AttachmentData.Error { + throw Error.attachmentData(index: index, cause: error) + } + do { + let uploaded = try await uploadAttachment(data: data, utType: utType, description: attachment.attachmentDescription) + attachments.append(uploaded) + currentStep += 1 + } catch let error as Client.Error { + throw Error.attachmentUpload(index: index, cause: error) + } + } + return attachments + } + + private func getData(for attachment: DraftAttachment) async throws -> (Data, UTType) { + return try await withCheckedThrowingContinuation { continuation in + attachment.data.getData(features: mastodonController.instanceFeatures) { result in + switch result { + case let .success(res): + continuation.resume(returning: res) + case let .failure(error): + continuation.resume(throwing: error) + } + } + } + } + + private func uploadAttachment(data: Data, utType: UTType, description: String?) async throws -> Attachment { + let formAttachment = FormAttachment(mimeType: utType.preferredMIMEType!, data: data, fileName: "file.\(utType.preferredFilenameExtension!)") + let req = Client.upload(attachment: formAttachment, description: description) + return try await mastodonController.run(req).0 + } + + private func textForPosting() -> String { + var text = draft.text + // when using dictation, iOS sometimes leaves a U+FFFC OBJECT REPLACEMENT CHARACTER behind in the text, + // which we want to strip out before actually posting the status + text = text.replacingOccurrences(of: "\u{fffc}", with: "") + + if draft.localOnly && mastodonController.instanceFeatures.needsLocalOnlyEmojiHack { + text += " 👁" + } + + return text + } + + enum Error: Swift.Error, LocalizedError { + case attachmentData(index: Int, cause: AttachmentData.Error) + case attachmentUpload(index: Int, cause: Client.Error) + case posting(Client.Error) + + var localizedDescription: String { + switch self { + case let .attachmentData(index: index, cause: cause): + return "Attachment \(index + 1): \(cause.localizedDescription)" + case let .attachmentUpload(index: index, cause: cause): + return "Attachment \(index + 1): \(cause.localizedDescription)" + case let .posting(error): + return error.localizedDescription + } + } + } +} diff --git a/Packages/Pachyderm/Sources/Pachyderm/Utilities/CharacterCounter.swift b/Packages/ComposeUI/Sources/ComposeUI/CharacterCounter.swift similarity index 67% rename from Packages/Pachyderm/Sources/Pachyderm/Utilities/CharacterCounter.swift rename to Packages/ComposeUI/Sources/ComposeUI/CharacterCounter.swift index 59bf1f25..2e2002cc 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Utilities/CharacterCounter.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/CharacterCounter.swift @@ -1,24 +1,25 @@ // // CharacterCounter.swift -// Pachyderm +// ComposeUI // // Created by Shadowfacts on 9/29/18. // Copyright © 2018 Shadowfacts. All rights reserved. // import Foundation +import InstanceFeatures public struct CharacterCounter { - static let linkDetector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) - static let mention = try! NSRegularExpression(pattern: "(@[a-z0-9_]+)(?:@[a-z0-9\\-\\.]+[a-z0-9]+)?", options: .caseInsensitive) + private static let linkDetector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) + private static let mention = try! NSRegularExpression(pattern: "(@[a-z0-9_]+)(?:@[a-z0-9\\-\\.]+[a-z0-9]+)?", options: .caseInsensitive) - public static func count(text: String, for instance: Instance? = nil) -> Int { + public static func count(text: String, for instanceFeatures: InstanceFeatures) -> Int { let mentionsRemoved = removeMentions(in: text) var count = mentionsRemoved.count for match in linkDetector.matches(in: mentionsRemoved, options: [], range: NSRange(location: 0, length: mentionsRemoved.utf16.count)) { count -= match.range.length - count += instance?.configuration?.statuses.charactersReservedPerURL ?? 23 // default Mastodon link length + count += instanceFeatures.charsReservedPerURL } return count } diff --git a/Packages/ComposeUI/Sources/ComposeUI/ComposeInput.swift b/Packages/ComposeUI/Sources/ComposeUI/ComposeInput.swift new file mode 100644 index 00000000..a5f47aaf --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/ComposeInput.swift @@ -0,0 +1,27 @@ +// +// ComposeInput.swift +// ComposeUI +// +// Created by Shadowfacts on 3/5/23. +// + +import Foundation +import Combine + +protocol ComposeInput: AnyObject, ObservableObject { + var toolbarElements: [ToolbarElement] { get } + + var autocompleteState: AutocompleteState? { get } + var autocompleteStatePublisher: Published.Publisher { get } + + func autocomplete(with string: String) + + func applyFormat(_ format: StatusFormat) + + func beginAutocompletingEmoji() +} + +enum ToolbarElement { + case emojiPicker + case formattingButtons +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/ComposeMastodonContext.swift b/Packages/ComposeUI/Sources/ComposeUI/ComposeMastodonContext.swift new file mode 100644 index 00000000..2eb2482a --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/ComposeMastodonContext.swift @@ -0,0 +1,26 @@ +// +// ComposeMastodonContext.swift +// ComposeUI +// +// Created by Shadowfacts on 3/5/23. +// + +import Foundation +import Pachyderm +import InstanceFeatures +import UserAccounts + +public protocol ComposeMastodonContext { + var accountInfo: UserAccountInfo? { get } + var instanceFeatures: InstanceFeatures { get } + + func run(_ request: Request) async throws -> (Result, Pagination?) + func getCustomEmojis(completion: @escaping ([Emoji]) -> Void) + + @MainActor + func searchCachedAccounts(query: String) -> [AccountProtocol] + @MainActor + func cachedRelationship(for accountID: String) -> RelationshipProtocol? + @MainActor + func searchCachedHashtags(query: String) -> [Hashtag] +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/ComposeUIConfig.swift b/Packages/ComposeUI/Sources/ComposeUI/ComposeUIConfig.swift new file mode 100644 index 00000000..f94f62df --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/ComposeUIConfig.swift @@ -0,0 +1,46 @@ +// +// ComposeUIConfig.swift +// ComposeUI +// +// Created by Shadowfacts on 3/4/23. +// + +import SwiftUI +import Pachyderm +import PhotosUI +import PencilKit + +public struct ComposeUIConfig { + public var backgroundColor = Color(uiColor: .systemBackground) + public var groupedBackgroundColor = Color(uiColor: .systemGroupedBackground) + public var groupedCellBackgroundColor = Color(uiColor: .systemBackground) + public var fillColor = Color(uiColor: .systemFill) + public var avatarStyle = AvatarStyle.roundRect + public var useTwitterKeyboard = false + public var contentType = StatusContentType.plain + public var automaticallySaveDrafts = false + public var requireAttachmentDescriptions = false + + public var dismiss: @MainActor (DismissMode) -> Void = { _ in } + public var presentAssetPicker: ((@MainActor @escaping ([PHPickerResult]) -> Void) -> Void)? + public var presentDrawing: ((PKDrawing, @escaping (PKDrawing) -> Void) -> Void)? + public var userActivityForDraft: ((Draft) -> NSItemProvider?) = { _ in nil } + + public init() { + } +} + +extension ComposeUIConfig { + public enum AvatarStyle: Equatable { + case roundRect, circle + + var cornerRadiusFraction: CGFloat { + switch self { + case .roundRect: + return 0.1 + case .circle: + return 0.5 + } + } + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentRowController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentRowController.swift new file mode 100644 index 00000000..4f09c9d9 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentRowController.swift @@ -0,0 +1,162 @@ +// +// AttachmentRowController.swift +// ComposeUI +// +// Created by Shadowfacts on 3/12/23. +// + +import SwiftUI +import TuskerComponents +import Vision + +class AttachmentRowController: ViewController { + let parent: ComposeController + let attachment: DraftAttachment + + @Published var descriptionMode: DescriptionMode = .allowEntry + @Published var textRecognitionError: Error? + + init(parent: ComposeController, attachment: DraftAttachment) { + self.parent = parent + self.attachment = attachment + } + + var view: some View { + AttachmentView(attachment: attachment) + } + + private func removeAttachment() { + withAnimation { + parent.draft.attachments.removeAll(where: { $0.id == attachment.id }) + } + } + + private func editDrawing() { + guard case .drawing(let drawing) = attachment.data else { + return + } + parent.config.presentDrawing?(drawing) { newDrawing in + self.attachment.data = .drawing(newDrawing) + } + } + + private func recognizeText() { + descriptionMode = .recognizingText + + DispatchQueue.global(qos: .userInitiated).async { + self.attachment.data.getData(features: self.parent.mastodonController.instanceFeatures, skipAllConversion: true) { result in + let data: Data + switch result { + case .success((let d, _)): + data = d + case .failure(let error): + self.descriptionMode = .allowEntry + self.textRecognitionError = error + return + } + + let handler = VNImageRequestHandler(data: data) + let request = VNRecognizeTextRequest { request, error in + DispatchQueue.main.async { + if let results = request.results as? [VNRecognizedTextObservation] { + var text = "" + for observation in results { + let result = observation.topCandidates(1).first! + text.append(result.string) + text.append("\n") + } + self.attachment.attachmentDescription = text + } + self.descriptionMode = .allowEntry + } + } + request.recognitionLevel = .accurate + request.usesLanguageCorrection = true + DispatchQueue.global(qos: .userInitiated).async { + do { + try handler.perform([request]) + } catch let error as NSError where error.code == 1 { + // The perform call throws an error with code 1 if the request is cancelled, which we don't want to show an alert for. + return + } catch { + DispatchQueue.main.async { + self.descriptionMode = .allowEntry + self.textRecognitionError = error + } + } + } + } + } + } + + struct AttachmentView: View { + @ObservedObject private var attachment: DraftAttachment + @EnvironmentObject private var controller: AttachmentRowController + + init(attachment: DraftAttachment) { + self.attachment = attachment + } + + var body: some View { + HStack(alignment: .center, spacing: 4) { + AttachmentThumbnailView(attachment: attachment, fullSize: false) + .frame(width: 80, height: 80) + .cornerRadius(8) + .contextMenu { + if case .drawing(_) = attachment.data { + Button(action: controller.editDrawing) { + Label("Edit Drawing", systemImage: "hand.draw") + } + } else if attachment.data.type == .image { + Button(action: controller.recognizeText) { + Label("Recognize Text", systemImage: "doc.text.viewfinder") + } + } + + Button(role: .destructive, action: controller.removeAttachment) { + Label("Delete", systemImage: "trash") + } + } previewIfAvailable: { + AttachmentThumbnailView(attachment: attachment, fullSize: true) + } + + switch controller.descriptionMode { + case .allowEntry: + AttachmentDescriptionTextView( + text: $attachment.attachmentDescription, + placeholder: Text("Describe for the visually impaired…"), + minHeight: 80 + ) + + case .recognizingText: + ProgressView() + .progressViewStyle(.circular) + } + } + .alertWithData("Text Recognition Failed", data: $controller.textRecognitionError) { _ in + Button("OK") {} + } message: { error in + Text(error.localizedDescription) + } + } + } + +} + +extension AttachmentRowController { + enum DescriptionMode { + case allowEntry, recognizingText + } +} + +private extension View { + @available(iOS, obsoleted: 16.0) + @ViewBuilder + func contextMenu(@ViewBuilder menuItems: () -> M, @ViewBuilder previewIfAvailable preview: () -> P) -> some View { + if #available(iOS 16.0, *) { + self.contextMenu(menuItems: menuItems, preview: preview) + } else { + self.contextMenu(menuItems: menuItems) + } + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentsListController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentsListController.swift new file mode 100644 index 00000000..182df4bf --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentsListController.swift @@ -0,0 +1,233 @@ +// +// AttachmentsListController.swift +// ComposeUI +// +// Created by Shadowfacts on 3/8/23. +// + +import SwiftUI +import PhotosUI +import PencilKit + +class AttachmentsListController: ViewController { + + unowned let parent: ComposeController + var draft: Draft { parent.draft } + + var isValid: Bool { + !requiresAttachmentDescriptions && validAttachmentCombination + } + + private var requiresAttachmentDescriptions: Bool { + if parent.config.requireAttachmentDescriptions { + return draft.attachments.allSatisfy { + !$0.attachmentDescription.isEmpty + } + } else { + return false + } + } + + private var validAttachmentCombination: Bool { + if !parent.mastodonController.instanceFeatures.mastodonAttachmentRestrictions { + return true + } else if draft.attachments.contains(where: { $0.data.type == .video }) && + draft.attachments.count > 1 { + return false + } else if draft.attachments.count > 4 { + return false + } + return true + } + + init(parent: ComposeController) { + self.parent = parent + } + + private var canAddAttachment: Bool { + if parent.mastodonController.instanceFeatures.mastodonAttachmentRestrictions { + return draft.attachments.count < 4 && draft.attachments.allSatisfy { $0.data.type == .image } && draft.poll == nil + } else { + return true + } + } + + private var canAddPoll: Bool { + if parent.mastodonController.instanceFeatures.pollsAndAttachments { + return true + } else { + return draft.attachments.isEmpty + } + } + + var view: some View { + AttachmentsList() + } + + private func moveAttachments(from source: IndexSet, to destination: Int) { + draft.attachments.move(fromOffsets: source, toOffset: destination) + } + + private func deleteAttachments(at indices: IndexSet) { + draft.attachments.remove(atOffsets: indices) + } + + @MainActor + private func insertAttachments(at offset: Int, itemProviders: [NSItemProvider]) async { + for provider in itemProviders where provider.canLoadObject(ofClass: DraftAttachment.self) { + provider.loadObject(ofClass: DraftAttachment.self) { object, error in + guard let attachment = object as? DraftAttachment else { return } + DispatchQueue.main.async { + guard self.canAddAttachment else { return } + self.draft.attachments.append(attachment) + } + } + } + } + + private func addImage() { + parent.config.presentAssetPicker?({ results in + Task { + await self.insertAttachments(at: self.draft.attachments.count, itemProviders: results.map(\.itemProvider)) + } + }) + } + + private func addDrawing() { + parent.config.presentDrawing?(PKDrawing()) { drawing in + self.draft.attachments.append(DraftAttachment(data: .drawing(drawing))) + } + } + + private func togglePoll() { + UIApplication.shared.sendAction(#selector(UIView.resignFirstResponder), to: nil, from: nil, for: nil) + + withAnimation { + draft.poll = draft.poll == nil ? Draft.Poll() : nil + } + } + + struct AttachmentsList: View { + private let cellHeight: CGFloat = 80 + private let cellPadding: CGFloat = 12 + + @EnvironmentObject private var controller: AttachmentsListController + @EnvironmentObject private var draft: Draft + @Environment(\.colorScheme) private var colorScheme + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + + var body: some View { + Group { + attachmentsList + + if controller.parent.config.presentAssetPicker != nil { + addImageButton + .listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2)) + } + + if controller.parent.config.presentDrawing != nil { + addDrawingButton + .listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2)) + } + + togglePollButton + .listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2)) + } + } + + private var attachmentsList: some View { + ForEach(draft.attachments) { attachment in + ControllerView(controller: { AttachmentRowController(parent: controller.parent, attachment: attachment) }) + .listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2)) + .onDrag { + NSItemProvider(object: attachment) + } + } + .onMove(perform: controller.moveAttachments) + .onDelete(perform: controller.deleteAttachments) + .conditionally(controller.canAddAttachment) { + $0.onInsert(of: DraftAttachment.readableTypeIdentifiersForItemProvider, perform: { offset, providers in + Task { + await controller.insertAttachments(at: offset, itemProviders: providers) + } + }) + } + } + + private var addImageButton: some View { + Button(action: controller.addImage) { + Label("Add photo or video", systemImage: colorScheme == .dark ? "photo.fill" : "photo") + } + .disabled(!controller.canAddAttachment) + .foregroundColor(.accentColor) + .frame(height: cellHeight / 2) + } + + private var addDrawingButton: some View { + Button(action: controller.addDrawing) { + Label("Draw something", systemImage: "hand.draw") + } + .disabled(!controller.canAddAttachment) + .foregroundColor(.accentColor) + .frame(height: cellHeight / 2) + } + + private var togglePollButton: some View { + Button(action: controller.togglePoll) { + Label(draft.poll == nil ? "Add a poll" : "Remove poll", systemImage: "chart.bar.doc.horizontal") + } + .disabled(!controller.canAddPoll) + .foregroundColor(.accentColor) + .frame(height: cellHeight / 2) + } + } +} + +fileprivate extension View { + @ViewBuilder + func conditionally(_ condition: Bool, body: (Self) -> some View) -> some View { + if condition { + body(self) + } else { + self + } + } + + @available(iOS, obsoleted: 16.0) + @ViewBuilder + func sheetOrPopover(isPresented: Binding, @ViewBuilder content: @escaping () -> some View) -> some View { + if #available(iOS 16.0, *) { + self.modifier(SheetOrPopover(isPresented: isPresented, view: content)) + } else { + self.popover(isPresented: isPresented, content: content) + } + } + + @available(iOS, obsoleted: 16.0) + @ViewBuilder + func withSheetDetentsIfAvailable() -> some View { + if #available(iOS 16.0, *) { + self + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } else { + self + } + } +} + +@available(iOS 16.0, *) +fileprivate struct SheetOrPopover: ViewModifier { + @Binding var isPresented: Bool + @ViewBuilder let view: () -> V + + @Environment(\.horizontalSizeClass) var sizeClass + + func body(content: Content) -> some View { + if sizeClass == .compact { + content.sheet(isPresented: $isPresented, content: view) + } else { + content.popover(isPresented: $isPresented, content: view) + } + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteController.swift new file mode 100644 index 00000000..c6867d74 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteController.swift @@ -0,0 +1,83 @@ +// +// AutocompleteController.swift +// ComposeUI +// +// Created by Shadowfacts on 3/25/23. +// + +import SwiftUI +import Combine + +class AutocompleteController: ViewController { + + unowned let parent: ComposeController + + @Published var mode: Mode? + + init(parent: ComposeController) { + self.parent = parent + + parent.$currentInput + .compactMap { $0 } + .flatMap { $0.autocompleteStatePublisher } + .map { + switch $0 { + case .mention(_): + return Mode.mention + case .emoji(_): + return Mode.emoji + case .hashtag(_): + return Mode.hashtag + case nil: + return nil + } + } + .assign(to: &$mode) + } + + var view: some View { + AutocompleteView() + } + + struct AutocompleteView: View { + @EnvironmentObject private var parent: ComposeController + @EnvironmentObject private var controller: AutocompleteController + @Environment(\.colorScheme) private var colorScheme: ColorScheme + + var body: some View { + if let mode = controller.mode { + VStack(spacing: 0) { + Divider() + suggestionsView(mode: mode) + } + .background(backgroundColor) + } + } + + @ViewBuilder + private func suggestionsView(mode: Mode) -> some View { + switch mode { + case .mention: + ControllerView(controller: { AutocompleteMentionsController(composeController: parent) }) + case .emoji: + ControllerView(controller: { AutocompleteEmojisController(composeController: parent) }) + case .hashtag: + ControllerView(controller: { AutocompleteHashtagsController(composeController: parent) }) + } + } + + private var backgroundColor: Color { + Color(white: colorScheme == .light ? 0.98 : 0.15) + } + + private var borderColor: Color { + Color(white: colorScheme == .light ? 0.85 : 0.25) + } + } + + enum Mode { + case mention + case emoji + case hashtag + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteEmojisController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteEmojisController.swift new file mode 100644 index 00000000..fba41c9b --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteEmojisController.swift @@ -0,0 +1,196 @@ +// +// AutocompleteEmojisController.swift +// ComposeUI +// +// Created by Shadowfacts on 3/26/23. +// + +import SwiftUI +import Pachyderm +import Combine + +class AutocompleteEmojisController: ViewController { + unowned let composeController: ComposeController + var mastodonController: ComposeMastodonContext { composeController.mastodonController } + + private var stateCancellable: AnyCancellable? + private var searchTask: Task? + + @Published var expanded = false + @Published var emojis: [Emoji] = [] + + var emojisBySection: [String: [Emoji]] { + var values: [String: [Emoji]] = [:] + for emoji in emojis { + let key = emoji.category ?? "" + if !values.keys.contains(key) { + values[key] = [emoji] + } else { + values[key]!.append(emoji) + } + } + return values + } + + init(composeController: ComposeController) { + self.composeController = composeController + + stateCancellable = composeController.$currentInput + .compactMap { $0 } + .flatMap { $0.autocompleteStatePublisher } + .compactMap { + if case .emoji(let s) = $0 { + return s + } else { + return nil + } + } + .removeDuplicates() + .sink { [unowned self] query in + self.searchTask?.cancel() + self.searchTask = Task { + await self.queryChanged(query) + } + } + } + + @MainActor + private func queryChanged(_ query: String) async { + var emojis = await withCheckedContinuation { continuation in + composeController.mastodonController.getCustomEmojis { + continuation.resume(returning: $0) + } + } + guard !Task.isCancelled else { + return + } + + if !query.isEmpty { + emojis = + emojis.map { emoji -> (Emoji, (matched: Bool, score: Int)) in + (emoji, FuzzyMatcher.match(pattern: query, str: emoji.shortcode)) + } + .filter(\.1.matched) + .sorted { $0.1.score > $1.1.score } + .map(\.0) + } + + var shortcodes = Set() + var newEmojis = [Emoji]() + for emoji in emojis where !shortcodes.contains(emoji.shortcode) { + newEmojis.append(emoji) + shortcodes.insert(emoji.shortcode) + } + self.emojis = newEmojis + } + + private func toggleExpanded() { + withAnimation { + expanded.toggle() + } + } + + private func autocomplete(with emoji: Emoji) { + guard let input = composeController.currentInput else { return } + input.autocomplete(with: ":\(emoji.shortcode):") + } + + var view: some View { + AutocompleteEmojisView() + } + + struct AutocompleteEmojisView: View { + @EnvironmentObject private var composeController: ComposeController + @EnvironmentObject private var controller: AutocompleteEmojisController + @ScaledMetric private var emojiSize = 30 + + var body: some View { + // When exapnded, the toggle button should be at the top. When collapsed, it should be centered. + HStack(alignment: controller.expanded ? .top : .center, spacing: 0) { + emojiList + .transition(.move(edge: .bottom)) + + toggleExpandedButton + .padding(.trailing, 8) + .padding(.top, controller.expanded ? 8 : 0) + } + } + + @ViewBuilder + private var emojiList: some View { + if controller.expanded { + verticalGrid + .frame(height: 150) + } else { + horizontalScrollView + } + } + + private var verticalGrid: some View { + ScrollView { + LazyVGrid(columns: [GridItem(.adaptive(minimum: emojiSize), spacing: 4)]) { + ForEach(controller.emojisBySection.keys.sorted(), id: \.self) { section in + Section { + ForEach(controller.emojisBySection[section]!, id: \.shortcode) { emoji in + Button(action: { controller.autocomplete(with: emoji) }) { + composeController.emojiImageView(emoji) + .frame(height: emojiSize) + } + .accessibilityLabel(emoji.shortcode) + } + } header: { + if !section.isEmpty { + VStack(alignment: .leading, spacing: 2) { + Text(section) + .font(.caption) + + Divider() + } + .padding(.top, 4) + } + } + } + } + .padding(.all, 8) + // the spacing between the grid sections doesn't seem to be taken into account by the ScrollView? + .padding(.bottom, CGFloat(controller.emojisBySection.keys.count) * 4) + } + .frame(maxWidth: .infinity) + } + + private var horizontalScrollView: some View { + ScrollView(.horizontal) { + HStack(spacing: 8) { + ForEach(controller.emojis, id: \.shortcode) { emoji in + Button(action: { controller.autocomplete(with: emoji) }) { + HStack(spacing: 4) { + composeController.emojiImageView(emoji) + .frame(height: emojiSize) + Text(verbatim: ":\(emoji.shortcode):") + .foregroundColor(.primary) + } + } + .accessibilityLabel(emoji.shortcode) + .frame(height: emojiSize) + } + .animation(.linear(duration: 0.2), value: controller.emojis) + + Spacer(minLength: emojiSize) + } + .padding(.horizontal, 8) + .frame(height: emojiSize + 16) + } + } + + private var toggleExpandedButton: some View { + Button(action: controller.toggleExpanded) { + Image(systemName: "chevron.down") + .resizable() + .aspectRatio(contentMode: .fit) + .rotationEffect(controller.expanded ? .zero : .degrees(180)) + } + .accessibilityLabel(controller.expanded ? "Collapse" : "Expand") + .frame(width: 20, height: 20) + } + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteHashtagsController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteHashtagsController.swift new file mode 100644 index 00000000..3d964d1e --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteHashtagsController.swift @@ -0,0 +1,124 @@ +// +// AutocompleteHashtagsController.swift +// ComposeUI +// +// Created by Shadowfacts on 4/1/23. +// + +import SwiftUI +import Combine +import Pachyderm + +class AutocompleteHashtagsController: ViewController { + unowned let composeController: ComposeController + var mastodonController: ComposeMastodonContext { composeController.mastodonController } + + private var stateCancellable: AnyCancellable? + private var searchTask: Task? + + @Published var hashtags: [Hashtag] = [] + + init(composeController: ComposeController) { + self.composeController = composeController + + stateCancellable = composeController.$currentInput + .compactMap { $0 } + .flatMap { $0.autocompleteStatePublisher } + .compactMap { + if case .hashtag(let s) = $0 { + return s + } else { + return nil + } + } + .debounce(for: .milliseconds(250), scheduler: DispatchQueue.main) + .sink { [unowned self] query in + self.searchTask?.cancel() + self.searchTask = Task { + await self.queryChanged(query) + } + } + } + + @MainActor + private func queryChanged(_ query: String) async { + guard !query.isEmpty else { + hashtags = [] + return + } + + let localHashtags = mastodonController.searchCachedHashtags(query: query) + + var onlyLocalTagsTask: Task? + if !localHashtags.isEmpty { + onlyLocalTagsTask = Task { + // we only want to do the local-only search if the trends API call takes more than .25sec or it fails + try await Task.sleep(nanoseconds: 250 * NSEC_PER_MSEC) + self.updateHashtags(searchResults: [], trendingTags: [], localHashtags: localHashtags, query: query) + } + } + + async let trendingTags = try? mastodonController.run(Client.getTrendingHashtags()).0 + async let searchResults = try? mastodonController.run(Client.search(query: "#\(query)", types: [.hashtags])).0.hashtags + + let trends = await trendingTags ?? [] + let search = await searchResults ?? [] + + onlyLocalTagsTask?.cancel() + guard !Task.isCancelled else { return } + + updateHashtags(searchResults: search, trendingTags: trends, localHashtags: localHashtags, query: query) + } + + @MainActor + private func updateHashtags(searchResults: [Hashtag], trendingTags: [Hashtag], localHashtags: [Hashtag], query: String) { + var addedHashtags = Set() + var hashtags = [(Hashtag, Int)]() + for group in [searchResults, trendingTags, localHashtags] { + for tag in group where !addedHashtags.contains(tag.name) { + let (matched, score) = FuzzyMatcher.match(pattern: query, str: tag.name) + if matched { + hashtags.append((tag, score)) + addedHashtags.insert(tag.name) + } + } + } + self.hashtags = hashtags + .sorted { $0.1 > $1.1 } + .map(\.0) + } + + private func autocomplete(with hashtag: Hashtag) { + guard let currentInput = composeController.currentInput else { return } + currentInput.autocomplete(with: "#\(hashtag.name)") + } + + var view: some View { + AutocompleteHashtagsView() + } + + struct AutocompleteHashtagsView: View { + @EnvironmentObject private var controller: AutocompleteHashtagsController + + var body: some View { + ScrollView(.horizontal) { + HStack(spacing: 8) { + ForEach(controller.hashtags, id: \.name) { hashtag in + Button(action: { controller.autocomplete(with: hashtag) }) { + Text(verbatim: "#\(hashtag.name)") + .foregroundColor(Color(uiColor: .label)) + } + .frame(height: 30) + .padding(.vertical, 8) + } + + Spacer() + } + .padding(.horizontal, 8) + .animation(.linear(duration: 0.2), value: controller.hashtags) + } + } + } + + +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteMentionsController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteMentionsController.swift new file mode 100644 index 00000000..2e63c5fe --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteMentionsController.swift @@ -0,0 +1,171 @@ +// +// AutocompleteMentionsController.swift +// ComposeUI +// +// Created by Shadowfacts on 3/25/23. +// + +import SwiftUI +import Combine +import Pachyderm + +class AutocompleteMentionsController: ViewController { + + unowned let composeController: ComposeController + var mastodonController: ComposeMastodonContext { composeController.mastodonController } + + private var stateCancellable: AnyCancellable? + + @Published private var accounts: [AnyAccount] = [] + private var searchTask: Task? + + init(composeController: ComposeController) { + self.composeController = composeController + + stateCancellable = composeController.$currentInput + .compactMap { $0 } + .flatMap { $0.autocompleteStatePublisher } + .compactMap { + if case .mention(let s) = $0 { + return s + } else { + return nil + } + } + .debounce(for: .milliseconds(250), scheduler: DispatchQueue.main) + .sink { [unowned self] query in + self.searchTask?.cancel() + self.searchTask = Task { + await self.queryChanged(query) + } + } + } + + @MainActor + private func queryChanged(_ query: String) async { + guard !query.isEmpty else { + accounts = [] + return + } + + let localSearchTask = Task { + // we only want to search locally if the search API call takes more than .25sec or it fails + try await Task.sleep(nanoseconds: 250 * NSEC_PER_MSEC) + + let results = self.mastodonController.searchCachedAccounts(query: query) + try Task.checkCancellation() + + if !results.isEmpty { + self.loadAccounts(results.map { .init(value: $0) }, query: query) + } + } + + let accounts = try? await mastodonController.run(Client.searchForAccount(query: query)).0 + guard let accounts, + !Task.isCancelled else { + return + } + localSearchTask.cancel() + + loadAccounts(accounts.map { .init(value: $0) }, query: query) + } + + @MainActor + private func loadAccounts(_ accounts: [AnyAccount], query: String) { + guard case .mention(query) = composeController.currentInput?.autocompleteState else { + return + } + + // when sorting account suggestions, ignore the domain component of the acct unless the user is typing it themself + let ignoreDomain = !query.contains("@") + + self.accounts = + accounts.map { (account) -> (AnyAccount, (matched: Bool, score: Int)) in + let fuzzyStr = ignoreDomain ? String(account.value.acct.split(separator: "@").first!) : account.value.acct + let res = (account, FuzzyMatcher.match(pattern: query, str: fuzzyStr)) + return res + } + .filter(\.1.matched) + .map { (account, res) -> (AnyAccount, Int) in + // give higher weight to accounts that the user follows or is followed by + var score = res.score + if let relationship = mastodonController.cachedRelationship(for: account.value.id) { + if relationship.following { + score += 3 + } + if relationship.followedBy { + score += 2 + } + } + return (account, score) + } + .sorted { $0.1 > $1.1 } + .map(\.0) + } + + private func autocomplete(with account: AnyAccount) { + guard let input = composeController.currentInput else { + return + } + input.autocomplete(with: "@\(account.value.acct)") + } + + var view: some View { + AutocompleteMentionsView() + } + + struct AutocompleteMentionsView: View { + @EnvironmentObject private var controller: AutocompleteMentionsController + + var body: some View { + ScrollView(.horizontal) { + HStack(spacing: 8) { + ForEach(controller.accounts) { account in + AutocompleteMentionButton(account: account) + } + + Spacer() + } + .padding(.horizontal, 8) + .animation(.linear(duration: 0.2), value: controller.accounts) + } + .onDisappear { + controller.searchTask?.cancel() + } + } + } + + private struct AutocompleteMentionButton: View { + @EnvironmentObject private var controller: AutocompleteMentionsController + let account: AnyAccount + + var body: some View { + Button(action: { controller.autocomplete(with: account) }) { + HStack(spacing: 4) { + AvatarImageView(url: account.value.avatar, size: 30) + + VStack(alignment: .leading) { + controller.composeController.displayNameLabel(account.value, .subheadline, 14) + .foregroundColor(.primary) + + Text(verbatim: "@\(account.value.acct)") + .font(.caption) + .foregroundColor(.primary) + } + } + } + .frame(height: 30) + .padding(.vertical, 8) + } + } +} + +fileprivate struct AnyAccount: Equatable, Identifiable { + let value: any AccountProtocol + + var id: String { value.id } + + static func ==(lhs: AnyAccount, rhs: AnyAccount) -> Bool { + return lhs.value.id == rhs.value.id + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift new file mode 100644 index 00000000..2088c456 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift @@ -0,0 +1,379 @@ +// +// ComposeController.swift +// ComposeUI +// +// Created by Shadowfacts on 3/4/23. +// + +import SwiftUI +import Combine +import Pachyderm + +public final class ComposeController: ViewController { + public typealias FetchAvatar = (URL) async -> UIImage? + public typealias FetchStatus = (String) -> (any StatusProtocol)? + public typealias DisplayNameLabel = (any AccountProtocol, Font.TextStyle, CGFloat) -> AnyView + public typealias ReplyContentView = (any StatusProtocol, @escaping (CGFloat) -> Void) -> AnyView + public typealias EmojiImageView = (Emoji) -> AnyView + + @Published public private(set) var draft: Draft + @Published public var config: ComposeUIConfig + let mastodonController: ComposeMastodonContext + let fetchAvatar: FetchAvatar + let fetchStatus: FetchStatus + let displayNameLabel: DisplayNameLabel + let replyContentView: ReplyContentView + let emojiImageView: EmojiImageView + + @Published public var currentAccount: (any AccountProtocol)? + @Published public var showToolbar = true + + @Published var autocompleteController: AutocompleteController! + @Published var toolbarController: ToolbarController! + @Published var attachmentsListController: AttachmentsListController! + + @Published var contentWarningBecomeFirstResponder = false + @Published var mainComposeTextViewBecomeFirstResponder = false + @Published var currentInput: (any ComposeInput)? = nil + @Published var shouldEmojiAutocompletionBeginExpanded = false + @Published var isShowingSaveDraftSheet = false + @Published var isShowingDraftsList = false + @Published var poster: PostService? + @Published var postError: (any Error)? + + var isPosting: Bool { + poster != nil + } + + var charactersRemaining: Int { + let instanceFeatures = mastodonController.instanceFeatures + let limit = instanceFeatures.maxStatusChars + let cwCount = draft.contentWarningEnabled ? draft.contentWarning.count : 0 + return limit - (cwCount + CharacterCounter.count(text: draft.text, for: instanceFeatures)) + } + + var postButtonEnabled: Bool { + draft.hasContent + && charactersRemaining >= 0 + && !isPosting + && attachmentsListController.isValid + && isPollValid + } + + private var isPollValid: Bool { + draft.poll == nil || draft.poll!.options.allSatisfy { !$0.text.isEmpty } + } + + public init( + draft: Draft, + config: ComposeUIConfig, + mastodonController: ComposeMastodonContext, + fetchAvatar: @escaping FetchAvatar, + fetchStatus: @escaping FetchStatus, + displayNameLabel: @escaping DisplayNameLabel, + replyContentView: @escaping ReplyContentView, + emojiImageView: @escaping EmojiImageView + ) { + self.draft = draft + self.config = config + self.mastodonController = mastodonController + self.fetchAvatar = fetchAvatar + self.fetchStatus = fetchStatus + self.displayNameLabel = displayNameLabel + self.replyContentView = replyContentView + self.emojiImageView = emojiImageView + + self.autocompleteController = AutocompleteController(parent: self) + self.toolbarController = ToolbarController(parent: self) + self.attachmentsListController = AttachmentsListController(parent: self) + } + + public var view: some View { + ComposeView(poster: poster) + .environmentObject(draft) + .environmentObject(mastodonController.instanceFeatures) + } + + public func canPaste(itemProviders: [NSItemProvider]) -> Bool { + guard itemProviders.allSatisfy({ $0.canLoadObject(ofClass: DraftAttachment.self) }) else { + return false + } + if mastodonController.instanceFeatures.mastodonAttachmentRestrictions { + if draft.attachments.allSatisfy({ $0.data.type == .image }) { + // if providers are videos, this technically allows invalid video/image combinations + return itemProviders.count + draft.attachments.count <= 4 + } else { + return false + } + } else { + return true + } + } + + public func paste(itemProviders: [NSItemProvider]) { + for provider in itemProviders where provider.canLoadObject(ofClass: DraftAttachment.self) { + provider.loadObject(ofClass: DraftAttachment.self) { object, error in + guard let attachment = object as? DraftAttachment else { return } + DispatchQueue.main.async { + self.draft.attachments.append(attachment) + } + } + } + } + + @MainActor + func cancel() { + if config.automaticallySaveDrafts { + config.dismiss(.cancel) + } else { + if draft.hasContent { + isShowingSaveDraftSheet = true + } else { + DraftsManager.shared.remove(draft) + config.dismiss(.cancel) + } + } + } + + func postStatus() { + guard !isPosting, + draft.hasContent else { + return + } + + Task { @MainActor in + let poster = PostService(mastodonController: mastodonController, config: config, draft: draft) + self.poster = poster + + // try to resign the first responder, if there is one. + // otherwise, the existence of the poster changes the .disabled modifier which causes the keyboard to hide + // and the first responder to change during a view update, which in turn triggers a bunch of state changes + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + + do { + try await poster.post() + + // wait .25 seconds so the user can see the progress bar has completed + try? await Task.sleep(nanoseconds: 250_000_000) + + config.dismiss(.post) + + // don't unset the poster, so the ui remains disabled while dismissing + + } catch let error as PostService.Error { + self.postError = error + self.poster = nil + } catch { + fatalError("unreachable") + } + } + } + + func showDrafts() { + isShowingDraftsList = true + } + + func selectDraft(_ draft: Draft) { + if !self.draft.hasContent { + DraftsManager.shared.remove(self.draft) + } + DraftsManager.save() + + self.draft = draft + } + + func onDisappear() { + if !draft.hasContent { + DraftsManager.shared.remove(draft) + } + DraftsManager.save() + } + + func toggleContentWarning() { + draft.contentWarningEnabled.toggle() + if draft.contentWarningEnabled { + contentWarningBecomeFirstResponder = true + } + } + + struct ComposeView: View { + @OptionalObservedObject var poster: PostService? + @EnvironmentObject var controller: ComposeController + @EnvironmentObject var draft: Draft + @StateObject private var keyboardReader = KeyboardReader() + @State private var globalFrameOutsideList = CGRect.zero + + init(poster: PostService?) { + self.poster = poster + } + + var config: ComposeUIConfig { + controller.config + } + + var body: some View { + ZStack(alignment: .top) { + // just using .background doesn't work; for some reason it gets inset immediately after the software keyboard is dismissed + config.backgroundColor + .edgesIgnoringSafeArea(.all) + + mainList + + if let poster = poster { + // can't use SwiftUI.ProgressView because there's no UIProgressView.Style.bar equivalent, see FB8587149 + WrappedProgressView(value: poster.currentStep, total: poster.totalSteps) + } + } + .safeAreaInset(edge: .bottom, spacing: 0) { + if controller.showToolbar { + VStack(spacing: 0) { + ControllerView(controller: { controller.autocompleteController }) + .transition(.move(edge: .bottom)) + .animation(.default, value: controller.currentInput?.autocompleteState) + + ControllerView(controller: { controller.toolbarController }) + } + // on iPadOS15, the toolbar ends up below the keyboard's toolbar without this + .padding(.bottom, keyboardInset) + .transition(.move(edge: .bottom)) + } + } + .toolbar { + ToolbarItem(placement: .cancellationAction) { cancelButton } + ToolbarItem(placement: .confirmationAction) { postButton } + } + .background(GeometryReader { proxy in + Color.clear + .preference(key: GlobalFrameOutsideListPrefKey.self, value: proxy.frame(in: .global)) + .onPreferenceChange(GlobalFrameOutsideListPrefKey.self) { newValue in + globalFrameOutsideList = newValue + } + }) + .sheet(isPresented: $controller.isShowingDraftsList) { + ControllerView(controller: { DraftsController(parent: controller, isPresented: $controller.isShowingDraftsList) }) + } + .alertWithData("Error Posting", data: $controller.postError, actions: { _ in + Button("OK") {} + }, message: { error in + Text(error.localizedDescription) + }) + .onDisappear(perform: controller.onDisappear) + .navigationTitle(navTitle) + } + + private var navTitle: String { + if let id = draft.inReplyToID, + let status = controller.fetchStatus(id) { + return "Reply to @\(status.account.acct)" + } else { + return "New Post" + } + } + + private var mainList: some View { + List { + if let id = draft.inReplyToID, + let status = controller.fetchStatus(id) { + ReplyStatusView( + status: status, + rowTopInset: 8, + globalFrameOutsideList: globalFrameOutsideList + ) + .listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8)) + .listRowSeparator(.hidden) + .listRowBackground(config.backgroundColor) + } + + HeaderView() + .listRowInsets(EdgeInsets(top: draft.inReplyToID == nil ? 8 : 4, leading: 8, bottom: 4, trailing: 8)) + .listRowSeparator(.hidden) + .listRowBackground(config.backgroundColor) + + if draft.contentWarningEnabled { + EmojiTextField( + text: $draft.contentWarning, + placeholder: "Write your warning here", + maxLength: nil, + becomeFirstResponder: $controller.contentWarningBecomeFirstResponder, + focusNextView: $controller.mainComposeTextViewBecomeFirstResponder + ) + .listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8)) + .listRowSeparator(.hidden) + .listRowBackground(config.backgroundColor) + } + + MainTextView() + .listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8)) + .listRowSeparator(.hidden) + .listRowBackground(config.backgroundColor) + + if let poll = draft.poll { + ControllerView(controller: { PollController(parent: controller, poll: poll) }) + .listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 8, trailing: 8)) + .listRowSeparator(.hidden) + .listRowBackground(config.backgroundColor) + } + + ControllerView(controller: { controller.attachmentsListController }) + .listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 8, trailing: 8)) + .listRowBackground(config.backgroundColor) + } + .listStyle(.plain) + .scrollDismissesKeyboardInteractivelyIfAvailable() + .disabled(controller.isPosting) + } + + private var cancelButton: some View { + Button(action: controller.cancel) { + Text("Cancel") + // otherwise all Buttons in the nav bar are made semibold + .font(.system(size: 17, weight: .regular)) + } + } + + @ViewBuilder + private var postButton: some View { + if draft.hasContent { + Button(action: controller.postStatus) { + Text("Post") + } + .keyboardShortcut(.return, modifiers: .command) + .disabled(!controller.postButtonEnabled) + } else { + Button(action: controller.showDrafts) { + Text("Drafts") + } + } + } + + @available(iOS, obsoleted: 16.0) + private var keyboardInset: CGFloat { + if #unavailable(iOS 16.0), + UIDevice.current.userInterfaceIdiom == .pad, + keyboardReader.isVisible { + return ToolbarController.height + } else { + return 0 + } + } + } +} + +private extension View { + @available(iOS, obsoleted: 16.0) + @ViewBuilder + func scrollDismissesKeyboardInteractivelyIfAvailable() -> some View { + if #available(iOS 16.0, *) { + self.scrollDismissesKeyboard(.interactively) + } else { + self + } + } +} + +private struct GlobalFrameOutsideListPrefKey: PreferenceKey { + static var defaultValue: CGRect = .zero + static func reduce(value: inout CGRect, nextValue: () -> CGRect) { + value = nextValue() + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/DraftsController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/DraftsController.swift new file mode 100644 index 00000000..8ee4c017 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/DraftsController.swift @@ -0,0 +1,165 @@ +// +// DraftsController.swift +// ComposeUI +// +// Created by Shadowfacts on 3/7/23. +// + +import SwiftUI +import TuskerComponents + +class DraftsController: ViewController { + + unowned let parent: ComposeController + @Binding var isPresented: Bool + + @Published var draftForDifferentReply: Draft? + + init(parent: ComposeController, isPresented: Binding) { + self.parent = parent + self._isPresented = isPresented + } + + var view: some View { + DraftsRepresentable() + } + + func maybeSelectDraft(_ draft: Draft) { + if draft.inReplyToID != parent.draft.inReplyToID, + parent.draft.hasContent { + draftForDifferentReply = draft + } else { + confirmSelectDraft(draft) + } + } + + func cancelSelectingDraft() { + draftForDifferentReply = nil + } + + func confirmSelectDraft(_ draft: Draft) { + parent.selectDraft(draft) + closeDrafts() + } + + func deleteDraft(_ draft: Draft) { + DraftsManager.shared.remove(draft) + } + + func closeDrafts() { + isPresented = false + DraftsManager.save() + } + + struct DraftsRepresentable: UIViewControllerRepresentable { + typealias UIViewControllerType = UIHostingController + + func makeUIViewController(context: Context) -> UIHostingController { + return UIHostingController(rootView: DraftsView()) + } + + func updateUIViewController(_ uiViewController: UIHostingController, context: Context) { + } + } + + struct DraftsView: View { + @EnvironmentObject private var controller: DraftsController + @EnvironmentObject private var currentDraft: Draft + @ObservedObject private var draftsManager = DraftsManager.shared + + private var visibleDrafts: [Draft] { + draftsManager.sorted.filter { + $0.accountID == controller.parent.mastodonController.accountInfo!.id && $0.id != currentDraft.id + } + } + + var body: some View { + NavigationView { + List { + ForEach(visibleDrafts) { draft in + Button(action: { controller.maybeSelectDraft(draft) }) { + DraftRow(draft: draft) + } + .contextMenu { + Button(role: .destructive, action: { controller.deleteDraft(draft) }) { + Label("Delete Draft", systemImage: "trash") + } + } + .ifLet(controller.parent.config.userActivityForDraft(draft), modify: { view, activity in + view.onDrag { activity } + }) + } + .onDelete { indices in + indices.map { visibleDrafts[$0] }.forEach(controller.deleteDraft) + } + } + .listStyle(.plain) + .navigationTitle("Drafts") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { cancelButton } + } + } + .alertWithData("Different Reply", data: $controller.draftForDifferentReply) { draft in + Button(role: .cancel, action: controller.cancelSelectingDraft) { + Text("Cancel") + } + Button(action: { controller.confirmSelectDraft(draft) }) { + Text("Restore Draft") + } + } message: { _ in + Text("The selected draft is a reply to a different post, do you wish to use it?") + } + } + + private var cancelButton: some View { + Button(action: controller.closeDrafts) { + Text("Cancel") + } + } + } +} + +private struct DraftRow: View { + @ObservedObject var draft: Draft + + var body: some View { + HStack { + VStack(alignment: .leading) { + if draft.contentWarningEnabled { + Text(draft.contentWarning) + .font(.body.bold()) + .foregroundColor(.secondary) + } + + Text(draft.text) + .font(.body) + + HStack(spacing: 8) { + ForEach(draft.attachments) { attachment in + AttachmentThumbnailView(attachment: attachment, fullSize: false) + .frame(width: 50, height: 50) + .cornerRadius(5) + } + } + } + + Spacer() + + Text(draft.lastModified.formatted(.abbreviatedTimeAgo)) + .font(.body) + .foregroundColor(.secondary) + } + } +} + +private extension View { + @ViewBuilder + func ifLet(_ value: T?, modify: (Self, T) -> V) -> some View { + if let value { + modify(self, value) + } else { + self + } + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/PlaceholderController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/PlaceholderController.swift new file mode 100644 index 00000000..0b04a5d4 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/PlaceholderController.swift @@ -0,0 +1,48 @@ +// +// PlaceholderController.swift +// ComposeUI +// +// Created by Shadowfacts on 3/6/23. +// + +import SwiftUI + +final class PlaceholderController: ViewController, PlaceholderViewProvider { + + private let placeholderView: PlaceholderView = PlaceholderController.makePlaceholderView() + + static func makePlaceholderView() -> some View { + let components = Calendar.current.dateComponents([.month, .day], from: Date()) + if components.month == 3 && components.day == 14, + Date().formatted(date: .numeric, time: .omitted).starts(with: "3") { + Text("Happy π day!") + } else if components.month == 4 && components.day == 1 { + Text("April Fool's!").rotationEffect(.radians(.pi), anchor: .center) + } else if components.month == 9 && components.day == 5 { + // https://weirder.earth/@noracodes/109276419847254552 + // https://retrocomputing.stackexchange.com/questions/14763/what-warning-was-given-on-attempting-to-post-to-usenet-circa-1990 + Text("This program posts news to thousands of machines throughout the entire populated world. Please be sure you know what you are doing.").italic() + } else if components.month == 9 && components.day == 21 { + Text("Do you remember?") + } else if components.month == 10 && components.day == 31 { + if .random() { + Text("Post something spooky!") + } else { + Text("Any questions?") + } + } else { + Text("What's on your mind?") + } + } + + var view: some View { + placeholderView + } +} + +// exists to provide access to the type alias since the @State property needs it to be explicit +private protocol PlaceholderViewProvider { + associatedtype PlaceholderView: View + @ViewBuilder + static func makePlaceholderView() -> PlaceholderView +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/PollController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/PollController.swift new file mode 100644 index 00000000..c13b8d85 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/PollController.swift @@ -0,0 +1,182 @@ +// +// PollController.swift +// ComposeUI +// +// Created by Shadowfacts on 3/25/23. +// + +import SwiftUI +import TuskerComponents + +class PollController: ViewController { + + unowned let parent: ComposeController + var draft: Draft { parent.draft } + let poll: Draft.Poll + + @Published var duration: Duration + + init(parent: ComposeController, poll: Draft.Poll) { + self.parent = parent + self.poll = poll + self.duration = .fromTimeInterval(poll.duration) ?? .oneDay + } + + var view: some View { + PollView() + .environmentObject(poll) + } + + private func removePoll() { + withAnimation { + draft.poll = nil + } + } + + private func moveOptions(indices: IndexSet, newIndex: Int) { + poll.options.move(fromOffsets: indices, toOffset: newIndex) + } + + private func removeOption(_ option: Draft.Poll.Option) { + poll.options.removeAll(where: { $0.id == option.id }) + } + + private var canAddOption: Bool { + if let max = parent.mastodonController.instanceFeatures.maxPollOptionsCount { + return poll.options.count < max + } else { + return true + } + } + + private func addOption() { + poll.options.append(.init("")) + } + + struct PollView: View { + @EnvironmentObject private var controller: PollController + @EnvironmentObject private var poll: Draft.Poll + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + VStack { + HStack { + Text("Poll") + .font(.headline) + + Spacer() + + Button(action: controller.removePoll) { + Image(systemName: "xmark") + .imageScale(.small) + .padding(4) + } + .accessibilityLabel("Remove poll") + .buttonStyle(.plain) + .accentColor(buttonForegroundColor) + .background(Circle().foregroundColor(buttonBackgroundColor)) + .hoverEffect() + } + + List { + ForEach(poll.options) { option in + PollOptionView(option: option, remove: { controller.removeOption(option) }) + .frame(height: 36) + .listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 0)) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + } + .onMove(perform: controller.moveOptions) + } + .listStyle(.plain) + .scrollDisabledIfAvailable(true) + .frame(height: 44 * CGFloat(poll.options.count)) + + Button(action: controller.addOption) { + Label { + Text("Add Option") + } icon: { + Image(systemName: "plus") + .foregroundColor(.accentColor) + } + } + .buttonStyle(.borderless) + .disabled(!controller.canAddOption) + + HStack { + MenuPicker(selection: $poll.multiple, options: [ + .init(value: true, title: "Allow multiple"), + .init(value: false, title: "Single choice"), + ]) + .frame(maxWidth: .infinity) + + MenuPicker(selection: $controller.duration, options: Duration.allCases.map { + .init(value: $0, title: Duration.formatter.string(from: $0.timeInterval)!) + }) + .frame(maxWidth: .infinity) + } + } + .padding(8) + .background( + backgroundColor + .cornerRadius(10) + ) + .onChange(of: controller.duration) { newValue in + poll.duration = newValue.timeInterval + } + } + + private var backgroundColor: Color { + // in light mode, .secondarySystemBackground has a blue-ish hue, which we don't want + colorScheme == .dark ? controller.parent.config.fillColor : Color(white: 0.95) + } + + private var buttonForegroundColor: Color { + Color(uiColor: .label) + } + + private var buttonBackgroundColor: Color { + Color(white: colorScheme == .dark ? 0.1 : 0.8) + } + } +} + +extension PollController { + enum Duration: Hashable, Equatable, CaseIterable { + case fiveMinutes, thirtyMinutes, oneHour, sixHours, oneDay, threeDays, sevenDays + + static let formatter: DateComponentsFormatter = { + let f = DateComponentsFormatter() + f.maximumUnitCount = 1 + f.unitsStyle = .full + f.allowedUnits = [.weekOfMonth, .day, .hour, .minute] + return f + }() + + static func fromTimeInterval(_ ti: TimeInterval) -> Duration? { + for it in allCases where it.timeInterval == ti { + return it + } + return nil + } + + var timeInterval: TimeInterval { + switch self { + case .fiveMinutes: + return 5 * 60 + case .thirtyMinutes: + return 30 * 60 + case .oneHour: + return 60 * 60 + case .sixHours: + return 6 * 60 * 60 + case .oneDay: + return 24 * 60 * 60 + case .threeDays: + return 3 * 24 * 60 * 60 + case .sevenDays: + return 7 * 24 * 60 * 60 + } + } + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/ToolbarController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ToolbarController.swift new file mode 100644 index 00000000..a5002d3b --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ToolbarController.swift @@ -0,0 +1,160 @@ +// +// ToolbarController.swift +// ComposeUI +// +// Created by Shadowfacts on 3/7/23. +// + +import SwiftUI +import Pachyderm +import TuskerComponents + +class ToolbarController: ViewController { + static let height: CGFloat = 44 + private static let visibilityOptions: [MenuPicker.Option] = Pachyderm.Visibility.allCases.map { vis in + .init(value: vis, title: vis.displayName, subtitle: vis.subtitle, image: UIImage(systemName: vis.unfilledImageName), accessibilityLabel: "Visibility: \(vis.displayName)") + } + + unowned let parent: ComposeController + + @Published var minWidth: CGFloat? + @Published var realWidth: CGFloat? + + init(parent: ComposeController) { + self.parent = parent + } + + var view: some View { + ToolbarView() + } + + func showEmojiPicker() { + guard parent.currentInput?.autocompleteState == nil else { + return + } + parent.shouldEmojiAutocompletionBeginExpanded = true + parent.currentInput?.beginAutocompletingEmoji() + } + + func formatAction(_ format: StatusFormat) -> () -> Void { + { [weak self] in + self?.parent.currentInput?.applyFormat(format) + } + } + + struct ToolbarView: View { + @EnvironmentObject private var draft: Draft + @EnvironmentObject private var controller: ToolbarController + @EnvironmentObject private var composeController: ComposeController + @ScaledMetric(relativeTo: .body) private var imageSize: CGFloat = 22 + + @State private var minWidth: CGFloat? + @State private var realWidth: CGFloat? + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 0) { + cwButton + + MenuPicker(selection: $draft.visibility, options: ToolbarController.visibilityOptions, buttonStyle: .iconOnly) + // the button has a bunch of extra space by default, but combined with what we add it's too much + .padding(.horizontal, -8) + + if composeController.mastodonController.instanceFeatures.localOnlyPosts { + localOnlyPicker + .padding(.horizontal, -8) + } + + if let currentInput = composeController.currentInput, + currentInput.toolbarElements.contains(.emojiPicker) { + customEmojiButton + } + + if let currentInput = composeController.currentInput, + currentInput.toolbarElements.contains(.formattingButtons), + composeController.config.contentType != .plain { + + Spacer() + formatButtons + } + + Spacer() + } + .padding(.horizontal, 16) + .frame(minWidth: minWidth) + .background(GeometryReader { proxy in + Color.clear + .preference(key: ToolbarWidthPrefKey.self, value: proxy.size.width) + .onPreferenceChange(ToolbarWidthPrefKey.self) { width in + realWidth = width + } + }) + } + .scrollDisabledIfAvailable(realWidth ?? 0 <= minWidth ?? 0) + .frame(height: ToolbarController.height) + .frame(maxWidth: .infinity) + .background(.regularMaterial, ignoresSafeAreaEdges: .bottom) + .overlay(alignment: .top) { + Divider() + } + .background(GeometryReader { proxy in + Color.clear + .preference(key: ToolbarWidthPrefKey.self, value: proxy.size.width) + .onPreferenceChange(ToolbarWidthPrefKey.self) { width in + minWidth = width + } + }) + } + + private var cwButton: some View { + Button("CW", action: controller.parent.toggleContentWarning) + .accessibilityLabel(draft.contentWarningEnabled ? "Remove content warning" : "Add content warning") + .padding(5) + .hoverEffect() + } + + private var localOnlyPicker: some View { + let domain = composeController.mastodonController.accountInfo!.instanceURL.host! + return MenuPicker(selection: $draft.localOnly, options: [ + .init(value: true, title: "Local-only", subtitle: "Only \(domain)", image: UIImage(named: "link.broken")), + .init(value: false, title: "Federated", image: UIImage(systemName: "link")), + ], buttonStyle: .iconOnly) + } + + private var customEmojiButton: some View { + Button(action: controller.showEmojiPicker) { + Label("Insert custom emoji", systemImage: "face.smiling") + } + .labelStyle(.iconOnly) + .font(.system(size: imageSize)) + .padding(5) + .hoverEffect() + .transition(.opacity.animation(.linear(duration: 0.2))) + } + + private var formatButtons: some View { + ForEach(StatusFormat.allCases, id: \.rawValue) { format in + Button(action: controller.formatAction(format)) { + if let imageName = format.imageName { + Image(systemName: imageName) + .font(.system(size: imageSize)) + } else if let (str, attrs) = format.title { + let container = try! AttributeContainer(attrs, including: \.uiKit) + Text(AttributedString(str, attributes: container)) + } + } + .accessibilityLabel(format.accessibilityLabel) + .padding(5) + .hoverEffect() + .transition(.opacity.animation(.linear(duration: 0.2))) + } + } + } +} + +private struct ToolbarWidthPrefKey: PreferenceKey { + static var defaultValue: CGFloat? = nil + static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) { + value = nextValue() + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/FuzzyMatcher.swift b/Packages/ComposeUI/Sources/ComposeUI/FuzzyMatcher.swift new file mode 100644 index 00000000..a1344836 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/FuzzyMatcher.swift @@ -0,0 +1,62 @@ +// +// FuzzyMatcher.swift +// ComposeUI +// +// Created by Shadowfacts on 10/10/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import Foundation + +struct FuzzyMatcher { + + private init() {} + + /// Rudimentary string fuzzy matching algorithm. + /// + /// Operates on UTF-8 code points, so attempting to match strings which include characters composed of + /// multiple code points may produce unexpected results. + /// + /// Scoring is as follows: + /// +2 points for every char in `pattern` that occurs in `str` sequentially + /// -2 points for every char in `pattern` that does not occur in `str` sequentially + /// -1 point for every char in `str` skipped between matching chars from the `pattern` + static func match(pattern: String, str: String) -> (matched: Bool, score: Int) { + let pattern = pattern.lowercased() + let str = str.lowercased() + + var patternIndex = pattern.utf8.startIndex + var lastStrMatchIndex: String.UTF8View.Index? + var strIndex = str.utf8.startIndex + + var score = 0 + + while patternIndex < pattern.utf8.endIndex && strIndex < str.utf8.endIndex { + let patternChar = pattern.utf8[patternIndex] + let strChar = str.utf8[strIndex] + if patternChar == strChar { + let distance = str.utf8.distance(from: lastStrMatchIndex ?? str.utf8.startIndex, to: strIndex) + if distance > 1 { + score -= distance - 1 + } + + patternIndex = pattern.utf8.index(after: patternIndex) + lastStrMatchIndex = strIndex + strIndex = str.utf8.index(after: strIndex) + + score += 2 + } else { + strIndex = str.utf8.index(after: strIndex) + + if strIndex >= str.utf8.endIndex { + patternIndex = pattern.utf8.index(after: patternIndex) + strIndex = str.utf8.index(after: lastStrMatchIndex ?? str.utf8.startIndex) + score -= 2 + } + } + } + + return (score > 0, score) + } + +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/KeyboardReader.swift b/Packages/ComposeUI/Sources/ComposeUI/KeyboardReader.swift new file mode 100644 index 00000000..323dfe83 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/KeyboardReader.swift @@ -0,0 +1,29 @@ +// +// KeyboardReader.swift +// ComposeUI +// +// Created by Shadowfacts on 3/7/23. +// + +import UIKit +import Combine + +@available(iOS, obsoleted: 16.0) +class KeyboardReader: ObservableObject { + @Published var isVisible = false + + init() { + NotificationCenter.default.addObserver(self, selector: #selector(willShow), name: UIResponder.keyboardWillShowNotification, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(willHide), name: UIResponder.keyboardWillHideNotification, object: nil) + } + + @objc func willShow(_ notification: Foundation.Notification) { + // when a hardware keyboard is connected, the height is very short, so we don't consider that being "visible" + let endFrame = notification.userInfo![UIResponder.keyboardFrameEndUserInfoKey] as! CGRect + isVisible = endFrame.height > 72 + } + + @objc func willHide() { + isVisible = false + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Model/AttachmentData.swift b/Packages/ComposeUI/Sources/ComposeUI/Model/AttachmentData.swift new file mode 100644 index 00000000..7bd31cd2 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Model/AttachmentData.swift @@ -0,0 +1,278 @@ +// +// AttachmentData.swift +// ComposeUI +// +// Created by Shadowfacts on 1/1/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import UIKit +import Photos +import UniformTypeIdentifiers +import PencilKit +import InstanceFeatures + +enum AttachmentData { + case asset(PHAsset) + case image(Data, originalType: UTType) + case video(URL) + case drawing(PKDrawing) + case gif(Data) + + var type: AttachmentType { + switch self { + case let .asset(asset): + return asset.attachmentType! + case .image(_, originalType: _): + return .image + case .video(_): + return .video + case .drawing(_): + return .image + case .gif(_): + return .image + } + } + + var isAsset: Bool { + switch self { + case .asset(_): + return true + default: + return false + } + } + + var canSaveToDraft: Bool { + switch self { + case .video(_): + return false + default: + return true + } + } + + func getData(features: InstanceFeatures, skipAllConversion: Bool = false, completion: @escaping (Result<(Data, UTType), Error>) -> Void) { + switch self { + case let .image(originalData, originalType): + let data: Data + let type: UTType + switch originalType { + case .png, .jpeg: + data = originalData + type = originalType + default: + let image = UIImage(data: originalData)! + // The quality of 0.8 was chosen completely arbitrarily, it may need to be tuned in the future. + data = image.jpegData(compressionQuality: 0.8)! + type = .jpeg + } + let processed = processImageData(data, type: type, features: features, skipAllConversion: skipAllConversion) + completion(.success(processed)) + case let .asset(asset): + if asset.mediaType == .image { + let options = PHImageRequestOptions() + options.version = .current + options.deliveryMode = .highQualityFormat + options.resizeMode = .none + options.isNetworkAccessAllowed = true + PHImageManager.default().requestImageDataAndOrientation(for: asset, options: options) { (data, dataUTI, orientation, info) in + guard let data = data, let dataUTI = dataUTI else { + completion(.failure(.missingData)) + return + } + let processed = processImageData(data, type: UTType(dataUTI)!, features: features, skipAllConversion: skipAllConversion) + completion(.success(processed)) + } + } else if asset.mediaType == .video { + let options = PHVideoRequestOptions() + options.deliveryMode = .automatic + options.isNetworkAccessAllowed = true + options.version = .current + PHImageManager.default().requestExportSession(forVideo: asset, options: options, exportPreset: AVAssetExportPresetHighestQuality) { (exportSession, info) in + if let exportSession = exportSession { + AttachmentData.exportVideoData(session: exportSession, completion: completion) + } else if let error = info?[PHImageErrorKey] as? Error { + completion(.failure(.videoExport(error))) + } else { + completion(.failure(.noVideoExportSession)) + } + } + } else { + fatalError("assetType must be either image or video") + } + case let .video(url): + let asset = AVURLAsset(url: url) + guard let session = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetHighestQuality) else { + completion(.failure(.noVideoExportSession)) + return + } + AttachmentData.exportVideoData(session: session, completion: completion) + + case let .drawing(drawing): + let image = drawing.imageInLightMode(from: drawing.bounds, scale: 1) + completion(.success((image.pngData()!, .png))) + case let .gif(data): + completion(.success((data, .gif))) + } + } + + private func processImageData(_ data: Data, type: UTType, features: InstanceFeatures, skipAllConversion: Bool) -> (Data, UTType) { + guard !skipAllConversion else { + return (data, type) + } + + var data = data + var type = type + let image = CIImage(data: data)! + let needsColorSpaceConversion = features.needsWideColorGamutHack && image.colorSpace?.name != CGColorSpace.sRGB + + // neither Mastodon nor Pleroma handles HEIC well, so convert to JPEG + // they also do a bad job converting wide color gamut images (they seem to just drop the profile, letting the wide-gamut values be reinterprete as sRGB) + // if that changes in the future, we'll need to pass the InstanceFeatures in here somehow and gate the conversion + if needsColorSpaceConversion || type == .heic { + let context = CIContext() + let colorSpace = needsColorSpaceConversion || image.colorSpace != nil ? CGColorSpace(name: CGColorSpace.sRGB)! : image.colorSpace! + if type == .png { + data = context.pngRepresentation(of: image, format: .ARGB8, colorSpace: colorSpace)! + } else { + data = context.jpegRepresentation(of: image, colorSpace: colorSpace)! + type = .jpeg + } + } + + return (data, type) + } + + private static func exportVideoData(session: AVAssetExportSession, completion: @escaping (Result<(Data, UTType), Error>) -> Void) { + session.outputFileType = .mp4 + session.outputURL = FileManager.default.temporaryDirectory.appendingPathComponent("exported_video_\(UUID())").appendingPathExtension("mp4") + session.exportAsynchronously { + guard session.status == .completed else { + completion(.failure(.videoExport(session.error!))) + return + } + do { + let data = try Data(contentsOf: session.outputURL!) + completion(.success((data, .mpeg4Movie))) + } catch { + completion(.failure(.videoExport(error))) + } + } + } + + enum AttachmentType { + case image, video + } + + enum Error: Swift.Error, LocalizedError { + case missingData + case videoExport(Swift.Error) + case noVideoExportSession + + var localizedDescription: String { + switch self { + case .missingData: + return "Missing Data" + case .videoExport(let error): + return "Exporting video: \(error)" + case .noVideoExportSession: + return "Couldn't create video export session" + } + } + } +} + +extension PHAsset { + var attachmentType: AttachmentData.AttachmentType? { + switch self.mediaType { + case .image: + return .image + case .video: + return .video + default: + return nil + } + } +} + +extension AttachmentData: Codable { + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case let .asset(asset): + try container.encode("asset", forKey: .type) + try container.encode(asset.localIdentifier, forKey: .assetIdentifier) + case let .image(originalData, originalType): + try container.encode("image", forKey: .type) + try container.encode(originalType, forKey: .imageType) + try container.encode(originalData, forKey: .imageData) + case .video(_): + throw EncodingError.invalidValue(self, EncodingError.Context(codingPath: encoder.codingPath, debugDescription: "video CompositionAttachments cannot be encoded")) + case let .drawing(drawing): + try container.encode("drawing", forKey: .type) + let drawingData = drawing.dataRepresentation() + try container.encode(drawingData, forKey: .drawing) + case .gif(_): + throw EncodingError.invalidValue(self, EncodingError.Context(codingPath: encoder.codingPath, debugDescription: "gif CompositionAttachments cannot be encoded")) + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + switch try container.decode(String.self, forKey: .type) { + case "asset": + let identifier = try container.decode(String.self, forKey: .assetIdentifier) + guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [identifier], options: nil).firstObject else { + throw DecodingError.dataCorruptedError(forKey: .assetIdentifier, in: container, debugDescription: "Could not fetch asset with local identifier") + } + self = .asset(asset) + case "image": + let data = try container.decode(Data.self, forKey: .imageData) + if let type = try container.decodeIfPresent(UTType.self, forKey: .imageType) { + self = .image(data, originalType: type) + } else { + guard let image = UIImage(data: data) else { + throw DecodingError.dataCorruptedError(forKey: .imageData, in: container, debugDescription: "CompositionAttachment data could not be decoded into UIImage") + } + let jpegData = image.jpegData(compressionQuality: 1)! + self = .image(jpegData, originalType: .jpeg) + } + case "drawing": + let drawingData = try container.decode(Data.self, forKey: .drawing) + let drawing = try PKDrawing(data: drawingData) + self = .drawing(drawing) + default: + throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "CompositionAttachment type must be one of image, asset, or drawing") + } + } + + enum CodingKeys: CodingKey { + case type + case imageData + case imageType + /// The local identifier of the PHAsset for this attachment + case assetIdentifier + /// The PKDrawing object for this attachment. + case drawing + } +} + +extension AttachmentData: Equatable { + static func ==(lhs: AttachmentData, rhs: AttachmentData) -> Bool { + switch (lhs, rhs) { + case let (.asset(a), .asset(b)): + return a.localIdentifier == b.localIdentifier + case let (.image(a, originalType: aType), .image(b, originalType: bType)): + return a == b && aType == bType + case let (.video(a), .video(b)): + return a == b + case let (.drawing(a), .drawing(b)): + return a == b + default: + return false + } + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Model/DismissMode.swift b/Packages/ComposeUI/Sources/ComposeUI/Model/DismissMode.swift new file mode 100644 index 00000000..cbbeec69 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Model/DismissMode.swift @@ -0,0 +1,12 @@ +// +// DismissMode.swift +// ComposeUI +// +// Created by Shadowfacts on 3/7/23. +// + +import Foundation + +public enum DismissMode { + case cancel, post +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Model/Draft.swift b/Packages/ComposeUI/Sources/ComposeUI/Model/Draft.swift new file mode 100644 index 00000000..0d24852f --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Model/Draft.swift @@ -0,0 +1,177 @@ +// +// Draft.swift +// ComposeUI +// +// Created by Shadowfacts on 8/18/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import Foundation +import Combine +import Pachyderm + +public class Draft: Codable, Identifiable, ObservableObject { + public let id: UUID + var lastModified: Date + + @Published public var accountID: String + @Published public var text: String + @Published public var contentWarningEnabled: Bool + @Published public var contentWarning: String + @Published public var attachments: [DraftAttachment] + @Published public var inReplyToID: String? + @Published public var visibility: Visibility + @Published public var poll: Poll? + @Published public var localOnly: Bool + + var initialText: String + + public var hasContent: Bool { + (!text.isEmpty && text != initialText) || + (contentWarningEnabled && !contentWarning.isEmpty) || + attachments.count > 0 || + poll?.hasContent == true + } + + public init( + accountID: String, + text: String, + contentWarning: String, + inReplyToID: String?, + visibility: Visibility, + localOnly: Bool + ) { + self.id = UUID() + self.lastModified = Date() + + self.accountID = accountID + self.text = text + self.contentWarning = contentWarning + self.contentWarningEnabled = !contentWarning.isEmpty + self.attachments = [] + self.inReplyToID = inReplyToID + self.visibility = visibility + self.localOnly = localOnly + + self.initialText = text + } + + public required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.id = try container.decode(UUID.self, forKey: .id) + self.lastModified = try container.decode(Date.self, forKey: .lastModified) + + self.accountID = try container.decode(String.self, forKey: .accountID) + self.text = try container.decode(String.self, forKey: .text) + self.contentWarningEnabled = try container.decode(Bool.self, forKey: .contentWarningEnabled) + self.contentWarning = try container.decode(String.self, forKey: .contentWarning) + self.attachments = try container.decode([DraftAttachment].self, forKey: .attachments) + self.inReplyToID = try container.decode(String?.self, forKey: .inReplyToID) + self.visibility = try container.decode(Visibility.self, forKey: .visibility) + self.poll = try container.decode(Poll?.self, forKey: .poll) + self.localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly) ?? false + + self.initialText = try container.decode(String.self, forKey: .initialText) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(id, forKey: .id) + try container.encode(lastModified, forKey: .lastModified) + + try container.encode(accountID, forKey: .accountID) + try container.encode(text, forKey: .text) + try container.encode(contentWarningEnabled, forKey: .contentWarningEnabled) + try container.encode(contentWarning, forKey: .contentWarning) + try container.encode(attachments, forKey: .attachments) + try container.encode(inReplyToID, forKey: .inReplyToID) + try container.encode(visibility, forKey: .visibility) + try container.encode(poll, forKey: .poll) + try container.encode(localOnly, forKey: .localOnly) + + try container.encode(initialText, forKey: .initialText) + } +} + +extension Draft: Equatable { + public static func ==(lhs: Draft, rhs: Draft) -> Bool { + return lhs.id == rhs.id + } +} + +extension Draft { + enum CodingKeys: String, CodingKey { + case id + case lastModified + + case accountID + case text + case contentWarningEnabled + case contentWarning + case attachments + case inReplyToID + case visibility + case poll + case localOnly + + case initialText + } +} + +extension Draft { + public class Poll: Codable, ObservableObject { + @Published public var options: [Option] + @Published public var multiple: Bool + @Published public var duration: TimeInterval + + var hasContent: Bool { + options.contains { !$0.text.isEmpty } + } + + public init() { + self.options = [Option(""), Option("")] + self.multiple = false + self.duration = 24 * 60 * 60 // 1 day + } + + public required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.options = try container.decode([Option].self, forKey: .options) + self.multiple = try container.decode(Bool.self, forKey: .multiple) + self.duration = try container.decode(TimeInterval.self, forKey: .duration) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(options, forKey: .options) + try container.encode(multiple, forKey: .multiple) + try container.encode(duration, forKey: .duration) + } + + private enum CodingKeys: String, CodingKey { + case options + case multiple + case duration + } + + public class Option: Identifiable, Codable, ObservableObject { + public let id = UUID() + @Published public var text: String + + init(_ text: String) { + self.text = text + } + + public required init(from decoder: Decoder) throws { + self.text = try decoder.singleValueContainer().decode(String.self) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(text) + } + } + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Model/DraftAttachment.swift b/Packages/ComposeUI/Sources/ComposeUI/Model/DraftAttachment.swift new file mode 100644 index 00000000..7f8de0f2 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Model/DraftAttachment.swift @@ -0,0 +1,117 @@ +// +// DraftAttachment.swift +// ComposeUI +// +// Created by Shadowfacts on 3/14/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import Foundation +import UIKit +import UniformTypeIdentifiers + +public final class DraftAttachment: NSObject, Codable, ObservableObject, Identifiable { + static let typeIdentifier = "space.vaccor.Tusker.composition-attachment" + + public let id: UUID + @Published var data: AttachmentData + @Published var attachmentDescription: String + + init(data: AttachmentData, description: String = "") { + self.id = UUID() + self.data = data + self.attachmentDescription = description + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.id = try container.decode(UUID.self, forKey: .id) + self.data = try container.decode(AttachmentData.self, forKey: .data) + self.attachmentDescription = try container.decode(String.self, forKey: .attachmentDescription) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(id, forKey: .id) + try container.encode(data, forKey: .data) + try container.encode(attachmentDescription, forKey: .attachmentDescription) + } + + static func ==(lhs: DraftAttachment, rhs: DraftAttachment) -> Bool { + return lhs.id == rhs.id + } + + enum CodingKeys: String, CodingKey { + case id + case data + case attachmentDescription + } +} + +private let imageType = UTType.image.identifier +private let mp4Type = UTType.mpeg4Movie.identifier +private let quickTimeType = UTType.quickTimeMovie.identifier +private let dataType = UTType.data.identifier +private let gifType = UTType.gif.identifier + +extension DraftAttachment: NSItemProviderWriting { + public static var writableTypeIdentifiersForItemProvider: [String] { + [typeIdentifier] + } + + public func loadData(withTypeIdentifier typeIdentifier: String, forItemProviderCompletionHandler completionHandler: @escaping (Data?, Error?) -> Void) -> Progress? { + if typeIdentifier == DraftAttachment.typeIdentifier { + do { + completionHandler(try PropertyListEncoder().encode(self), nil) + } catch { + completionHandler(nil, error) + } + } else { + completionHandler(nil, ItemProviderError.incompatibleTypeIdentifier) + } + return nil + } + + enum ItemProviderError: Error { + case incompatibleTypeIdentifier + + var localizedDescription: String { + switch self { + case .incompatibleTypeIdentifier: + return "Cannot provide data for given type" + } + } + } +} + +extension DraftAttachment: NSItemProviderReading { + public static var readableTypeIdentifiersForItemProvider: [String] { + // todo: is there a better way of handling movies than manually adding all possible UTI types? + // just using kUTTypeMovie doesn't work, because we need the actually type in order to get the file extension + // without the file extension, getting the thumbnail and exporting the video for attachment upload fails + [typeIdentifier] + UIImage.readableTypeIdentifiersForItemProvider + [mp4Type, quickTimeType] + NSURL.readableTypeIdentifiersForItemProvider + } + + public static func object(withItemProviderData data: Data, typeIdentifier: String) throws -> DraftAttachment { + if typeIdentifier == DraftAttachment.typeIdentifier { + return try PropertyListDecoder().decode(DraftAttachment.self, from: data) + } else if typeIdentifier == gifType { + return DraftAttachment(data: .gif(data)) + } else if UIImage.readableTypeIdentifiersForItemProvider.contains(typeIdentifier) { + return DraftAttachment(data: .image(data, originalType: UTType(typeIdentifier)!)) + } else if let type = UTType(typeIdentifier), type == .mpeg4Movie || type == .quickTimeMovie { + let temporaryDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + let temporaryFileName = ProcessInfo().globallyUniqueString + let fileExt = type.preferredFilenameExtension! + let temporaryFileURL = temporaryDirectoryURL.appendingPathComponent(temporaryFileName).appendingPathExtension(fileExt) + try data.write(to: temporaryFileURL) + return DraftAttachment(data: .video(temporaryFileURL)) + } else if NSURL.readableTypeIdentifiersForItemProvider.contains(typeIdentifier), let url = try? NSURL.object(withItemProviderData: data, typeIdentifier: typeIdentifier) as URL { + return DraftAttachment(data: .video(url)) + } else { + throw ItemProviderError.incompatibleTypeIdentifier + } + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Model/DraftsManager.swift b/Packages/ComposeUI/Sources/ComposeUI/Model/DraftsManager.swift new file mode 100644 index 00000000..ef46ef08 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Model/DraftsManager.swift @@ -0,0 +1,93 @@ +// +// DraftsManager.swift +// ComposeUI +// +// Created by Shadowfacts on 10/22/18. +// Copyright © 2018 Shadowfacts. All rights reserved. +// + +import Foundation +import Combine + +public class DraftsManager: Codable, ObservableObject { + + public private(set) static var shared: DraftsManager = load() + + private static var appGroupDirectory = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.space.vaccor.Tusker")! + private static var archiveURL = appGroupDirectory.appendingPathComponent("drafts").appendingPathExtension("plist") + + private static let saveQueue = DispatchQueue(label: "DraftsManager", qos: .utility) + + public static func save() { + saveQueue.async { + let encoder = PropertyListEncoder() + let data = try? encoder.encode(shared) + try? data?.write(to: archiveURL, options: .noFileProtection) + } + } + + static func load() -> DraftsManager { + let decoder = PropertyListDecoder() + if let data = try? Data(contentsOf: archiveURL), + let draftsManager = try? decoder.decode(DraftsManager.self, from: data) { + return draftsManager + } + return DraftsManager() + } + + private init() {} + + public required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + if let dict = try? container.decode([UUID: SafeDraft].self, forKey: .drafts) { + self.drafts = dict.compactMapValues { $0.draft } + } else if let array = try? container.decode([SafeDraft].self, forKey: .drafts) { + self.drafts = array.reduce(into: [:], { partialResult, safeDraft in + if let draft = safeDraft.draft { + partialResult[draft.id] = draft + } + }) + } else { + throw DecodingError.dataCorruptedError(forKey: .drafts, in: container, debugDescription: "expected drafts to be a dict or array of drafts") + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(drafts, forKey: .drafts) + } + + @Published private var drafts: [UUID: Draft] = [:] + var sorted: [Draft] { + return drafts.values.sorted(by: { $0.lastModified > $1.lastModified }) + } + + public func add(_ draft: Draft) { + drafts[draft.id] = draft + } + + public func remove(_ draft: Draft) { + drafts.removeValue(forKey: draft.id) + } + + public func getBy(id: UUID) -> Draft? { + return drafts[id] + } + + enum CodingKeys: String, CodingKey { + case drafts + } + + // a container that always succeeds at decoding + // so if a single draft can't be decoded, we don't lose all drafts + struct SafeDraft: Decodable { + let draft: Draft? + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + self.draft = try? container.decode(Draft.self) + } + } + +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Model/StatusFormat.swift b/Packages/ComposeUI/Sources/ComposeUI/Model/StatusFormat.swift new file mode 100644 index 00000000..2357b019 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Model/StatusFormat.swift @@ -0,0 +1,95 @@ +// +// StatusFormat.swift +// ComposeUI +// +// Created by Shadowfacts on 1/12/19. +// Copyright © 2019 Shadowfacts. All rights reserved. +// + +import UIKit +import Pachyderm + +enum StatusFormat: Int, CaseIterable { + case bold, italics, strikethrough, code + + func insertionResult(for contentType: StatusContentType) -> FormatInsertionResult? { + switch contentType { + case .plain: + return nil + case .markdown: + return Markdown.format(self) + case .html: + return HTML.format(self) + } + } + + var imageName: String? { + switch self { + case .italics: + return "italic" + case .bold: + return "bold" + case .strikethrough: + return "strikethrough" + default: + return nil + } + } + + var title: (String, [NSAttributedString.Key: Any])? { + if self == .code { + return ("", [.font: UIFont(name: "Menlo", size: 17)!]) + } else { + return nil + } + } + + var accessibilityLabel: String { + switch self { + case .italics: + return NSLocalizedString("Italics", comment: "italics text format accessibility label") + case .bold: + return NSLocalizedString("Bold", comment: "bold text format accessibility label") + case .strikethrough: + return NSLocalizedString("Strikethrough", comment: "strikethrough text format accessibility label") + case .code: + return NSLocalizedString("Code", comment: "code text format accessibility label") + } + } +} + +typealias FormatInsertionResult = (prefix: String, suffix: String, insertionPoint: Int) + +fileprivate protocol FormatType { + static func format(_ format: StatusFormat) -> FormatInsertionResult +} + +extension StatusFormat { + struct Markdown: FormatType { + static var formats: [StatusFormat: String] = [ + .italics: "_", + .bold: "**", + .strikethrough: "~~", + .code: "`" + ] + + static func format(_ format: StatusFormat) -> FormatInsertionResult { + let str = formats[format]! + return (str, str, str.count) + } + } + + struct HTML: FormatType { + static var tags: [StatusFormat: String] = [ + .italics: "em", + .bold: "strong", + .strikethrough: "del", + .code: "code" + ] + + static func format(_ format: StatusFormat) -> FormatInsertionResult { + let tag = tags[format]! + return ("<\(tag)>", "", tag.count + 2) + } + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/OptionalObservedObject.swift b/Packages/ComposeUI/Sources/ComposeUI/OptionalObservedObject.swift new file mode 100644 index 00000000..fe2e9316 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/OptionalObservedObject.swift @@ -0,0 +1,33 @@ +// +// OptionalObservedObject.swift +// ComposeUI +// +// Created by Shadowfacts on 4/15/23. +// + +import SwiftUI +import Combine + +@propertyWrapper +struct OptionalObservedObject: DynamicProperty { + private class Republisher: ObservableObject { + var cancellable: AnyCancellable? + var wrapped: T? { + didSet { + cancellable?.cancel() + cancellable = wrapped?.objectWillChange + .receive(on: RunLoop.main) + .sink { [unowned self] _ in + self.objectWillChange.send() + } + } + } + } + + @StateObject private var republisher = Republisher() + var wrappedValue: T? + + func update() { + republisher.wrapped = wrappedValue + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/PKDrawing+Render.swift b/Packages/ComposeUI/Sources/ComposeUI/PKDrawing+Render.swift new file mode 100644 index 00000000..f6eb4273 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/PKDrawing+Render.swift @@ -0,0 +1,33 @@ +// +// PKDrawing+Render.swift +// ComposeUI +// +// Created by Shadowfacts on 5/9/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import UIKit +import PencilKit + +extension PKDrawing { + + func imageInLightMode(from rect: CGRect, scale: CGFloat = UIScreen.main.scale) -> UIImage { + let lightTraitCollection = UITraitCollection(userInterfaceStyle: .light) + var drawingImage: UIImage! + lightTraitCollection.performAsCurrent { + drawingImage = self.image(from: rect, scale: scale) + } + + let imageRect = CGRect(origin: .zero, size: rect.size) + let format = UIGraphicsImageRendererFormat() + format.opaque = false + format.scale = scale + let renderer = UIGraphicsImageRenderer(size: rect.size, format: format) + return renderer.image { (context) in + UIColor.white.setFill() + context.fill(imageRect) + drawingImage.draw(in: imageRect) + } + } + +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/TextViewCaretScrolling.swift b/Packages/ComposeUI/Sources/ComposeUI/TextViewCaretScrolling.swift new file mode 100644 index 00000000..aa38ce18 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/TextViewCaretScrolling.swift @@ -0,0 +1,60 @@ +// +// TextViewCaretScrolling.swift +// Tusker +// +// Created by Shadowfacts on 11/11/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import UIKit + +protocol TextViewCaretScrolling: AnyObject { + var caretScrollPositionAnimator: UIViewPropertyAnimator? { get set } +} + +extension TextViewCaretScrolling { + func ensureCursorVisible(textView: UITextView) { + guard textView.isFirstResponder, + let range = textView.selectedTextRange, + let scrollView = findParentScrollView(of: textView) else { + return + } + + // We use a UIViewProperty animator to change the scroll view position so that we can store the currently + // running one on the Coordinator. This allows us to cancel the running one, preventing multiple animations + // from attempting to change the scroll view offset simultaneously, causing it to jitter around. This can + // happen if the user is pressing return and quickly creating many new lines. + + if let existing = caretScrollPositionAnimator { + existing.stopAnimation(true) + } + + let cursorRect = textView.caretRect(for: range.start) + var rectToMakeVisible = textView.convert(cursorRect, to: scrollView) + + // expand the rect to be three times the cursor height centered on the cursor so that there's + // some space between the bottom of the line of text being edited and the top of the keyboard + rectToMakeVisible.origin.y -= cursorRect.height + rectToMakeVisible.size.height *= 3 + + let animator = UIViewPropertyAnimator(duration: 0.2, curve: .easeInOut) { + scrollView.scrollRectToVisible(rectToMakeVisible, animated: false) + } + self.caretScrollPositionAnimator = animator + animator.startAnimation() + } + + private func findParentScrollView(of view: UIView) -> UIScrollView? { + var current: UIView = view + while let superview = current.superview { + if let scrollView = superview as? UIScrollView, + scrollView.isScrollEnabled { + return scrollView + } else { + current = superview + } + } + + return nil + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/UITextInput+Autocomplete.swift b/Packages/ComposeUI/Sources/ComposeUI/UITextInput+Autocomplete.swift new file mode 100644 index 00000000..5e8d5607 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/UITextInput+Autocomplete.swift @@ -0,0 +1,183 @@ +// +// UITextInput+Autocomplete.swift +// ComposeUI +// +// Created by Shadowfacts on 3/5/23. +// + +import UIKit +import SwiftUI + +extension UITextInput { + func autocomplete(with string: String, permittedModes: AutocompleteModes, autocompleteState: inout AutocompleteState?) { + guard let selectedTextRange, + let wholeDocumentRange = self.textRange(from: self.beginningOfDocument, to: self.endOfDocument), + let text = self.text(in: wholeDocumentRange), + let (lastWordStartIndex, _) = findAutocompleteLastWord() else { + return + } + + let distanceToEnd = self.offset(from: selectedTextRange.start, to: self.endOfDocument) + + let selectedRangeStartUTF16 = self.offset(from: self.beginningOfDocument, to: selectedTextRange.start) + let characterBeforeCursorIndex = text.utf16.index(text.startIndex, offsetBy: selectedRangeStartUTF16) + + let insertSpace: Bool + if distanceToEnd > 0 { + let charAfterCursor = text[characterBeforeCursorIndex] + insertSpace = charAfterCursor != " " && charAfterCursor != "\n" + } else { + insertSpace = true + } + let string = insertSpace ? string + " " : string + + let startPosition = self.position(from: self.beginningOfDocument, offset: text.utf16.distance(from: text.startIndex, to: lastWordStartIndex))! + let lastWordRange = self.textRange(from: startPosition, to: selectedTextRange.start)! + replace(lastWordRange, withText: string) + + autocompleteState = updateAutocompleteState(permittedModes: permittedModes) + + // keep the cursor at the same position in the text, immediately after what was inserted + // if we inserted a space, move the cursor 1 farther so it's immediately after the pre-existing space + let insertSpaceOffset = insertSpace ? 0 : 1 + let newCursorPosition = self.position(from: self.endOfDocument, offset: -distanceToEnd + insertSpaceOffset)! + self.selectedTextRange = self.textRange(from: newCursorPosition, to: newCursorPosition) + } + + func updateAutocompleteState(permittedModes: AutocompleteModes) -> AutocompleteState? { + guard let selectedTextRange, + let wholeDocumentRange = self.textRange(from: self.beginningOfDocument, to: self.endOfDocument), + let text = self.text(in: wholeDocumentRange), + !text.isEmpty, + let (lastWordStartIndex, foundFirstAtSign) = findAutocompleteLastWord() else { + return nil + } + + let triggerChars = permittedModes.triggerChars + + if lastWordStartIndex > text.startIndex { + // if the character before the "word" beginning is a valid part of a "word", + // we aren't able to autocomplete + let c = text[text.index(before: lastWordStartIndex)] + if isPermittedForAutocomplete(c) || triggerChars.contains(c) { + return nil + } + } + + let characterBeforeCursorIndex = text.utf16.index(text.startIndex, offsetBy: self.offset(from: self.beginningOfDocument, to: selectedTextRange.start)) + + if lastWordStartIndex >= text.startIndex { + let lastWord = text[lastWordStartIndex.. (index: String.Index, foundFirstAtSign: Bool)? { + guard (self as? UIView)?.isFirstResponder == true, + let selectedTextRange, + selectedTextRange.isEmpty, + let wholeDocumentRange = self.textRange(from: self.beginningOfDocument, to: self.endOfDocument), + let text = self.text(in: wholeDocumentRange), + !text.isEmpty else { + return nil + } + + let selectedRangeStartUTF16 = self.offset(from: self.beginningOfDocument, to: selectedTextRange.start) + let cursorIndex = text.utf16.index(text.startIndex, offsetBy: selectedRangeStartUTF16) + + guard cursorIndex != text.startIndex else { + return nil + } + + var lastWordStartIndex = text.index(before: cursorIndex) + var foundFirstAtSign = false + while true { + let c = text[lastWordStartIndex] + + if !isPermittedForAutocomplete(c) { + if foundFirstAtSign { + if c != "@" { + // move the index forward by 1, so that the first char of the substring is the 1st @ instead of whatever comes before it + lastWordStartIndex = text.index(after: lastWordStartIndex) + } + break + } else { + if c == "@" { + foundFirstAtSign = true + } else if c != "." { + // periods are allowed for domain names in mentions + break + } + } + } + + guard lastWordStartIndex > text.startIndex else { + break + } + + lastWordStartIndex = text.index(before: lastWordStartIndex) + } + + return (lastWordStartIndex, foundFirstAtSign) + } +} + +enum AutocompleteState: Equatable { + case mention(String) + case emoji(String) + case hashtag(String) +} + +struct AutocompleteModes: OptionSet { + static let mentions = AutocompleteModes(rawValue: 1 << 0) + static let hashtags = AutocompleteModes(rawValue: 1 << 2) + static let emojis = AutocompleteModes(rawValue: 1 << 3) + + static let all: AutocompleteModes = [ + .mentions, + .hashtags, + .emojis, + ] + + let rawValue: Int + + var triggerChars: [Character] { + var chars: [Character] = [] + if contains(.mentions) { + chars.append("@") + } + if contains(.hashtags) { + chars.append("#") + } + if contains(.emojis) { + chars.append(":") + } + return chars + } +} + +private func isPermittedForAutocomplete(_ c: Character) -> Bool { + return (c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || (c >= "0" && c <= "9") || c == "_" +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/View+ForwardsCompat.swift b/Packages/ComposeUI/Sources/ComposeUI/View+ForwardsCompat.swift new file mode 100644 index 00000000..418e36b9 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/View+ForwardsCompat.swift @@ -0,0 +1,20 @@ +// +// View+ForwardsCompat.swift +// ComposeUI +// +// Created by Shadowfacts on 3/25/23. +// + +import SwiftUI + +extension View { + @available(iOS, obsoleted: 16.0) + @ViewBuilder + func scrollDisabledIfAvailable(_ disabled: Bool) -> some View { + if #available(iOS 16.0, *) { + self.scrollDisabled(disabled) + } else { + self + } + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/ViewController.swift b/Packages/ComposeUI/Sources/ComposeUI/ViewController.swift new file mode 100644 index 00000000..b08d953e --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/ViewController.swift @@ -0,0 +1,29 @@ +// +// ViewController.swift +// ComposeUI +// +// Created by Shadowfacts on 3/4/23. +// + +import SwiftUI +import Combine + +public protocol ViewController: ObservableObject { + associatedtype ContentView: View + + @ViewBuilder + var view: ContentView { get } +} + +public struct ControllerView: View { + @StateObject private var controller: Controller + + public init(controller: @escaping () -> Controller) { + self._controller = StateObject(wrappedValue: controller()) + } + + public var body: some View { + controller.view + .environmentObject(controller) + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentDescriptionTextView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentDescriptionTextView.swift new file mode 100644 index 00000000..4f7dd68b --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentDescriptionTextView.swift @@ -0,0 +1,112 @@ +// +// AttachmentDescriptionTextView.swift +// ComposeUI +// +// Created by Shadowfacts on 3/12/23. +// + +import SwiftUI + +struct AttachmentDescriptionTextView: View { + @Binding private var text: String + private let placeholder: Text? + private let minHeight: CGFloat + + @State private var height: CGFloat? + + init(text: Binding, placeholder: Text?, minHeight: CGFloat) { + self._text = text + self.placeholder = placeholder + self.minHeight = minHeight + } + + var body: some View { + ZStack(alignment: .topLeading) { + if text.isEmpty, let placeholder { + placeholder + .font(.body) + .foregroundColor(.secondary) + .offset(x: 4, y: 8) + } + + WrappedTextView( + text: $text, + textDidChange: self.textDidChange, + font: .preferredFont(forTextStyle: .body) + ) + .frame(height: height ?? minHeight) + } + } + + private func textDidChange(_ textView: UITextView) { + height = max(minHeight, textView.contentSize.height) + } +} + +private struct WrappedTextView: UIViewRepresentable { + typealias UIViewType = UITextView + + @Binding var text: String + let textDidChange: ((UITextView) -> Void) + let font: UIFont + + @Environment(\.isEnabled) private var isEnabled + + func makeUIView(context: Context) -> UITextView { + let view = UITextView() + view.delegate = context.coordinator + view.backgroundColor = .clear + view.font = font + view.adjustsFontForContentSizeCategory = true + view.textContainer.lineBreakMode = .byWordWrapping + return view + } + + func updateUIView(_ uiView: UITextView, context: Context) { + uiView.text = text + uiView.isEditable = isEnabled + context.coordinator.textView = uiView + context.coordinator.text = $text + context.coordinator.didChange = textDidChange + // wait until the next runloop iteration so that SwiftUI view updates have finished and + // the text view knows its new content size + DispatchQueue.main.async { + self.textDidChange(uiView) + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(text: $text, didChange: textDidChange) + } + + class Coordinator: NSObject, UITextViewDelegate, TextViewCaretScrolling { + weak var textView: UITextView? + var text: Binding + var didChange: (UITextView) -> Void + var caretScrollPositionAnimator: UIViewPropertyAnimator? + + init(text: Binding, didChange: @escaping (UITextView) -> Void) { + self.text = text + self.didChange = didChange + + super.init() + + NotificationCenter.default.addObserver(self, selector: #selector(keyboardDidShow), name: UIResponder.keyboardDidShowNotification, object: nil) + } + + @objc private func keyboardDidShow() { + guard let textView, + textView.isFirstResponder else { + return + } + ensureCursorVisible(textView: textView) + } + + func textViewDidChange(_ textView: UITextView) { + text.wrappedValue = textView.text + didChange(textView) + + ensureCursorVisible(textView: textView) + } + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentThumbnailView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentThumbnailView.swift new file mode 100644 index 00000000..caeaaac9 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentThumbnailView.swift @@ -0,0 +1,117 @@ +// +// AttachmentThumbnailView.swift +// ComposeUI +// +// Created by Shadowfacts on 11/10/21. +// Copyright © 2021 Shadowfacts. All rights reserved. +// + +import SwiftUI +import Photos +import TuskerComponents + +struct AttachmentThumbnailView: View { + let attachment: DraftAttachment + let fullSize: Bool + + @State private var gifData: Data? = nil + @State private var image: UIImage? = nil + @State private var imageContentMode: ContentMode = .fill + @State private var imageBackgroundColor: Color = .black + + @Environment(\.colorScheme) private var colorScheme: ColorScheme + + var body: some View { + if let gifData { + GIFViewWrapper(gifData: gifData) + } else if let image { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: imageContentMode) + .background(imageBackgroundColor) + } else { + Image(systemName: placeholderImageName) + .onAppear(perform: self.loadImage) + } + } + + private var placeholderImageName: String { + switch colorScheme { + case .light: + return "photo" + case .dark: + return "photo.fill" + @unknown default: + return "photo" + } + } + + private func loadImage() { + switch attachment.data { + case let .image(originalData, originalType: _): + self.image = UIImage(data: originalData) + case let .asset(asset): + let size: CGSize + if fullSize { + size = PHImageManagerMaximumSize + } else { + // currently only used as thumbnail in ComposeAttachmentRow + size = CGSize(width: 80, height: 80) + } + let isGIF = PHAssetResource.assetResources(for: asset).contains(where: { $0.uniformTypeIdentifier == UTType.gif.identifier }) + if isGIF { + PHImageManager.default().requestImageDataAndOrientation(for: asset, options: nil) { data, typeIdentifier, orientation, info in + if typeIdentifier == UTType.gif.identifier { + self.gifData = data + } else if let data { + let image = UIImage(data: data) + DispatchQueue.main.async { + self.image = image + } + } + } + } else { + PHImageManager.default().requestImage(for: asset, targetSize: size, contentMode: .aspectFill, options: nil) { (image, _) in + DispatchQueue.main.async { + self.image = image + } + } + } + case let .video(url): + let asset = AVURLAsset(url: url) + let imageGenerator = AVAssetImageGenerator(asset: asset) + if let cgImage = try? imageGenerator.copyCGImage(at: .zero, actualTime: nil) { + self.image = UIImage(cgImage: cgImage) + } + case let .drawing(drawing): + image = drawing.imageInLightMode(from: drawing.bounds) + imageContentMode = .fit + imageBackgroundColor = .white + case let .gif(data): + self.gifData = data + } + } +} + +private struct GIFViewWrapper: UIViewRepresentable { + typealias UIViewType = GIFImageView + + @State private var controller: GIFController + + init(gifData: Data) { + self._controller = State(wrappedValue: GIFController(gifData: gifData)) + } + + func makeUIView(context: Context) -> GIFImageView { + let view = GIFImageView() + controller.attach(to: view) + controller.startAnimating() + view.contentMode = .scaleAspectFit + view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + view.setContentCompressionResistancePriority(.defaultLow, for: .vertical) + return view + } + + func updateUIView(_ uiView: GIFImageView, context: Context) { + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/AvatarImageView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/AvatarImageView.swift new file mode 100644 index 00000000..993e81fe --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/AvatarImageView.swift @@ -0,0 +1,42 @@ +// +// AvatarImageView.swift +// ComposeUI +// +// Created by Shadowfacts on 3/4/23. +// + +import SwiftUI + +struct AvatarImageView: View { + let url: URL? + let size: CGFloat + @State private var image: UIImage? + @EnvironmentObject private var controller: ComposeController + + var body: some View { + imageView + .resizable() + .frame(width: size, height: size) + .cornerRadius(controller.config.avatarStyle.cornerRadiusFraction * size) + .task { + if let url { + image = await controller.fetchAvatar(url) + } + } + // tell swiftui that this view has changed (and therefore the task needs to re-run) when the url changes + .id(url) + + } + + private var imageView: Image { + if let image { + return Image(uiImage: image) + } else { + return placeholder + } + } + + private var placeholder: Image { + Image(systemName: controller.config.avatarStyle == .roundRect ? "person.crop.square" : "person.crop.circle") + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/CurrentAccountView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/CurrentAccountView.swift new file mode 100644 index 00000000..87f6b2af --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/CurrentAccountView.swift @@ -0,0 +1,35 @@ +// +// CurrentAccountView.swift +// ComposeUI +// +// Created by Shadowfacts on 3/4/23. +// + +import SwiftUI +import Pachyderm + +struct CurrentAccountView: View { + let account: (any AccountProtocol)? + @EnvironmentObject private var controller: ComposeController + + var body: some View { + HStack(alignment: .top) { + AvatarImageView(url: account?.avatar, size: 50) + .accessibilityHidden(true) + + if let account { + VStack(alignment: .leading) { + controller.displayNameLabel(account, .title2, 24) + .lineLimit(1) + + Text(verbatim: "@\(account.acct)") + .font(.body.weight(.light)) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + + Spacer() + } + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/EmojiTextField.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/EmojiTextField.swift new file mode 100644 index 00000000..b5a0809c --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/EmojiTextField.swift @@ -0,0 +1,137 @@ +// +// EmojiTextField.swift +// ComposeUI +// +// Created by Shadowfacts on 3/5/23. +// + +import SwiftUI + +struct EmojiTextField: UIViewRepresentable { + typealias UIViewType = UITextField + + @EnvironmentObject private var controller: ComposeController + @Environment(\.colorScheme) private var colorScheme + + @Binding var text: String + let placeholder: String + let maxLength: Int? + let becomeFirstResponder: Binding? + let focusNextView: Binding? + + init(text: Binding, placeholder: String, maxLength: Int?, becomeFirstResponder: Binding? = nil, focusNextView: Binding? = nil) { + self._text = text + self.placeholder = placeholder + self.maxLength = maxLength + self.becomeFirstResponder = becomeFirstResponder + self.focusNextView = focusNextView + } + + func makeUIView(context: Context) -> UITextField { + let view = UITextField() + view.borderStyle = .roundedRect + view.font = .preferredFont(forTextStyle: .body) + view.adjustsFontForContentSizeCategory = true + view.attributedPlaceholder = NSAttributedString(string: placeholder, attributes: [ + .foregroundColor: UIColor.secondaryLabel, + ]) + + context.coordinator.textField = view + + view.delegate = context.coordinator + view.addTarget(context.coordinator, action: #selector(Coordinator.didChange(_:)), for: .editingChanged) + view.addTarget(context.coordinator, action: #selector(Coordinator.returnKeyPressed), for: .primaryActionTriggered) + + // otherwise when the text gets too wide it starts expanding the ComposeView + view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + return view + } + + func updateUIView(_ uiView: UITextField, context: Context) { + if text != uiView.text { + uiView.text = text + } + + context.coordinator.text = $text + context.coordinator.maxLength = maxLength + context.coordinator.focusNextView = focusNextView + + uiView.backgroundColor = colorScheme == .dark ? UIColor(controller.config.fillColor) : .secondarySystemBackground + + if becomeFirstResponder?.wrappedValue == true { + DispatchQueue.main.async { + uiView.becomeFirstResponder() + becomeFirstResponder!.wrappedValue = false + } + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(controller: controller, text: $text, focusNextView: focusNextView) + } + + class Coordinator: NSObject, UITextFieldDelegate, ComposeInput { + let controller: ComposeController + var text: Binding + var focusNextView: Binding? + var maxLength: Int? + + @Published var autocompleteState: AutocompleteState? + var autocompleteStatePublisher: Published.Publisher { $autocompleteState } + + weak var textField: UITextField? + + init(controller: ComposeController, text: Binding, focusNextView: Binding?, maxLength: Int? = nil) { + self.controller = controller + self.text = text + self.focusNextView = focusNextView + self.maxLength = maxLength + } + + @objc func didChange(_ textField: UITextField) { + text.wrappedValue = textField.text ?? "" + } + + @objc func returnKeyPressed() { + focusNextView?.wrappedValue = true + } + + func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { + if let maxLength { + return ((textField.text ?? "") as NSString).replacingCharacters(in: range, with: string).count <= maxLength + } else { + return true + } + } + + func textFieldDidBeginEditing(_ textField: UITextField) { + controller.currentInput = self + autocompleteState = textField.updateAutocompleteState(permittedModes: .emojis) + } + + func textFieldDidEndEditing(_ textField: UITextField) { + controller.currentInput = nil + autocompleteState = textField.updateAutocompleteState(permittedModes: .emojis) + } + + func textFieldDidChangeSelection(_ textField: UITextField) { + autocompleteState = textField.updateAutocompleteState(permittedModes: .emojis) + } + + // MARK: ComposeInput + + var toolbarElements: [ToolbarElement] { [.emojiPicker] } + + func applyFormat(_ format: StatusFormat) { + } + + func beginAutocompletingEmoji() { + textField?.insertText(":") + } + + func autocomplete(with string: String) { + textField?.autocomplete(with: string, permittedModes: .emojis, autocompleteState: &autocompleteState) + } + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/HeaderView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/HeaderView.swift new file mode 100644 index 00000000..9786f606 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/HeaderView.swift @@ -0,0 +1,34 @@ +// +// HeaderView.swift +// ComposeUI +// +// Created by Shadowfacts on 3/4/23. +// + +import SwiftUI +import Pachyderm +import InstanceFeatures + +struct HeaderView: View { + @EnvironmentObject private var controller: ComposeController + @EnvironmentObject private var draft: Draft + @EnvironmentObject private var instanceFeatures: InstanceFeatures + + private var charsRemaining: Int { controller.charactersRemaining } + + var body: some View { + HStack(alignment: .top) { + CurrentAccountView(account: controller.currentAccount) + .accessibilitySortPriority(1) + + Spacer() + + Text(verbatim: charsRemaining.description) + .foregroundColor(charsRemaining < 0 ? .red : .secondary) + .font(Font.body.monospacedDigit()) + .accessibility(label: Text(charsRemaining < 0 ? "\(-charsRemaining) characters too many" : "\(charsRemaining) characters remaining")) + // this should come first, so VO users can back to it from the main compose text view + .accessibilitySortPriority(0) + }.frame(height: 50) + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/MainTextView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/MainTextView.swift new file mode 100644 index 00000000..079584c6 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/MainTextView.swift @@ -0,0 +1,293 @@ +// +// MainTextView.swift +// ComposeUI +// +// Created by Shadowfacts on 3/6/23. +// + +import SwiftUI + +struct MainTextView: View { + @EnvironmentObject private var controller: ComposeController + @EnvironmentObject private var draft: Draft + @Environment(\.colorScheme) private var colorScheme + @ScaledMetric private var fontSize = 20 + + @State private var hasFirstAppeared = false + @State private var height: CGFloat? + private let minHeight: CGFloat = 150 + private var effectiveHeight: CGFloat { height ?? minHeight } + + var config: ComposeUIConfig { + controller.config + } + + var body: some View { + ZStack(alignment: .topLeading) { + colorScheme == .dark ? config.fillColor : Color(uiColor: .secondarySystemBackground) + + if draft.text.isEmpty { + ControllerView(controller: { PlaceholderController() }) + .font(.system(size: fontSize)) + .foregroundColor(.secondary) + .offset(x: 4, y: 8) + .accessibilityHidden(true) + } + + MainWrappedTextViewRepresentable(text: $draft.text, becomeFirstResponder: $controller.mainComposeTextViewBecomeFirstResponder, textDidChange: textDidChange) + } + .frame(height: effectiveHeight) + .onAppear(perform: becomeFirstResponderOnFirstAppearance) + } + + private func becomeFirstResponderOnFirstAppearance() { + if !hasFirstAppeared { + hasFirstAppeared = true + controller.mainComposeTextViewBecomeFirstResponder = true + } + } + + private func textDidChange(textView: UITextView) { + height = max(textView.contentSize.height, minHeight) + } +} + +fileprivate struct MainWrappedTextViewRepresentable: UIViewRepresentable { + typealias UIViewType = UITextView + + @Binding var text: String + @Binding var becomeFirstResponder: Bool + let textDidChange: (UITextView) -> Void + + @EnvironmentObject private var controller: ComposeController + @Environment(\.isEnabled) private var isEnabled: Bool + + func makeUIView(context: Context) -> UITextView { + let textView = WrappedTextView(composeController: controller) + context.coordinator.textView = textView + textView.delegate = context.coordinator + textView.isEditable = true + textView.backgroundColor = .clear + textView.font = UIFontMetrics.default.scaledFont(for: .systemFont(ofSize: 20)) + textView.adjustsFontForContentSizeCategory = true + textView.textContainer.lineBreakMode = .byWordWrapping + return textView + } + + func updateUIView(_ uiView: UITextView, context: Context) { + if text != uiView.text { + context.coordinator.skipNextSelectionChangedAutocompleteUpdate = true + uiView.text = text + } + + uiView.isEditable = isEnabled + uiView.keyboardType = controller.config.useTwitterKeyboard ? .twitter : .default + + context.coordinator.text = $text + + // wait until the next runloop iteration so that SwiftUI view updates have finished and + // the text view knows its new content size + DispatchQueue.main.async { + textDidChange(uiView) + + if becomeFirstResponder { + // calling becomeFirstResponder during the SwiftUI update causes a crash on iOS 13 + uiView.becomeFirstResponder() + // can't update @State vars during the SwiftUI update + becomeFirstResponder = false + } + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(controller: controller, text: $text, textDidChange: textDidChange) + } + + class WrappedTextView: UITextView { + private let formattingActions = [#selector(toggleBoldface(_:)), #selector(toggleItalics(_:))] + private let composeController: ComposeController + + init(composeController: ComposeController) { + self.composeController = composeController + super.init(frame: .zero, textContainer: nil) + } + + required init?(coder: NSCoder) { + fatalError() + } + + override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { + if formattingActions.contains(action) { + return composeController.config.contentType != .plain + } + return super.canPerformAction(action, withSender: sender) + } + + override func toggleBoldface(_ sender: Any?) { + (delegate as! Coordinator).applyFormat(.bold) + } + + override func toggleItalics(_ sender: Any?) { + (delegate as! Coordinator).applyFormat(.italics) + } + + override func validate(_ command: UICommand) { + super.validate(command) + + if formattingActions.contains(command.action), + composeController.config.contentType != .plain { + command.attributes.remove(.disabled) + } + } + + override func paste(_ sender: Any?) { + // we deliberately exclude the other CompositionAttachment readable type identifiers, because that's too overzealous with the conversion + // and things like URLs end up pasting as attachments + if UIPasteboard.general.contains(pasteboardTypes: UIImage.readableTypeIdentifiersForItemProvider) { + composeController.paste(itemProviders: UIPasteboard.general.itemProviders) + } else { + super.paste(sender) + } + } + } + + class Coordinator: NSObject, UITextViewDelegate, ComposeInput, TextViewCaretScrolling { + weak var textView: UITextView? + + let controller: ComposeController + var text: Binding + let textDidChange: (UITextView) -> Void + + var caretScrollPositionAnimator: UIViewPropertyAnimator? + + @Published var autocompleteState: AutocompleteState? + var autocompleteStatePublisher: Published.Publisher { $autocompleteState } + var skipNextSelectionChangedAutocompleteUpdate = false + + init(controller: ComposeController, text: Binding, textDidChange: @escaping (UITextView) -> Void) { + self.controller = controller + self.text = text + self.textDidChange = textDidChange + + super.init() + + NotificationCenter.default.addObserver(self, selector: #selector(keyboardDidShow), name: UIResponder.keyboardDidShowNotification, object: nil) + } + + @objc private func keyboardDidShow() { + guard let textView, + textView.isFirstResponder else { + return + } + ensureCursorVisible(textView: textView) + } + + // MARK: UITextViewDelegate + + func textViewDidChange(_ textView: UITextView) { + text.wrappedValue = textView.text + textDidChange(textView) + + ensureCursorVisible(textView: textView) + } + + func textViewDidBeginEditing(_ textView: UITextView) { + controller.currentInput = self + updateAutocompleteState() + } + + func textViewDidEndEditing(_ textView: UITextView) { + controller.currentInput = nil + updateAutocompleteState() + } + + func textViewDidChangeSelection(_ textView: UITextView) { + if skipNextSelectionChangedAutocompleteUpdate { + skipNextSelectionChangedAutocompleteUpdate = false + } else { + updateAutocompleteState() + } + } + + func textView(_ textView: UITextView, editMenuForTextIn range: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? { + var actions = suggestedActions + if controller.config.contentType != .plain, + let index = suggestedActions.firstIndex(where: { ($0 as? UIMenu)?.identifier.rawValue == "com.apple.menu.format" }) { + if range.length > 0 { + let formatMenu = suggestedActions[index] as! UIMenu + let newFormatMenu = formatMenu.replacingChildren(StatusFormat.allCases.map { fmt in + var image: UIImage? + if let imageName = fmt.imageName { + image = UIImage(systemName: imageName) + } + return UIAction(title: fmt.accessibilityLabel, image: image) { [weak self] _ in + self?.applyFormat(fmt) + } + }) + actions[index] = newFormatMenu + } else { + actions.remove(at: index) + } + } + if range.length == 0 { + actions.append(UIAction(title: "Insert Emoji", image: UIImage(systemName: "face.smiling"), handler: { [weak self] _ in + self?.controller.shouldEmojiAutocompletionBeginExpanded = true + self?.beginAutocompletingEmoji() + })) + } + return UIMenu(children: actions) + } + + // MARK: ComposeInput + + var toolbarElements: [ToolbarElement] { + [.emojiPicker, .formattingButtons] + } + + func autocomplete(with string: String) { + textView?.autocomplete(with: string, permittedModes: .all, autocompleteState: &autocompleteState) + } + + func applyFormat(_ format: StatusFormat) { + guard let textView, + textView.isFirstResponder, + let insertionResult = format.insertionResult(for: controller.config.contentType) else { + return + } + + let currentSelectedRange = textView.selectedRange + if currentSelectedRange.length == 0 { + textView.insertText(insertionResult.prefix + insertionResult.suffix) + textView.selectedRange = NSRange(location: currentSelectedRange.location + insertionResult.insertionPoint, length: 0) + } else { + let start = textView.text.utf16.index(textView.text.utf16.startIndex, offsetBy: currentSelectedRange.lowerBound) + let end = textView.text.utf16.index(textView.text.utf16.startIndex, offsetBy: currentSelectedRange.upperBound) + let selectedText = textView.text.utf16[start.. 0 { + let characterBeforeCursorIndex = text.utf16.index(before: text.utf16.index(text.startIndex, offsetBy: textView.selectedRange.upperBound)) + insertSpace = !text[characterBeforeCursorIndex].isWhitespace + } + textView.insertText((insertSpace ? " " : "") + ":") + } + + private func updateAutocompleteState() { + guard let textView else { + autocompleteState = nil + return + } + autocompleteState = textView.updateAutocompleteState(permittedModes: .all) + } + + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/PollOptionView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/PollOptionView.swift new file mode 100644 index 00000000..10e4b198 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/PollOptionView.swift @@ -0,0 +1,75 @@ +// +// PollOptionView.swift +// ComposeUI +// +// Created by Shadowfacts on 3/25/23. +// + +import SwiftUI + +struct PollOptionView: View { + @EnvironmentObject private var controller: PollController + @EnvironmentObject private var poll: Draft.Poll + @ObservedObject private var option: Draft.Poll.Option + let remove: () -> Void + + init(option: Draft.Poll.Option, remove: @escaping () -> Void) { + self.option = option + self.remove = remove + } + + private var optionIndex: Int { + poll.options.firstIndex(where: { $0.id == option.id }) ?? 0 + } + + var body: some View { + HStack(spacing: 4) { + Checkbox(radiusFraction: poll.multiple ? 0.1 : 0.5, background: controller.parent.config.backgroundColor) + .animation(.default, value: poll.multiple) + + textField + + Button(action: remove) { + Image(systemName: "minus.circle.fill") + } + .accessibilityLabel("Remove option") + .buttonStyle(.plain) + .foregroundColor(poll.options.count == 1 ? .gray : .red) + .disabled(poll.options.count == 1) + .hoverEffect() + } + } + + private var textField: some View { + let placeholder = "Option \(optionIndex + 1)" + let maxLength = controller.parent.mastodonController.instanceFeatures.maxPollOptionChars + return EmojiTextField(text: $option.text, placeholder: placeholder, maxLength: maxLength) + } + + struct Checkbox: View { + private let radiusFraction: CGFloat + private let size: CGFloat = 20 + private let innerSize: CGFloat + private let background: Color + + init(radiusFraction: CGFloat, background: Color) { + self.radiusFraction = radiusFraction + self.innerSize = self.size - 4 + self.background = background + } + + var body: some View { + ZStack { + Rectangle() + .foregroundColor(.gray) + .frame(width: size, height: size) + .cornerRadius(radiusFraction * size) + + Rectangle() + .foregroundColor(background) + .frame(width: innerSize, height: innerSize) + .cornerRadius(radiusFraction * innerSize) + } + } + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/ReplyStatusView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/ReplyStatusView.swift new file mode 100644 index 00000000..bf0e3b58 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/ReplyStatusView.swift @@ -0,0 +1,90 @@ +// +// ReplyStatusView.swift +// ComposeUI +// +// Created by Shadowfacts on 3/25/23. +// + +import SwiftUI +import Pachyderm + +struct ReplyStatusView: View { + let status: any StatusProtocol + let rowTopInset: CGFloat + let globalFrameOutsideList: CGRect + + @EnvironmentObject private var controller: ComposeController + @State private var displayNameHeight: CGFloat? + @State private var contentHeight: CGFloat? + + private let horizSpacing: CGFloat = 8 + + var body: some View { + HStack(alignment: .top, spacing: horizSpacing) { + GeometryReader(content: self.replyAvatarImage) + .frame(width: 50) + + VStack(alignment: .leading, spacing: 0) { + HStack { + controller.displayNameLabel(status.account, .body, 17) + .lineLimit(1) + .layoutPriority(1) + + Text(verbatim: "@\(status.account.acct)") + .font(.body.weight(.light)) + .foregroundColor(.secondary) + .lineLimit(1) + + Spacer() + } + .background(GeometryReader { proxy in + Color.clear + .preference(key: DisplayNameHeightPrefKey.self, value: proxy.size.height) + .onPreferenceChange(DisplayNameHeightPrefKey.self) { newValue in + displayNameHeight = newValue + } + }) + + controller.replyContentView(status) { newHeight in + // otherwise, with long in-reply-to statuses, the main content text view position seems not to update + // and it ends up partially behind the header + DispatchQueue.main.async { + contentHeight = newHeight + } + } + .frame(height: contentHeight ?? 0) + } + } + .frame(minHeight: 50, alignment: .top) + } + + private func replyAvatarImage(geometry: GeometryProxy) -> some View { + // using a coordinate space declared outside of the List doesn't work, so we do the math ourselves + let globalFrame = geometry.frame(in: .global) + let scrollOffset = -(globalFrame.minY - globalFrameOutsideList.minY) + + // add rowTopInset so that the image is always at least rowTopInset away from the top + var offset = scrollOffset + rowTopInset + + // offset can never be less than 0 (i.e., above the top of the in-reply-to content) + offset = max(offset, 0) + + // subtract 50, because we care about where the bottom of the view is but the offset is relative to the top of the view + let maxOffset = max((contentHeight ?? 0) + (displayNameHeight ?? 0) - 50, 0) + + // once you scroll past the in-reply-to-content, the bottom of the avatar should be pinned to the bottom of the content + offset = min(offset, maxOffset) + + return AvatarImageView(url: status.account.avatar, size: 50) + .offset(x: 0, y: offset) + .accessibilityHidden(true) + } + +} + +private struct DisplayNameHeightPrefKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = nextValue() + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/WrappedProgressView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/WrappedProgressView.swift new file mode 100644 index 00000000..a6037682 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/WrappedProgressView.swift @@ -0,0 +1,29 @@ +// +// WrappedProgressView.swift +// Tusker +// +// Created by Shadowfacts on 8/30/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import SwiftUI + +struct WrappedProgressView: UIViewRepresentable { + typealias UIViewType = UIProgressView + + let value: Int + let total: Int + + func makeUIView(context: Context) -> UIProgressView { + return UIProgressView(progressViewStyle: .bar) + } + + func updateUIView(_ uiView: UIProgressView, context: Context) { + if total > 0 { + let progress = Float(value) / Float(total) + uiView.setProgress(progress, animated: true) + } else { + uiView.setProgress(0, animated: true) + } + } +} diff --git a/Packages/ComposeUI/Tests/ComposeUITests/FuzzyMatcherTests.swift b/Packages/ComposeUI/Tests/ComposeUITests/FuzzyMatcherTests.swift new file mode 100644 index 00000000..c00916b4 --- /dev/null +++ b/Packages/ComposeUI/Tests/ComposeUITests/FuzzyMatcherTests.swift @@ -0,0 +1,25 @@ +// +// FuzzyMatcherTests.swift +// ComposeUITests +// +// Created by Shadowfacts on 10/11/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import XCTest +@testable import ComposeUI + +class FuzzyMatcherTests: XCTestCase { + + func testExample() throws { + XCTAssertEqual(FuzzyMatcher.match(pattern: "foo", str: "foo").score, 6) + XCTAssertEqual(FuzzyMatcher.match(pattern: "foo", str: "faoao").score, 4) + XCTAssertEqual(FuzzyMatcher.match(pattern: "foo", str: "aaa").score, -6) + + XCTAssertEqual(FuzzyMatcher.match(pattern: "bar", str: "baz").score, 2) + XCTAssertEqual(FuzzyMatcher.match(pattern: "bar", str: "bur").score, 1) + + XCTAssertGreaterThan(FuzzyMatcher.match(pattern: "sir", str: "sir").score, FuzzyMatcher.match(pattern: "sir", str: "georgespolitzer").score) + } + +} diff --git a/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceFeatures.swift b/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceFeatures.swift index c8760bc8..6ddf11dc 100644 --- a/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceFeatures.swift +++ b/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceFeatures.swift @@ -19,6 +19,9 @@ public class InstanceFeatures: ObservableObject { @Published private var instanceType: InstanceType = .mastodon(.vanilla, nil) @Published public private(set) var maxStatusChars = 500 + @Published public private(set) var charsReservedPerURL = 23 + @Published public private(set) var maxPollOptionChars: Int? + @Published public private(set) var maxPollOptionsCount: Int? public var localOnlyPosts: Bool { switch instanceType { @@ -155,6 +158,11 @@ public class InstanceFeatures: ObservableObject { } maxStatusChars = instance.maxStatusCharacters ?? 500 + charsReservedPerURL = instance.configuration?.statuses.charactersReservedPerURL ?? 23 // default Mastodon link length + if let pollsConfig = instance.pollsConfiguration { + maxPollOptionChars = pollsConfig.maxCharactersPerOption + maxPollOptionsCount = pollsConfig.maxOptions + } _featuresUpdated.send() } diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Hashtag.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Hashtag.swift index e5c98bd4..85b2c877 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Model/Hashtag.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Hashtag.swift @@ -64,6 +64,6 @@ extension Hashtag: Equatable, Hashable { } public func hash(into hasher: inout Hasher) { - hasher.combine(url) + hasher.combine(name) } } diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Protocols/AccountProtocol.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Protocols/AccountProtocol.swift index 6b1be094..c3f26ae7 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Model/Protocols/AccountProtocol.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Protocols/AccountProtocol.swift @@ -9,7 +9,6 @@ import Foundation public protocol AccountProtocol { - associatedtype Account: AccountProtocol var id: String { get } var username: String { get } @@ -27,7 +26,7 @@ public protocol AccountProtocol { var moved: Bool? { get } var bot: Bool? { get } - var movedTo: Account? { get } + var movedTo: Self? { get } var emojis: [Emoji] { get } var fields: [Pachyderm.Account.Field] { get } } diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Protocols/RelationshipProtocol.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Protocols/RelationshipProtocol.swift new file mode 100644 index 00000000..bfe47579 --- /dev/null +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Protocols/RelationshipProtocol.swift @@ -0,0 +1,21 @@ +// +// RelationshipProtocol.swift +// Pachyderm +// +// Created by Shadowfacts on 3/26/23. +// + +import Foundation + +public protocol RelationshipProtocol { + var accountID: String { get } + var following: Bool { get } + var followedBy: Bool { get } + var blocking: Bool { get } + var muting: Bool { get } + var mutingNotifications: Bool { get } + var followRequested: Bool { get } + var domainBlocking: Bool { get } + var showingReblogs: Bool { get } + var endorsed: Bool { get } +} diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Relationship.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Relationship.swift index 66245702..96c1d03b 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Model/Relationship.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Relationship.swift @@ -8,8 +8,8 @@ import Foundation -public struct Relationship: Decodable, Sendable { - public let id: String +public struct Relationship: RelationshipProtocol, Decodable, Sendable { + public let accountID: String public let following: Bool public let followedBy: Bool public let blocking: Bool @@ -18,7 +18,21 @@ public struct Relationship: Decodable, Sendable { public let followRequested: Bool public let domainBlocking: Bool public let showingReblogs: Bool - public let endorsed: Bool? + public let endorsed: Bool + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.accountID = try container.decode(String.self, forKey: .id) + self.following = try container.decode(Bool.self, forKey: .following) + self.followedBy = try container.decode(Bool.self, forKey: .followedBy) + self.blocking = try container.decode(Bool.self, forKey: .blocking) + self.muting = try container.decode(Bool.self, forKey: .muting) + self.mutingNotifications = try container.decode(Bool.self, forKey: .mutingNotifications) + self.followRequested = try container.decode(Bool.self, forKey: .followRequested) + self.domainBlocking = try container.decode(Bool.self, forKey: .domainBlocking) + self.showingReblogs = try container.decode(Bool.self, forKey: .showingReblogs) + self.endorsed = try container.decodeIfPresent(Bool.self, forKey: .endorsed) ?? false + } private enum CodingKeys: String, CodingKey { case id diff --git a/Packages/TuskerComponents/Sources/TuskerComponents/AbbreviatedTimeAgoFormatStyle.swift b/Packages/TuskerComponents/Sources/TuskerComponents/AbbreviatedTimeAgoFormatStyle.swift new file mode 100644 index 00000000..df10e99f --- /dev/null +++ b/Packages/TuskerComponents/Sources/TuskerComponents/AbbreviatedTimeAgoFormatStyle.swift @@ -0,0 +1,70 @@ +// +// AbbreviatedTimeAgoFormatStyle.swift +// +// +// Created by Shadowfacts on 4/9/23. +// + +import Foundation + +public struct AbbreviatedTimeAgoFormatStyle: FormatStyle { + public typealias FormatInput = Date + public typealias FormatOutput = String + + public func format(_ value: Date) -> String { + let (amount, component) = timeAgo(value: value) + + switch component { + case .year: + return "\(amount)y" + case .month: + return "\(amount)mo" + case .weekOfYear: + return "\(amount)w" + case .day: + return "\(amount)d" + case .hour: + return "\(amount)h" + case .minute: + return "\(amount)m" + case .second: + if amount >= 3 { + return "\(amount)s" + } else { + return "Now" + } + default: + fatalError("Unexpected component: \(component)") + } + } + + private static let unitFlags = Set([.second, .minute, .hour, .day, .weekOfYear, .month, .year]) + + private func timeAgo(value: Date) -> (Int, Calendar.Component) { + let calendar = NSCalendar.current + let components = calendar.dateComponents(Self.unitFlags, from: value, to: Date()) + + if components.year! >= 1 { + return (components.year!, .year) + } else if components.month! >= 1 { + return (components.month!, .month) + } else if components.weekOfYear! >= 1 { + return (components.weekOfYear!, .weekOfYear) + } else if components.day! >= 1 { + return (components.day!, .day) + } else if components.hour! >= 1 { + return (components.hour!, .hour) + } else if components.minute! >= 1 { + return (components.minute!, .minute) + } else { + return (components.second!, .second) + } + } + +} + +public extension FormatStyle where Self == AbbreviatedTimeAgoFormatStyle { + static var abbreviatedTimeAgo: Self { + Self() + } +} diff --git a/Tusker/Views/AlertWithData.swift b/Packages/TuskerComponents/Sources/TuskerComponents/AlertWithData.swift similarity index 83% rename from Tusker/Views/AlertWithData.swift rename to Packages/TuskerComponents/Sources/TuskerComponents/AlertWithData.swift index 226ab9f2..8b3b6238 100644 --- a/Tusker/Views/AlertWithData.swift +++ b/Packages/TuskerComponents/Sources/TuskerComponents/AlertWithData.swift @@ -1,6 +1,6 @@ // // AlertWithData.swift -// Tusker +// TuskerComponents // // Created by Shadowfacts on 11/9/22. // Copyright © 2022 Shadowfacts. All rights reserved. @@ -39,7 +39,7 @@ struct AlertWithData: ViewModifier { } extension View { - func alertWithData(_ title: LocalizedStringKey, data: Binding, @ViewBuilder actions: @escaping (Data) -> A, @ViewBuilder message: @escaping (Data) -> M) -> some View { + public func alertWithData(_ title: LocalizedStringKey, data: Binding, @ViewBuilder actions: @escaping (Data) -> A, @ViewBuilder message: @escaping (Data) -> M) -> some View { modifier(AlertWithData(title: title, data: data, actions: actions, message: message)) } } diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 376e2314..f27dd33b 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -40,7 +40,6 @@ D6114E0D27F7FEB30080E273 /* TrendingStatusesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6114E0C27F7FEB30080E273 /* TrendingStatusesViewController.swift */; }; D6114E1127F899B30080E273 /* TrendingLinksViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6114E1027F899B30080E273 /* TrendingLinksViewController.swift */; }; D6114E1327F89B440080E273 /* TrendingLinkTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6114E1227F89B440080E273 /* TrendingLinkTableViewCell.swift */; }; - D6114E1727F8BB210080E273 /* VersionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6114E1627F8BB210080E273 /* VersionTests.swift */; }; D61A45E628DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E528DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift */; }; D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */; }; D61A45EA28DF51EE002BE511 /* TimelineLikeCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */; }; @@ -184,7 +183,7 @@ D677284824ECBCB100C732D3 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284724ECBCB100C732D3 /* ComposeView.swift */; }; D677284A24ECBDF400C732D3 /* ComposeCurrentAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */; }; D677284C24ECBE9100C732D3 /* ComposeAvatarImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284B24ECBE9100C732D3 /* ComposeAvatarImageView.swift */; }; - D677284E24ECC01D00C732D3 /* Draft.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284D24ECC01D00C732D3 /* Draft.swift */; }; + D677284E24ECC01D00C732D3 /* OldDraft.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284D24ECC01D00C732D3 /* OldDraft.swift */; }; D67895BC24671E6D00D4CD9E /* PKDrawing+Render.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67895BB24671E6D00D4CD9E /* PKDrawing+Render.swift */; }; D67895C0246870DE00D4CD9E /* LocalAccountAvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67895BF246870DE00D4CD9E /* LocalAccountAvatarView.swift */; }; D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67B506C250B291200FAECFB /* BlurHashDecode.swift */; }; @@ -289,10 +288,11 @@ D6BC9DB5232D4CE3002CA326 /* NotificationsMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DB4232D4CE3002CA326 /* NotificationsMode.swift */; }; D6BC9DD7232D7811002CA326 /* TimelinesPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */; }; D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */; }; + D6BD395929B64426005FFD2B /* ComposeUI in Frameworks */ = {isa = PBXBuildFile; productRef = D6BD395829B64426005FFD2B /* ComposeUI */; }; + D6BD395B29B64441005FFD2B /* NewComposeHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BD395A29B64441005FFD2B /* NewComposeHostingController.swift */; }; D6BEA245291A0EDE002F4D01 /* Duckable in Frameworks */ = {isa = PBXBuildFile; productRef = D6BEA244291A0EDE002F4D01 /* Duckable */; }; D6BEA247291A0F2D002F4D01 /* Duckable+Root.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BEA246291A0F2D002F4D01 /* Duckable+Root.swift */; }; D6BEA249291C6118002F4D01 /* DraftsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BEA248291C6118002F4D01 /* DraftsView.swift */; }; - D6BEA24B291C6A2B002F4D01 /* AlertWithData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BEA24A291C6A2B002F4D01 /* AlertWithData.swift */; }; D6BED174212667E900F02DA0 /* TimelineStatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */; }; D6C143DA253510F4007DC240 /* ComposeEmojiTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */; }; D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */; }; @@ -456,7 +456,6 @@ D6114E0C27F7FEB30080E273 /* TrendingStatusesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingStatusesViewController.swift; sourceTree = ""; }; D6114E1027F899B30080E273 /* TrendingLinksViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingLinksViewController.swift; sourceTree = ""; }; D6114E1227F89B440080E273 /* TrendingLinkTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingLinkTableViewCell.swift; sourceTree = ""; }; - D6114E1627F8BB210080E273 /* VersionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionTests.swift; sourceTree = ""; }; D61A45E528DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmLoadMoreCollectionViewCell.swift; sourceTree = ""; }; D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingCollectionViewCell.swift; sourceTree = ""; }; D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineLikeCollectionViewController.swift; sourceTree = ""; }; @@ -600,7 +599,7 @@ D677284724ECBCB100C732D3 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = ""; }; D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeCurrentAccount.swift; sourceTree = ""; }; D677284B24ECBE9100C732D3 /* ComposeAvatarImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAvatarImageView.swift; sourceTree = ""; }; - D677284D24ECC01D00C732D3 /* Draft.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Draft.swift; sourceTree = ""; }; + D677284D24ECC01D00C732D3 /* OldDraft.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OldDraft.swift; sourceTree = ""; }; D67895BB24671E6D00D4CD9E /* PKDrawing+Render.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PKDrawing+Render.swift"; sourceTree = ""; }; D67895BF246870DE00D4CD9E /* LocalAccountAvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAccountAvatarView.swift; sourceTree = ""; }; D67B506C250B291200FAECFB /* BlurHashDecode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurHashDecode.swift; sourceTree = ""; }; @@ -706,11 +705,12 @@ D6BC9DB4232D4CE3002CA326 /* NotificationsMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsMode.swift; sourceTree = ""; }; D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinesPageViewController.swift; sourceTree = ""; }; D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsViewController.swift; sourceTree = ""; }; + D6BD395729B6441F005FFD2B /* ComposeUI */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = ComposeUI; path = Packages/ComposeUI; sourceTree = ""; }; + D6BD395A29B64441005FFD2B /* NewComposeHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewComposeHostingController.swift; sourceTree = ""; }; D6BD395C29B789D5005FFD2B /* TuskerComponents */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = TuskerComponents; path = Packages/TuskerComponents; sourceTree = ""; }; D6BEA243291A0C83002F4D01 /* Duckable */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Duckable; path = Packages/Duckable; sourceTree = ""; }; D6BEA246291A0F2D002F4D01 /* Duckable+Root.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Duckable+Root.swift"; sourceTree = ""; }; D6BEA248291C6118002F4D01 /* DraftsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftsView.swift; sourceTree = ""; }; - D6BEA24A291C6A2B002F4D01 /* AlertWithData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertWithData.swift; sourceTree = ""; }; D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStatusTableViewCell.swift; sourceTree = ""; }; D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeEmojiTextField.swift; sourceTree = ""; }; D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCardView.swift; sourceTree = ""; }; @@ -807,6 +807,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + D6BD395929B64426005FFD2B /* ComposeUI in Frameworks */, D6FA94E129B52898006AAC51 /* InstanceFeatures in Frameworks */, D635237129B78A7D009ED5E7 /* TuskerComponents in Frameworks */, D674A50927F9128D00BA03AC /* Pachyderm in Frameworks */, @@ -880,7 +881,7 @@ D6285B5221EA708700FE4B39 /* StatusFormat.swift */, D63F9C6D241D2D85004C03CF /* CompositionAttachment.swift */, D626493423BD94CE00612E6E /* CompositionAttachmentData.swift */, - D677284D24ECC01D00C732D3 /* Draft.swift */, + D677284D24ECC01D00C732D3 /* OldDraft.swift */, D627FF75217E923E00CC0648 /* DraftsManager.swift */, D61F75AE293AF50C00C0B37F /* EditedFilter.swift */, D65B4B532971F71D00DABDFB /* EditedReport.swift */, @@ -1150,6 +1151,7 @@ D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */, D6F6A5582920676800F496A8 /* ComposeToolbar.swift */, D6BEA248291C6118002F4D01 /* DraftsView.swift */, + D6BD395A29B64441005FFD2B /* NewComposeHostingController.swift */, ); path = Compose; sourceTree = ""; @@ -1446,7 +1448,6 @@ isa = PBXGroup; children = ( D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameLabel.swift */, - D6BEA24A291C6A2B002F4D01 /* AlertWithData.swift */, D68E6F5E253C9B2D001A1B4C /* BaseEmojiLabel.swift */, D6ADB6EF28ED1F25009924AB /* CachedImageView.swift */, D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */, @@ -1536,6 +1537,7 @@ D6B0026C29B5245400C70BE2 /* UserAccounts */, D6FA94DF29B52891006AAC51 /* InstanceFeatures */, D6BD395C29B789D5005FFD2B /* TuskerComponents */, + D6BD395729B6441F005FFD2B /* ComposeUI */, D6D4DDCE212518A000E1C4BB /* Tusker */, D6D4DDE3212518A200E1C4BB /* TuskerTests */, D6D4DDEE212518A200E1C4BB /* TuskerUITests */, @@ -1607,7 +1609,6 @@ D6D4DDE4212518A200E1C4BB /* TuskerTests.swift */, D62FF04723D7CDD700909D6E /* AttributedStringHelperTests.swift */, D6E426AC25334DA500C02E1C /* FuzzyMatcherTests.swift */, - D6114E1627F8BB210080E273 /* VersionTests.swift */, D61F75A029396DE200C0B37F /* SemiCaseSensitiveComparatorTests.swift */, D6CA8CD92962231F0050C433 /* ArrayUniqueTests.swift */, D600891C298482F0005B4D00 /* PinnedTimelineTests.swift */, @@ -1741,6 +1742,7 @@ D6B0026D29B5248800C70BE2 /* UserAccounts */, D6FA94E029B52898006AAC51 /* InstanceFeatures */, D635237029B78A7D009ED5E7 /* TuskerComponents */, + D6BD395829B64426005FFD2B /* ComposeUI */, ); productName = Tusker; productReference = D6D4DDCC212518A000E1C4BB /* Tusker.app */; @@ -1985,6 +1987,7 @@ D6412B0324AFF6A600F5412E /* TabBarScrollableViewController.swift in Sources */, D6093FB725BE0CF3004811E6 /* TrendHistoryView.swift in Sources */, D6EBF01723C55E0D00AE061B /* UISceneSession+MastodonController.swift in Sources */, + D6BD395B29B64441005FFD2B /* NewComposeHostingController.swift in Sources */, D61F759229365C6C00C0B37F /* CollectionViewController.swift in Sources */, D6DD996B2998611A0015C962 /* SuggestedProfilesViewController.swift in Sources */, D6DD8FFD298495A8002AD3FD /* LogoutService.swift in Sources */, @@ -2155,7 +2158,6 @@ D64BC18623C1253A000D0238 /* AssetPreviewViewController.swift in Sources */, D6F6A550291F058600F496A8 /* CreateListService.swift in Sources */, D601FA5B29787AB100A8E8B5 /* AccountFollowsListViewController.swift in Sources */, - D6BEA24B291C6A2B002F4D01 /* AlertWithData.swift in Sources */, D61ABEFE28F1C92600B29151 /* FavoriteService.swift in Sources */, D61F75AB293AF11400C0B37F /* FilterKeywordMO.swift in Sources */, D65B4B5A29720AB000DABDFB /* ReportStatusView.swift in Sources */, @@ -2261,7 +2263,7 @@ D6093F9B25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift in Sources */, D6E4267725327FB400C02E1C /* ComposeAutocompleteView.swift in Sources */, D6E77D09286D25FA00D8B732 /* TrendingHashtagCollectionViewCell.swift in Sources */, - D677284E24ECC01D00C732D3 /* Draft.swift in Sources */, + D677284E24ECC01D00C732D3 /* OldDraft.swift in Sources */, D667E5F12134D5050057A976 /* UIViewController+Delegates.swift in Sources */, D6BC8748219738E1006163F1 /* EnhancedTableViewController.swift in Sources */, D6E426B325337C7000C02E1C /* CustomEmojiImageView.swift in Sources */, @@ -2293,7 +2295,6 @@ D62FF04823D7CDD700909D6E /* AttributedStringHelperTests.swift in Sources */, D6E426AD25334DA500C02E1C /* FuzzyMatcherTests.swift in Sources */, D600891D298482F0005B4D00 /* PinnedTimelineTests.swift in Sources */, - D6114E1727F8BB210080E273 /* VersionTests.swift in Sources */, D6CA8CDA2962231F0050C433 /* ArrayUniqueTests.swift in Sources */, D6D4DDE5212518A200E1C4BB /* TuskerTests.swift in Sources */, ); @@ -2969,6 +2970,10 @@ isa = XCSwiftPackageProductDependency; productName = UserAccounts; }; + D6BD395829B64426005FFD2B /* ComposeUI */ = { + isa = XCSwiftPackageProductDependency; + productName = ComposeUI; + }; D6BEA244291A0EDE002F4D01 /* Duckable */ = { isa = XCSwiftPackageProductDependency; productName = Duckable; diff --git a/Tusker/API/MastodonController.swift b/Tusker/API/MastodonController.swift index 76d80669..5739b45e 100644 --- a/Tusker/API/MastodonController.swift +++ b/Tusker/API/MastodonController.swift @@ -12,6 +12,7 @@ import Combine import UserAccounts import InstanceFeatures import Sentry +import ComposeUI class MastodonController: ObservableObject { @@ -456,6 +457,55 @@ class MastodonController: ObservableObject { filters = (try? persistentContainer.viewContext.fetch(FilterMO.fetchRequest())) ?? [] } + func createDraft(inReplyToID: String? = nil, mentioningAcct: String? = nil, text: String? = nil) -> Draft { + var acctsToMention = [String]() + + var visibility = inReplyToID != nil ? Preferences.shared.defaultReplyVisibility.resolved : Preferences.shared.defaultPostVisibility + var localOnly = false + var contentWarning = "" + + if let inReplyToID = inReplyToID, + let inReplyTo = persistentContainer.status(for: inReplyToID) { + acctsToMention.append(inReplyTo.account.acct) + acctsToMention.append(contentsOf: inReplyTo.mentions.map(\.acct)) + visibility = min(visibility, inReplyTo.visibility) + localOnly = instanceFeatures.localOnlyPosts && inReplyTo.localOnly + + if !inReplyTo.spoilerText.isEmpty { + switch Preferences.shared.contentWarningCopyMode { + case .doNotCopy: + break + case .asIs: + contentWarning = inReplyTo.spoilerText + case .prependRe: + if inReplyTo.spoilerText.lowercased().starts(with: "re:") { + contentWarning = inReplyTo.spoilerText + } else { + contentWarning = "re: \(inReplyTo.spoilerText)" + } + } + } + } + if let mentioningAcct = mentioningAcct { + acctsToMention.append(mentioningAcct) + } + if let ownAccount = self.account { + acctsToMention.removeAll(where: { $0 == ownAccount.acct }) + } + acctsToMention = acctsToMention.uniques() + + let draft = Draft( + accountID: accountInfo!.id, + text: text ?? acctsToMention.map { "@\($0) " }.joined(), + contentWarning: contentWarning, + inReplyToID: inReplyToID, + visibility: visibility, + localOnly: localOnly + ) + DraftsManager.shared.add(draft) + return draft + } + } private func setInstanceBreadcrumb(instance: Instance, nodeInfo: NodeInfo?) { diff --git a/Tusker/API/PostService.swift b/Tusker/API/PostService.swift index 91a689aa..72ef7281 100644 --- a/Tusker/API/PostService.swift +++ b/Tusker/API/PostService.swift @@ -12,12 +12,12 @@ import UniformTypeIdentifiers class PostService: ObservableObject { private let mastodonController: MastodonController - private let draft: Draft + private let draft: OldDraft let totalSteps: Int @Published var currentStep = 1 - init(mastodonController: MastodonController, draft: Draft) { + init(mastodonController: MastodonController, draft: OldDraft) { self.mastodonController = mastodonController self.draft = draft // 2 steps (request data, then upload) for each attachment @@ -31,7 +31,7 @@ class PostService: ObservableObject { } // save before posting, so if a crash occurs during network request, the status won't be lost - DraftsManager.save() + OldDraftsManager.save() let uploadedAttachments = try await uploadAttachments() @@ -56,7 +56,7 @@ class PostService: ObservableObject { let (_, _) = try await mastodonController.run(request) currentStep += 1 - DraftsManager.shared.remove(self.draft) + OldDraftsManager.shared.remove(self.draft) } catch let error as Client.Error { throw Error.posting(error) } diff --git a/Tusker/CoreData/MastodonCachePersistentStore.swift b/Tusker/CoreData/MastodonCachePersistentStore.swift index 05e4560f..1859cdb2 100644 --- a/Tusker/CoreData/MastodonCachePersistentStore.swift +++ b/Tusker/CoreData/MastodonCachePersistentStore.swift @@ -321,7 +321,7 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer { @discardableResult private func upsert(relationship: Relationship, in context: NSManagedObjectContext) -> RelationshipMO { - if let relationshipMO = self.relationship(forAccount: relationship.id, in: context) { + if let relationshipMO = self.relationship(forAccount: relationship.accountID, in: context) { relationshipMO.updateFrom(apiRelationship: relationship, container: self) return relationshipMO } else { @@ -336,7 +336,7 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer { let relationshipMO = self.upsert(relationship: relationship, in: context) self.save(context: context) completion?(relationshipMO) - self.relationshipSubject.send(relationship.id) + self.relationshipSubject.send(relationship.accountID) } } diff --git a/Tusker/CoreData/RelationshipMO.swift b/Tusker/CoreData/RelationshipMO.swift index 175287bc..260830fc 100644 --- a/Tusker/CoreData/RelationshipMO.swift +++ b/Tusker/CoreData/RelationshipMO.swift @@ -11,7 +11,7 @@ import CoreData import Pachyderm @objc(RelationshipMO) -public final class RelationshipMO: NSManagedObject { +public final class RelationshipMO: NSManagedObject, RelationshipProtocol { @nonobjc public class func fetchRequest() -> NSFetchRequest { return NSFetchRequest(entityName: "Relationship") @@ -29,6 +29,8 @@ public final class RelationshipMO: NSManagedObject { @NSManaged public var showingReblogs: Bool @NSManaged public var account: AccountMO? + public var followRequested: Bool { requested } + } extension RelationshipMO { @@ -43,10 +45,10 @@ extension RelationshipMO { return } - self.accountID = relationship.id + self.accountID = relationship.accountID self.blocking = relationship.blocking self.domainBlocking = relationship.domainBlocking - self.endorsed = relationship.endorsed ?? false + self.endorsed = relationship.endorsed self.followedBy = relationship.followedBy self.following = relationship.following self.muting = relationship.muting @@ -54,6 +56,6 @@ extension RelationshipMO { self.requested = relationship.followRequested self.showingReblogs = relationship.showingReblogs - self.account = container.account(for: relationship.id, in: context) + self.account = container.account(for: relationship.accountID, in: context) } } diff --git a/Tusker/Extensions/Date+TimeAgo.swift b/Tusker/Extensions/Date+TimeAgo.swift index bce334e2..cf99ff2b 100644 --- a/Tusker/Extensions/Date+TimeAgo.swift +++ b/Tusker/Extensions/Date+TimeAgo.swift @@ -7,6 +7,7 @@ // import Foundation +import TuskerComponents extension Date { @@ -34,30 +35,7 @@ extension Date { } func timeAgoString() -> String { - let (amount, component) = timeAgo() - - switch component { - case .year: - return "\(amount)y" - case .month: - return "\(amount)mo" - case .weekOfYear: - return "\(amount)w" - case .day: - return "\(amount)d" - case .hour: - return "\(amount)h" - case .minute: - return "\(amount)m" - case .second: - if amount >= 3 { - return "\(amount)s" - } else { - return "Now" - } - default: - fatalError("Unexpected component: \(component)") - } + self.formatted(.abbreviatedTimeAgo) } } diff --git a/Tusker/Models/CompositionAttachment.swift b/Tusker/Models/CompositionAttachment.swift index 9f7d621f..2c51302e 100644 --- a/Tusker/Models/CompositionAttachment.swift +++ b/Tusker/Models/CompositionAttachment.swift @@ -70,9 +70,9 @@ extension CompositionAttachment: NSItemProviderWriting { } catch { completionHandler(nil, error) } + } else { + completionHandler(nil, ItemProviderError.incompatibleTypeIdentifier) } - - completionHandler(nil, ItemProviderError.incompatibleTypeIdentifier) return nil } diff --git a/Tusker/Models/DraftsManager.swift b/Tusker/Models/DraftsManager.swift index 8f7d9c1e..723d8a9e 100644 --- a/Tusker/Models/DraftsManager.swift +++ b/Tusker/Models/DraftsManager.swift @@ -1,5 +1,5 @@ // -// DraftsManager.swift +// OldDraftsManager.swift // Tusker // // Created by Shadowfacts on 10/22/18. @@ -8,13 +8,13 @@ import Foundation -class DraftsManager: Codable, ObservableObject { +class OldDraftsManager: Codable, ObservableObject { + + private(set) static var shared: OldDraftsManager = load() - private(set) static var shared: DraftsManager = load() - private static var documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! - private static var archiveURL = DraftsManager.documentsDirectory.appendingPathComponent("drafts").appendingPathExtension("plist") - + private static var archiveURL = OldDraftsManager.documentsDirectory.appendingPathComponent("drafts").appendingPathExtension("plist") + static func save() { DispatchQueue.global(qos: .utility).async { let encoder = PropertyListEncoder() @@ -22,24 +22,24 @@ class DraftsManager: Codable, ObservableObject { try? data?.write(to: archiveURL, options: .noFileProtection) } } - - static func load() -> DraftsManager { + + static func load() -> OldDraftsManager { let decoder = PropertyListDecoder() if let data = try? Data(contentsOf: archiveURL), - let draftsManager = try? decoder.decode(DraftsManager.self, from: data) { - return draftsManager + let OldDraftsManager = try? decoder.decode(OldDraftsManager.self, from: data) { + return OldDraftsManager } - return DraftsManager() + return OldDraftsManager() } - + private init() {} - + required init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - - if let dict = try? container.decode([UUID: Draft].self, forKey: .drafts) { + + if let dict = try? container.decode([UUID: OldDraft].self, forKey: .drafts) { self.drafts = dict - } else if let array = try? container.decode([Draft].self, forKey: .drafts) { + } else if let array = try? container.decode([OldDraft].self, forKey: .drafts) { self.drafts = array.reduce(into: [:], { partialResult, draft in partialResult[draft.id] = draft }) @@ -47,31 +47,31 @@ class DraftsManager: Codable, ObservableObject { throw DecodingError.dataCorruptedError(forKey: .drafts, in: container, debugDescription: "expected drafts to be a dict or array of drafts") } } - + func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(drafts, forKey: .drafts) } - - @Published private var drafts: [UUID: Draft] = [:] - var sorted: [Draft] { + + @Published private var drafts: [UUID: OldDraft] = [:] + var sorted: [OldDraft] { return drafts.values.sorted(by: { $0.lastModified > $1.lastModified }) } - - func add(_ draft: Draft) { + + func add(_ draft: OldDraft) { drafts[draft.id] = draft } - - func remove(_ draft: Draft) { + + func remove(_ draft: OldDraft) { drafts.removeValue(forKey: draft.id) } - - func getBy(id: UUID) -> Draft? { + + func getBy(id: UUID) -> OldDraft? { return drafts[id] } - + enum CodingKeys: String, CodingKey { case drafts } - + } diff --git a/Tusker/Models/Draft.swift b/Tusker/Models/OldDraft.swift similarity index 94% rename from Tusker/Models/Draft.swift rename to Tusker/Models/OldDraft.swift index 6cfab936..24ca5808 100644 --- a/Tusker/Models/Draft.swift +++ b/Tusker/Models/OldDraft.swift @@ -1,5 +1,5 @@ // -// Draft.swift +// OldDraft.swift // Tusker // // Created by Shadowfacts on 8/18/20. @@ -9,7 +9,7 @@ import Foundation import Pachyderm -class Draft: Codable, ObservableObject { +class OldDraft: Codable, ObservableObject { let id: UUID var lastModified: Date @@ -88,15 +88,15 @@ class Draft: Codable, ObservableObject { } } -extension Draft: Equatable { - static func ==(lhs: Draft, rhs: Draft) -> Bool { +extension OldDraft: Equatable { + static func ==(lhs: OldDraft, rhs: OldDraft) -> Bool { return lhs.id == rhs.id } } -extension Draft: Identifiable {} +extension OldDraft: Identifiable {} -extension Draft { +extension OldDraft { enum CodingKeys: String, CodingKey { case id case lastModified @@ -115,7 +115,7 @@ extension Draft { } } -extension Draft { +extension OldDraft { class Poll: Codable, ObservableObject { @Published var options: [Option] @Published var multiple: Bool @@ -173,7 +173,7 @@ extension Draft { extension MastodonController { - func createDraft(inReplyToID: String? = nil, mentioningAcct: String? = nil) -> Draft { + func createOldDraft(inReplyToID: String? = nil, mentioningAcct: String? = nil) -> OldDraft { var acctsToMention = [String]() var visibility = inReplyToID != nil ? Preferences.shared.defaultReplyVisibility.resolved : Preferences.shared.defaultPostVisibility @@ -210,7 +210,7 @@ extension MastodonController { } acctsToMention = acctsToMention.uniques() - let draft = Draft(accountID: accountInfo!.id) + let draft = OldDraft(accountID: accountInfo!.id) draft.inReplyToID = inReplyToID draft.text = acctsToMention.map { "@\($0) " }.joined() draft.initialText = draft.text @@ -219,7 +219,7 @@ extension MastodonController { draft.contentWarning = contentWarning draft.contentWarningEnabled = !contentWarning.isEmpty - DraftsManager.shared.add(draft) + OldDraftsManager.shared.add(draft) return draft } diff --git a/Tusker/Scenes/ComposeSceneDelegate.swift b/Tusker/Scenes/ComposeSceneDelegate.swift index 2ae505a5..7eb558eb 100644 --- a/Tusker/Scenes/ComposeSceneDelegate.swift +++ b/Tusker/Scenes/ComposeSceneDelegate.swift @@ -9,6 +9,7 @@ import UIKit import Combine import UserAccounts +import ComposeUI class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate { @@ -58,7 +59,7 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDeleg session.mastodonController = controller controller.initialize() - let composeVC = ComposeHostingController(draft: draft, mastodonController: controller) + let composeVC = NewComposeHostingController(draft: draft, mastodonController: controller) composeVC.delegate = self let nav = EnhancedNavigationViewController(rootViewController: composeVC) @@ -66,7 +67,7 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDeleg window!.rootViewController = nav window!.makeKeyAndVisible() - updateTitle(draft: composeVC.draft) + updateTitle(draft: composeVC.controller.draft) composeVC.controller.$draft .sink { [unowned self] in self.updateTitle(draft: $0) } .store(in: &cancellables) @@ -76,7 +77,7 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDeleg } func sceneWillResignActive(_ scene: UIScene) { - DraftsManager.save() + OldDraftsManager.save() if let window = window, let nav = window.rootViewController as? UINavigationController, @@ -108,8 +109,8 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDeleg } -extension ComposeSceneDelegate: ComposeHostingControllerDelegate { - func dismissCompose(mode: ComposeUIState.DismissMode) -> Bool { +extension ComposeSceneDelegate: NewComposeHostingControllerDelegate { + func dismissCompose(mode: DismissMode) -> Bool { let animation: UIWindowScene.DismissalAnimation switch mode { case .cancel: diff --git a/Tusker/Scenes/MainSceneDelegate.swift b/Tusker/Scenes/MainSceneDelegate.swift index 8b6f5547..685874f2 100644 --- a/Tusker/Scenes/MainSceneDelegate.swift +++ b/Tusker/Scenes/MainSceneDelegate.swift @@ -87,7 +87,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate // The scene may re-connect later, as its session was not neccessarily discarded (see `application:didDiscardSceneSessions` instead). Preferences.save() - DraftsManager.save() + OldDraftsManager.save() } func sceneDidBecomeActive(_ scene: UIScene) { @@ -100,7 +100,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate // This may occur due to temporary interruptions (ex. an incoming phone call). Preferences.save() - DraftsManager.save() + OldDraftsManager.save() } func stateRestorationActivity(for scene: UIScene) -> NSUserActivity? { diff --git a/Tusker/Screens/Compose/ComposeAssetPicker.swift b/Tusker/Screens/Compose/ComposeAssetPicker.swift index 12b3cb50..4a0cf43b 100644 --- a/Tusker/Screens/Compose/ComposeAssetPicker.swift +++ b/Tusker/Screens/Compose/ComposeAssetPicker.swift @@ -7,11 +7,12 @@ // import SwiftUI +import ComposeUI struct ComposeAssetPicker: UIViewControllerRepresentable { typealias UIViewControllerType = AssetPickerViewController - @ObservedObject var draft: Draft + @ObservedObject var draft: OldDraft let delegate: AssetPickerViewControllerDelegate? @EnvironmentObject var mastodonController: MastodonController diff --git a/Tusker/Screens/Compose/ComposeAttachmentRow.swift b/Tusker/Screens/Compose/ComposeAttachmentRow.swift index 29eb78b1..5839d760 100644 --- a/Tusker/Screens/Compose/ComposeAttachmentRow.swift +++ b/Tusker/Screens/Compose/ComposeAttachmentRow.swift @@ -12,7 +12,7 @@ import AVFoundation import Vision struct ComposeAttachmentRow: View { - @ObservedObject var draft: Draft + @ObservedObject var draft: OldDraft @ObservedObject var attachment: CompositionAttachment @EnvironmentObject var mastodonController: MastodonController diff --git a/Tusker/Screens/Compose/ComposeAttachmentsList.swift b/Tusker/Screens/Compose/ComposeAttachmentsList.swift index 743caa0e..011e2c92 100644 --- a/Tusker/Screens/Compose/ComposeAttachmentsList.swift +++ b/Tusker/Screens/Compose/ComposeAttachmentsList.swift @@ -12,7 +12,7 @@ struct ComposeAttachmentsList: View { private let cellHeight: CGFloat = 80 private let cellPadding: CGFloat = 12 - @ObservedObject var draft: Draft + @ObservedObject var draft: OldDraft @EnvironmentObject var mastodonController: MastodonController @EnvironmentObject var uiState: ComposeUIState @@ -119,13 +119,13 @@ struct ComposeAttachmentsList: View { } private func addAttachment() { - if #available(iOS 16.0, *) { - isShowingAssetPickerPopover = true - } else if horizontalSizeClass == .regular { - isShowingAssetPickerPopover = true - } else { +// if #available(iOS 16.0, *) { +// isShowingAssetPickerPopover = true +// } else if horizontalSizeClass == .regular { +// isShowingAssetPickerPopover = true +// } else { uiState.delegate?.presentAssetPickerSheet() - } +// } } private func moveAttachments(from source: IndexSet, to destination: Int) { @@ -158,7 +158,7 @@ struct ComposeAttachmentsList: View { UIApplication.shared.sendAction(#selector(UIView.resignFirstResponder), to: nil, from: nil, for: nil) withAnimation { - draft.poll = draft.poll == nil ? Draft.Poll() : nil + draft.poll = draft.poll == nil ? OldDraft.Poll() : nil } } } diff --git a/Tusker/Screens/Compose/ComposeHostingController.swift b/Tusker/Screens/Compose/ComposeHostingController.swift index d346604d..9a38cdc8 100644 --- a/Tusker/Screens/Compose/ComposeHostingController.swift +++ b/Tusker/Screens/Compose/ComposeHostingController.swift @@ -25,14 +25,14 @@ class ComposeHostingController: UIHostingController Bool { @@ -153,7 +153,7 @@ extension ComposeHostingController { struct Wrapper: View { let mastodonController: MastodonController @ObservedObject var uiState: ComposeUIState - var draft: Draft { + var draft: OldDraft { uiState.draft } @@ -202,11 +202,11 @@ extension ComposeHostingController: ComposeUIStateDelegate { present(ComposeDrawingNavigationController(editing: drawing, delegate: self), animated: true) } - func selectDraft(_ draft: Draft) { + func selectDraft(_ draft: OldDraft) { if self.draft.hasContent { - DraftsManager.save() + OldDraftsManager.save() } else { - DraftsManager.shared.remove(self.draft) + OldDraftsManager.shared.remove(self.draft) } uiState.draft = draft uiState.isShowingDraftsList = false @@ -253,7 +253,7 @@ extension ComposeHostingController: UIAdaptivePresentationControllerDelegate { } func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { - DraftsManager.save() + OldDraftsManager.save() } } diff --git a/Tusker/Screens/Compose/ComposePollView.swift b/Tusker/Screens/Compose/ComposePollView.swift index 467c8c21..073b29e6 100644 --- a/Tusker/Screens/Compose/ComposePollView.swift +++ b/Tusker/Screens/Compose/ComposePollView.swift @@ -18,15 +18,15 @@ struct ComposePollView: View { return f }() - @ObservedObject var draft: Draft - @ObservedObject var poll: Draft.Poll + @ObservedObject var draft: OldDraft + @ObservedObject var poll: OldDraft.Poll @EnvironmentObject var mastodonController: MastodonController @Environment(\.colorScheme) var colorScheme: ColorScheme @State private var duration: Duration - init(draft: Draft, poll: Draft.Poll) { + init(draft: OldDraft, poll: OldDraft.Poll) { self.draft = draft self.poll = poll @@ -130,7 +130,7 @@ struct ComposePollView: View { } private func addOption() { - poll.options.append(Draft.Poll.Option("")) + poll.options.append(OldDraft.Poll.Option("")) } } @@ -167,8 +167,8 @@ extension ComposePollView { } struct ComposePollOption: View { - @ObservedObject var poll: Draft.Poll - @ObservedObject var option: Draft.Poll.Option + @ObservedObject var poll: OldDraft.Poll + @ObservedObject var option: OldDraft.Poll.Option let optionIndex: Int @EnvironmentObject private var mastodonController: MastodonController diff --git a/Tusker/Screens/Compose/ComposeReplyContentView.swift b/Tusker/Screens/Compose/ComposeReplyContentView.swift index 19b47daa..199a1f28 100644 --- a/Tusker/Screens/Compose/ComposeReplyContentView.swift +++ b/Tusker/Screens/Compose/ComposeReplyContentView.swift @@ -7,26 +7,31 @@ // import SwiftUI +import Pachyderm struct ComposeReplyContentView: UIViewRepresentable { typealias UIViewType = ComposeReplyContentTextView - let status: StatusMO - - @EnvironmentObject var mastodonController: MastodonController + let status: any StatusProtocol + let mastodonController: MastodonController let heightChanged: (CGFloat) -> Void func makeUIView(context: Context) -> UIViewType { let view = ComposeReplyContentTextView() - view.overrideMastodonController = mastodonController - view.setTextFrom(status: status) view.isUserInteractionEnabled = false // scroll needs to be enabled, otherwise the text view never reports a contentSize greater than 1 line view.isScrollEnabled = true view.backgroundColor = .clear view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + view.adjustsFontForContentSizeCategory = true + view.defaultFont = TimelineStatusCollectionViewCell.contentFont + view.monospaceFont = TimelineStatusCollectionViewCell.monospaceFont + + view.overrideMastodonController = mastodonController + view.setTextFrom(status: status) + return view } diff --git a/Tusker/Screens/Compose/ComposeReplyView.swift b/Tusker/Screens/Compose/ComposeReplyView.swift index 0ada5abe..48d95247 100644 --- a/Tusker/Screens/Compose/ComposeReplyView.swift +++ b/Tusker/Screens/Compose/ComposeReplyView.swift @@ -16,6 +16,7 @@ struct ComposeReplyView: View { @State private var displayNameHeight: CGFloat? @State private var contentHeight: CGFloat? + @EnvironmentObject private var mastodonController: MastodonController @ObservedObject private var preferences = Preferences.shared private let horizSpacing: CGFloat = 8 @@ -46,7 +47,7 @@ struct ComposeReplyView: View { } }) - ComposeReplyContentView(status: status) { newHeight in + ComposeReplyContentView(status: status, mastodonController: mastodonController) { newHeight in // otherwise, with long in-reply-to statuses, the main content text view position seems not to update // and it ends up partially behind the header DispatchQueue.main.async { diff --git a/Tusker/Screens/Compose/ComposeToolbar.swift b/Tusker/Screens/Compose/ComposeToolbar.swift index f5574053..62132a75 100644 --- a/Tusker/Screens/Compose/ComposeToolbar.swift +++ b/Tusker/Screens/Compose/ComposeToolbar.swift @@ -16,7 +16,7 @@ struct ComposeToolbar: View { .init(value: vis, title: vis.displayName, subtitle: vis.subtitle, image: UIImage(systemName: vis.unfilledImageName), accessibilityLabel: "Visibility: \(vis.displayName)") } - @ObservedObject var draft: Draft + @ObservedObject var draft: OldDraft @EnvironmentObject private var uiState: ComposeUIState @EnvironmentObject private var mastodonController: MastodonController @@ -144,6 +144,6 @@ private extension View { struct ComposeToolbar_Previews: PreviewProvider { static var previews: some View { - ComposeToolbar(draft: Draft(accountID: "")) + ComposeToolbar(draft: OldDraft(accountID: "")) } } diff --git a/Tusker/Screens/Compose/ComposeUIState.swift b/Tusker/Screens/Compose/ComposeUIState.swift index f5d40ffc..46e93f7f 100644 --- a/Tusker/Screens/Compose/ComposeUIState.swift +++ b/Tusker/Screens/Compose/ComposeUIState.swift @@ -15,7 +15,7 @@ protocol ComposeUIStateDelegate: AnyObject { // @available(iOS, obsoleted: 16.0) func presentAssetPickerSheet() func presentComposeDrawing() - func selectDraft(_ draft: Draft) + func selectDraft(_ draft: OldDraft) func paste(itemProviders: [NSItemProvider]) } @@ -23,7 +23,7 @@ class ComposeUIState: ObservableObject { weak var delegate: ComposeUIStateDelegate? - @Published var draft: Draft + @Published var draft: OldDraft @Published var isShowingSaveDraftSheet = false @Published var isShowingDraftsList = false @Published var attachmentsMissingDescriptions = Set() @@ -35,7 +35,7 @@ class ComposeUIState: ObservableObject { var shouldEmojiAutocompletionBeginExpanded = false @Published var currentInput: ComposeInput? - init(draft: Draft) { + init(draft: OldDraft) { self.draft = draft } diff --git a/Tusker/Screens/Compose/ComposeView.swift b/Tusker/Screens/Compose/ComposeView.swift index 1d178f3a..dfc811f0 100644 --- a/Tusker/Screens/Compose/ComposeView.swift +++ b/Tusker/Screens/Compose/ComposeView.swift @@ -9,6 +9,7 @@ import SwiftUI import Pachyderm import Combine +import ComposeUI @propertyWrapper struct OptionalStateObject: DynamicProperty { private class Republisher: ObservableObject { @@ -44,7 +45,7 @@ import Combine struct ComposeView: View { @EnvironmentObject var mastodonController: MastodonController @EnvironmentObject var uiState: ComposeUIState - @EnvironmentObject var draft: Draft + @EnvironmentObject var draft: OldDraft @State private var globalFrameOutsideList: CGRect = .zero @State private var contentWarningBecomeFirstResponder = false @@ -63,7 +64,7 @@ struct ComposeView: View { private var charactersRemaining: Int { let limit = mastodonController.instanceFeatures.maxStatusChars let cwCount = draft.contentWarningEnabled ? draft.contentWarning.count : 0 - return limit - (cwCount + CharacterCounter.count(text: draft.text, for: mastodonController.instance)) + return limit - (cwCount + CharacterCounter.count(text: draft.text, for: mastodonController.instanceFeatures)) } private var requiresAttachmentDescriptions: Bool { @@ -279,7 +280,7 @@ struct ComposeView: View { if draft.hasContent { uiState.isShowingSaveDraftSheet = true } else { - DraftsManager.shared.remove(draft) + OldDraftsManager.shared.remove(draft) uiState.delegate?.dismissCompose(mode: .cancel) } } @@ -293,7 +294,7 @@ struct ComposeView: View { uiState.delegate?.dismissCompose(mode: .cancel) }), .destructive(Text("Delete Draft"), action: { - DraftsManager.shared.remove(draft) + OldDraftsManager.shared.remove(draft) uiState.isShowingSaveDraftSheet = false uiState.delegate?.dismissCompose(mode: .cancel) }), diff --git a/Tusker/Screens/Compose/DraftsView.swift b/Tusker/Screens/Compose/DraftsView.swift index 4c872566..97dc58b1 100644 --- a/Tusker/Screens/Compose/DraftsView.swift +++ b/Tusker/Screens/Compose/DraftsView.swift @@ -12,7 +12,7 @@ import SwiftUI struct DraftsRepresentable: UIViewControllerRepresentable { typealias UIViewControllerType = UIHostingController - let currentDraft: Draft + let currentDraft: OldDraft let mastodonController: MastodonController func makeUIViewController(context: Context) -> UIHostingController { @@ -24,73 +24,74 @@ struct DraftsRepresentable: UIViewControllerRepresentable { } struct DraftsView: View { - let currentDraft: Draft + let currentDraft: OldDraft // don't pass this in via the environment b/c it crashes on macOS (at least, in Designed for iPad mode) since the environment doesn't get propagated through the modal popup window or something let mastodonController: MastodonController @EnvironmentObject var uiState: ComposeUIState - @StateObject private var draftsManager = DraftsManager.shared - @State private var draftForDifferentReply: Draft? + @StateObject private var draftsManager = OldDraftsManager.shared + @State private var draftForDifferentReply: OldDraft? - private var visibleDrafts: [Draft] { + private var visibleDrafts: [OldDraft] { draftsManager.sorted.filter { $0.accountID == mastodonController.accountInfo!.id && $0.id != currentDraft.id } } var body: some View { - NavigationView { - List { - ForEach(visibleDrafts) { draft in - Button { - maybeSelectDraft(draft) - } label: { - DraftView(draft: draft) - } - .contextMenu { - Button(role: .destructive) { - draftsManager.remove(draft) - } label: { - Label("Delete Draft", systemImage: "trash") - } - } - .onDrag { - let activity = UserActivityManager.editDraftActivity(id: draft.id, accountID: mastodonController.accountInfo!.id) - activity.displaysAuxiliaryScene = true - return NSItemProvider(object: activity) - } - } - .onDelete { indices in - indices - .map { visibleDrafts[$0] } - .forEach { draftsManager.remove($0) } - } - .appGroupedListRowBackground() - } - .listStyle(.plain) - .appGroupedListBackground(container: DraftsRepresentable.UIViewControllerType.self) - .navigationTitle(Text("Drafts")) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - uiState.isShowingDraftsList = false - } - } - } - } - .alertWithData("Different Reply", data: $draftForDifferentReply) { draft in - Button("Cancel", role: .cancel) { - draftForDifferentReply = nil - } - Button("Restore Draft") { - uiState.delegate?.selectDraft(draft) - } - } message: { draft in - Text("The selected draft is a reply to a different post, do you wish to use it?") - } +// NavigationView { +// List { +// ForEach(visibleDrafts) { draft in +// Button { +// maybeSelectDraft(draft) +// } label: { +// DraftView(draft: draft) +// } +// .contextMenu { +// Button(role: .destructive) { +// OldDraftsManager.remove(draft) +// } label: { +// Label("Delete Draft", systemImage: "trash") +// } +// } +// .onDrag { +// let activity = UserActivityManager.editDraftActivity(id: draft.id, accountID: mastodonController.accountInfo!.id) +// activity.displaysAuxiliaryScene = true +// return NSItemProvider(object: activity) +// } +// } +// .onDelete { indices in +// indices +// .map { visibleDrafts[$0] } +// .forEach { OldDraftsManager.remove($0) } +// } +// .appGroupedListRowBackground() +// } +// .listStyle(.plain) +// .appGroupedListBackground(container: DraftsRepresentable.UIViewControllerType.self) +// .navigationTitle(Text("Drafts")) +// .navigationBarTitleDisplayMode(.inline) +// .toolbar { +// ToolbarItem(placement: .cancellationAction) { +// Button("Cancel") { +// uiState.isShowingDraftsList = false +// } +// } +// } +// } +// .alertWithData("Different Reply", data: $draftForDifferentReply) { draft in +// Button("Cancel", role: .cancel) { +// draftForDifferentReply = nil +// } +// Button("Restore Draft") { +// uiState.delegate?.selectDraft(draft) +// } +// } message: { draft in +// Text("The selected draft is a reply to a different post, do you wish to use it?") +// } + Text("drafts") } - private func maybeSelectDraft(_ draft: Draft) { + private func maybeSelectDraft(_ draft: OldDraft) { if draft.inReplyToID != currentDraft.inReplyToID, currentDraft.hasContent { draftForDifferentReply = draft @@ -101,9 +102,9 @@ struct DraftsView: View { } struct DraftView: View { - @ObservedObject private var draft: Draft + @ObservedObject private var draft: OldDraft - init(draft: Draft) { + init(draft: OldDraft) { self._draft = ObservedObject(wrappedValue: draft) } diff --git a/Tusker/Screens/Compose/MainComposeTextView.swift b/Tusker/Screens/Compose/MainComposeTextView.swift index 1ef34ea1..f7d29904 100644 --- a/Tusker/Screens/Compose/MainComposeTextView.swift +++ b/Tusker/Screens/Compose/MainComposeTextView.swift @@ -10,7 +10,7 @@ import SwiftUI import Pachyderm struct MainComposeTextView: View, PlaceholderViewProvider { - @ObservedObject var draft: Draft + @ObservedObject var draft: OldDraft @State private var placeholder: PlaceholderView = Self.placeholderView() let minHeight: CGFloat = 150 diff --git a/Tusker/Screens/Compose/NewComposeHostingController.swift b/Tusker/Screens/Compose/NewComposeHostingController.swift new file mode 100644 index 00000000..77e3441a --- /dev/null +++ b/Tusker/Screens/Compose/NewComposeHostingController.swift @@ -0,0 +1,223 @@ +// +// NewComposeHostingController.swift +// Tusker +// +// Created by Shadowfacts on 3/6/23. +// Copyright © 2023 Shadowfacts. All rights reserved. +// + +import SwiftUI +import ComposeUI +import Combine +import PhotosUI +import PencilKit +import Pachyderm +import CoreData +import Duckable + +protocol NewComposeHostingControllerDelegate: AnyObject { + func dismissCompose(mode: DismissMode) -> Bool +} + +class NewComposeHostingController: UIHostingController, DuckableViewController { + + weak var delegate: NewComposeHostingControllerDelegate? + weak var duckableDelegate: DuckableViewControllerDelegate? + + let controller: ComposeController + let mastodonController: MastodonController + + private var assetPickerCompletion: (@MainActor ([PHPickerResult]) -> Void)? + private var drawingCompletion: ((PKDrawing) -> Void)? + + init(draft: Draft?, mastodonController: MastodonController) { + let draft = draft ?? mastodonController.createDraft() + DraftsManager.shared.add(draft) + + self.controller = ComposeController( + draft: draft, + config: ComposeUIConfig(), + mastodonController: mastodonController, + fetchAvatar: { await ImageCache.avatars.get($0).1 }, + fetchStatus: { mastodonController.persistentContainer.status(for: $0) }, + displayNameLabel: { AnyView(AccountDisplayNameLabel(account: $0, textStyle: $1, emojiSize: $2)) }, + replyContentView: { AnyView(ComposeReplyContentView(status: $0, mastodonController: mastodonController, heightChanged: $1)) }, + emojiImageView: { AnyView(CustomEmojiImageView(emoji: $0)) } + ) + controller.currentAccount = mastodonController.account + + self.mastodonController = mastodonController + + super.init(rootView: View(mastodonController: mastodonController, controller: controller)) + + self.updateConfig() + + pasteConfiguration = UIPasteConfiguration(forAccepting: ComposeUI.DraftAttachment.self) + + NotificationCenter.default.addObserver(self, selector: #selector(updateConfig), name: .preferencesChanged, object: nil) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func updateConfig() { + var config = ComposeUIConfig() + config.backgroundColor = .appBackground + config.groupedBackgroundColor = .appGroupedBackground + config.groupedCellBackgroundColor = .appGroupedCellBackground + config.fillColor = .appFill + switch Preferences.shared.avatarStyle { + case .roundRect: + config.avatarStyle = .roundRect + case .circle: + config.avatarStyle = .circle + } + + config.useTwitterKeyboard = Preferences.shared.useTwitterKeyboard + config.contentType = Preferences.shared.statusContentType + config.automaticallySaveDrafts = Preferences.shared.automaticallySaveDrafts + config.requireAttachmentDescriptions = Preferences.shared.requireAttachmentDescriptions + + config.dismiss = { [unowned self] in self.dismiss(mode: $0) } + config.presentAssetPicker = { [unowned self] in self.presentAssetPicker(completion: $0) } + config.presentDrawing = { [unowned self] in self.presentDrawing($0, completion: $1) } + config.userActivityForDraft = { [unowned self] in + let activity = UserActivityManager.editDraftActivity(id: $0.id, accountID: self.mastodonController.accountInfo!.id) + activity.displaysAuxiliaryScene = true + return NSItemProvider(object: activity) + } + + controller.config = config + } + + override func canPaste(_ itemProviders: [NSItemProvider]) -> Bool { + return controller.canPaste(itemProviders: itemProviders) + } + + override func paste(itemProviders: [NSItemProvider]) { + controller.paste(itemProviders: itemProviders) + } + + private func dismiss(mode: DismissMode) { + if delegate?.dismissCompose(mode: mode) == true { + return + } else { + dismiss(animated: true) + duckableDelegate?.duckableViewControllerWillDismiss(animated: true) + } + } + + private func presentAssetPicker(completion: @MainActor @escaping ([PHPickerResult]) -> Void) { + self.assetPickerCompletion = completion + + var config = PHPickerConfiguration() + config.selection = .ordered + config.selectionLimit = 0 + config.preferredAssetRepresentationMode = .compatible + let picker = PHPickerViewController(configuration: config) + picker.delegate = self + picker.modalPresentationStyle = .pageSheet + picker.overrideUserInterfaceStyle = .dark + // sheet detents don't play nice with PHPickerViewController, see +// let sheet = picker.sheetPresentationController! +// sheet.detents = [.medium(), .large()] +// sheet.prefersEdgeAttachedInCompactHeight = true +// sheet.prefersGrabberVisible = true + present(picker, animated: true) + } + + private func presentDrawing(_ drawing: PKDrawing, completion: @escaping (PKDrawing) -> Void) { + self.drawingCompletion = completion + + present(ComposeDrawingNavigationController(editing: drawing, delegate: self), animated: true) + } + + // MARK: Duckable + + func duckableViewControllerWillAnimateDuck(withDuration duration: CGFloat, afterDelay delay: CGFloat) { + withAnimation(.linear(duration: duration).delay(delay)) { + controller.showToolbar = false + } + } + + func duckableViewControllerDidFinishAnimatingDuck() { + controller.showToolbar = true + } + + struct View: SwiftUI.View { + let mastodonController: MastodonController + let controller: ComposeController + + var body: some SwiftUI.View { + ControllerView(controller: { controller }) + .task { + if let account = try? await mastodonController.getOwnAccount() { + controller.currentAccount = account + } + } + } + } +} + +extension MastodonController: ComposeMastodonContext { + @MainActor + func searchCachedAccounts(query: String) -> [AccountProtocol] { + // todo: there's got to be something more efficient than this :/ + let wildcardedQuery = query.map { "*\($0)" }.joined() + "*" + let request: NSFetchRequest = AccountMO.fetchRequest() + request.predicate = NSPredicate(format: "displayName LIKE[cd] %@ OR acct LIKE[cd] %@", wildcardedQuery, wildcardedQuery) + + if let results = try? persistentContainer.viewContext.fetch(request) { + return results + } else { + return [] + } + } + + @MainActor + func cachedRelationship(for accountID: String) -> RelationshipProtocol? { + return persistentContainer.relationship(forAccount: accountID) + } + + @MainActor + func searchCachedHashtags(query: String) -> [Hashtag] { + let wildcardedQuery = query.map { "*\($0)" }.joined() + "*" + let predicate = NSPredicate(format: "name LIKE[cd] %@", wildcardedQuery) + let savedReq = SavedHashtag.fetchRequest(account: accountInfo!) + savedReq.predicate = predicate + let followedReq = FollowedHashtag.fetchRequest() + followedReq.predicate = predicate + + let saved = try? persistentContainer.viewContext.fetch(savedReq).map { Hashtag(name: $0.name, url: $0.url) } + let followed = try? persistentContainer.viewContext.fetch(followedReq).map { Hashtag(name: $0.name, url: $0.url) } + + var results = saved ?? [] + if let followed { + results.append(contentsOf: followed) + } + return results + } +} + +extension NewComposeHostingController: PHPickerViewControllerDelegate { + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + dismiss(animated: true) + + assetPickerCompletion?(results) + assetPickerCompletion = nil + } +} + +extension NewComposeHostingController: ComposeDrawingViewControllerDelegate { + func composeDrawingViewControllerClose(_ drawingController: ComposeDrawingViewController) { + dismiss(animated: true) + drawingCompletion = nil + } + + func composeDrawingViewController(_ drawingController: ComposeDrawingViewController, saveDrawing drawing: PKDrawing) { + dismiss(animated: true) + drawingCompletion?(drawing) + drawingCompletion = nil + } +} diff --git a/Tusker/Screens/Main/AccountSwitchingContainerViewController.swift b/Tusker/Screens/Main/AccountSwitchingContainerViewController.swift index 29a0b767..1b325867 100644 --- a/Tusker/Screens/Main/AccountSwitchingContainerViewController.swift +++ b/Tusker/Screens/Main/AccountSwitchingContainerViewController.swift @@ -9,6 +9,7 @@ import UIKit import ScreenCorners import UserAccounts +import ComposeUI class AccountSwitchingContainerViewController: UIViewController { diff --git a/Tusker/Screens/Main/Duckable+Root.swift b/Tusker/Screens/Main/Duckable+Root.swift index d740c9d5..a10e40bd 100644 --- a/Tusker/Screens/Main/Duckable+Root.swift +++ b/Tusker/Screens/Main/Duckable+Root.swift @@ -8,14 +8,15 @@ import UIKit import Duckable +import ComposeUI @available(iOS 16.0, *) extension DuckableContainerViewController: TuskerRootViewController { func stateRestorationActivity() -> NSUserActivity? { var activity = (child as? TuskerRootViewController)?.stateRestorationActivity() - if let compose = duckedViewController as? ComposeHostingController, - compose.draft.hasContent { - activity = UserActivityManager.addDuckedDraft(to: activity, draft: compose.draft) + if let compose = duckedViewController as? NewComposeHostingController, + compose.controller.draft.hasContent { + activity = UserActivityManager.addDuckedDraft(to: activity, draft: compose.controller.draft) } return activity } diff --git a/Tusker/Screens/Main/MainTabBarViewController.swift b/Tusker/Screens/Main/MainTabBarViewController.swift index a6a950c4..eefdfc3b 100644 --- a/Tusker/Screens/Main/MainTabBarViewController.swift +++ b/Tusker/Screens/Main/MainTabBarViewController.swift @@ -7,6 +7,7 @@ // import UIKit +import ComposeUI class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate { diff --git a/Tusker/Screens/Main/TuskerRootViewController.swift b/Tusker/Screens/Main/TuskerRootViewController.swift index 861e664d..38c9171b 100644 --- a/Tusker/Screens/Main/TuskerRootViewController.swift +++ b/Tusker/Screens/Main/TuskerRootViewController.swift @@ -7,6 +7,7 @@ // import UIKit +import ComposeUI @MainActor protocol TuskerRootViewController: UIViewController, StateRestorableViewController, StatusBarTappableViewController { diff --git a/Tusker/Screens/Utilities/Previewing.swift b/Tusker/Screens/Utilities/Previewing.swift index 1ecca0e5..b72d555f 100644 --- a/Tusker/Screens/Utilities/Previewing.swift +++ b/Tusker/Screens/Utilities/Previewing.swift @@ -358,16 +358,16 @@ extension MenuActionProvider { }), createAction(identifier: "postlink", title: "Post this Link", systemImageName: "square.and.pencil", handler: { [weak self] _ in guard let self = self else { return } - let draft = self.mastodonController!.createDraft() + + var text = "" let title = card.title.trimmingCharacters(in: .whitespacesAndNewlines) if !title.isEmpty { - draft.text += title - draft.text += ":\n" + text += title + text += ":\n" } - draft.text += url.absoluteString - // prevents the draft from being saved automatically until the user makes a change - // also prevents it from being posted without being changed - draft.initialText = draft.text + text += url.absoluteString + + let draft = self.mastodonController!.createDraft(text: text) self.navigationDelegate?.compose(editing: draft) }) ] diff --git a/Tusker/Shortcuts/UserActivityHandlingContext.swift b/Tusker/Shortcuts/UserActivityHandlingContext.swift index 9931d962..4bf6e663 100644 --- a/Tusker/Shortcuts/UserActivityHandlingContext.swift +++ b/Tusker/Shortcuts/UserActivityHandlingContext.swift @@ -8,6 +8,7 @@ import UIKit import Duckable +import ComposeUI @MainActor protocol UserActivityHandlingContext { diff --git a/Tusker/Shortcuts/UserActivityManager.swift b/Tusker/Shortcuts/UserActivityManager.swift index eaa3eff0..d4c82ad5 100644 --- a/Tusker/Shortcuts/UserActivityManager.swift +++ b/Tusker/Shortcuts/UserActivityManager.swift @@ -11,6 +11,7 @@ import Intents import Pachyderm import OSLog import UserAccounts +import ComposeUI private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "UserActivityManager") diff --git a/Tusker/TuskerNavigationDelegate.swift b/Tusker/TuskerNavigationDelegate.swift index 4e0a2172..29b57cde 100644 --- a/Tusker/TuskerNavigationDelegate.swift +++ b/Tusker/TuskerNavigationDelegate.swift @@ -9,6 +9,7 @@ import UIKit import SafariServices import Pachyderm +import ComposeUI @MainActor protocol TuskerNavigationDelegate: UIViewController, ToastableViewController { @@ -97,16 +98,26 @@ extension TuskerNavigationDelegate { options.preferredPresentationStyle = .prominent UIApplication.shared.requestSceneSessionActivation(nil, userActivity: compose, options: options, errorHandler: nil) } else { - let compose = ComposeHostingController(draft: draft, mastodonController: apiController) + let compose = NewComposeHostingController(draft: draft, mastodonController: apiController) if #available(iOS 16.0, *), presentDuckable(compose, animated: animated, isDucked: isDucked) { return } else { - let compose = ComposeHostingController(draft: draft, mastodonController: apiController) let nav = UINavigationController(rootViewController: compose) - nav.presentationController?.delegate = compose + // TODO: is this still necessary? +// nav.presentationController?.delegate = compose present(nav, animated: animated) } +// let compose = ComposeHostingController(draft: draft, mastodonController: apiController) +// if #available(iOS 16.0, *), +// presentDuckable(compose, animated: animated, isDucked: isDucked) { +// return +// } else { +// let compose = ComposeHostingController(draft: draft, mastodonController: apiController) +// let nav = UINavigationController(rootViewController: compose) +// nav.presentationController?.delegate = compose +// present(nav, animated: animated) +// } } } diff --git a/Tusker/Views/StatusContentTextView.swift b/Tusker/Views/StatusContentTextView.swift index ceb1ca45..05f3677b 100644 --- a/Tusker/Views/StatusContentTextView.swift +++ b/Tusker/Views/StatusContentTextView.swift @@ -14,7 +14,7 @@ class StatusContentTextView: ContentTextView { private var statusID: String? - func setTextFrom(status: StatusMO, precomputed attributedText: NSAttributedString? = nil) { + func setTextFrom(status: some StatusProtocol, precomputed attributedText: NSAttributedString? = nil) { statusID = status.id if let attributedText { self.attributedText = attributedText diff --git a/TuskerUITests/ComposeTests.swift b/TuskerUITests/ComposeTests.swift index ef96222f..546fe031 100644 --- a/TuskerUITests/ComposeTests.swift +++ b/TuskerUITests/ComposeTests.swift @@ -34,28 +34,28 @@ class ComposeTests: TuskerUITests { XCTAssertFalse(app.staticTexts["What's on your mind?"].exists, "placeholder does not exist") } - func testCharacterCounter() { - XCTAssertTrue(app.staticTexts["500 characters remaining"].exists, "initial character count is 500") - let textView = app.textViews.firstMatch - - let fragments = [ - "Hello", - "World", - "@admin", - "@admin@example.com", - "https://foo.example.com/?bar=baz#qux", - ] - - var remaining = 500 - for s in fragments { - let length = CharacterCounter.count(text: s) - // add 1 for newline - remaining -= length + 1 - - textView.typeText(s + "\n") - XCTAssertTrue(app.staticTexts["\(remaining) characters remaining"].exists, "subsequent character count is \(remaining)") - } - } +// func testCharacterCounter() { +// XCTAssertTrue(app.staticTexts["500 characters remaining"].exists, "initial character count is 500") +// let textView = app.textViews.firstMatch +// +// let fragments = [ +// "Hello", +// "World", +// "@admin", +// "@admin@example.com", +// "https://foo.example.com/?bar=baz#qux", +// ] +// +// var remaining = 500 +// for s in fragments { +// let length = CharacterCounter.count(text: s) +// // add 1 for newline +// remaining -= length + 1 +// +// textView.typeText(s + "\n") +// XCTAssertTrue(app.staticTexts["\(remaining) characters remaining"].exists, "subsequent character count is \(remaining)") +// } +// } // func testToolbarSwitching() { // // text view is automatically focused, so unfocus From 6b4223a9d6d287871cc98e417f3654df98dda582 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sun, 16 Apr 2023 13:31:10 -0400 Subject: [PATCH 09/11] Migrate drafts to new file --- .../Sources/ComposeUI/Model/DraftsManager.swift | 11 +++++++++++ Tusker/AppDelegate.swift | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/Packages/ComposeUI/Sources/ComposeUI/Model/DraftsManager.swift b/Packages/ComposeUI/Sources/ComposeUI/Model/DraftsManager.swift index ef46ef08..5670f530 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Model/DraftsManager.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Model/DraftsManager.swift @@ -35,6 +35,17 @@ public class DraftsManager: Codable, ObservableObject { return DraftsManager() } + public static func migrate(from url: URL) -> Result { + do { + try? FileManager.default.removeItem(at: archiveURL) + try FileManager.default.moveItem(at: url, to: archiveURL) + } catch { + return .failure(error) + } + shared = load() + return .success(()) + } + private init() {} public required init(from decoder: Decoder) throws { diff --git a/Tusker/AppDelegate.swift b/Tusker/AppDelegate.swift index d4141592..aa64b5a8 100644 --- a/Tusker/AppDelegate.swift +++ b/Tusker/AppDelegate.swift @@ -11,6 +11,7 @@ import CoreData import OSLog import Sentry import UserAccounts +import ComposeUI let stateRestorationLogger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "StateRestoration") @@ -47,6 +48,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // no-op } } + + DispatchQueue.global(qos: .userInitiated).async { + let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + let oldDraftsFile = documentsDirectory.appendingPathComponent("drafts").appendingPathExtension("plist") + if FileManager.default.fileExists(atPath: oldDraftsFile.path) { + if case .failure(let error) = DraftsManager.migrate(from: oldDraftsFile) { + SentrySDK.capture(error: error) + } + } + } return true } From afed157f29962e79aa265e4ddd42a9418993995e Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sun, 16 Apr 2023 13:47:06 -0400 Subject: [PATCH 10/11] Remove old compose screen code --- .../Sources/ComposeUI/ComposeUIConfig.swift | 15 +- .../AutocompleteMentionsController.swift | 9 +- .../Controllers/ComposeController.swift | 6 +- .../ComposeUI/Views/AvatarImageView.swift | 42 -- .../ComposeUI/Views/CurrentAccountView.swift | 10 +- .../ComposeUI/Views/ReplyStatusView.swift | 12 +- .../TuskerComponents/AvatarImageView.swift | 65 +++ Tusker.xcodeproj/project.pbxproj | 84 ---- Tusker/API/PostService.swift | 136 ------ Tusker/Models/DraftsManager.swift | 77 --- Tusker/Models/OldDraft.swift | 226 --------- Tusker/Scenes/ComposeSceneDelegate.swift | 6 +- Tusker/Scenes/MainSceneDelegate.swift | 5 +- .../Screens/Compose/ComposeAssetPicker.swift | 30 -- .../Compose/ComposeAttachmentImage.swift | 123 ----- .../Compose/ComposeAttachmentRow.swift | 164 ------- .../Compose/ComposeAttachmentsList.swift | 210 --------- .../Compose/ComposeAutocompleteView.swift | 424 ----------------- .../Compose/ComposeAvatarImageView.swift | 64 --- .../Compose/ComposeCurrentAccount.swift | 49 -- .../Compose/ComposeEmojiTextField.swift | 269 ----------- .../Compose/ComposeHostingController.swift | 278 ----------- Tusker/Screens/Compose/ComposePollView.swift | 232 --------- Tusker/Screens/Compose/ComposeReplyView.swift | 100 ---- Tusker/Screens/Compose/ComposeTextView.swift | 137 ------ .../ComposeTextViewCaretScrolling.swift | 60 --- Tusker/Screens/Compose/ComposeToolbar.swift | 149 ------ Tusker/Screens/Compose/ComposeUIState.swift | 80 ---- Tusker/Screens/Compose/ComposeView.swift | 376 --------------- Tusker/Screens/Compose/DraftsView.swift | 145 ------ .../Screens/Compose/MainComposeTextView.swift | 443 ------------------ .../Customize Timelines/EditFilterView.swift | 12 + .../Main/MainTabBarViewController.swift | 7 +- Tusker/Screens/Mute/MuteAccountView.swift | 9 +- Tusker/Screens/Report/ReportView.swift | 28 +- 35 files changed, 144 insertions(+), 3938 deletions(-) delete mode 100644 Packages/ComposeUI/Sources/ComposeUI/Views/AvatarImageView.swift create mode 100644 Packages/TuskerComponents/Sources/TuskerComponents/AvatarImageView.swift delete mode 100644 Tusker/API/PostService.swift delete mode 100644 Tusker/Models/DraftsManager.swift delete mode 100644 Tusker/Models/OldDraft.swift delete mode 100644 Tusker/Screens/Compose/ComposeAssetPicker.swift delete mode 100644 Tusker/Screens/Compose/ComposeAttachmentImage.swift delete mode 100644 Tusker/Screens/Compose/ComposeAttachmentRow.swift delete mode 100644 Tusker/Screens/Compose/ComposeAttachmentsList.swift delete mode 100644 Tusker/Screens/Compose/ComposeAutocompleteView.swift delete mode 100644 Tusker/Screens/Compose/ComposeAvatarImageView.swift delete mode 100644 Tusker/Screens/Compose/ComposeCurrentAccount.swift delete mode 100644 Tusker/Screens/Compose/ComposeEmojiTextField.swift delete mode 100644 Tusker/Screens/Compose/ComposeHostingController.swift delete mode 100644 Tusker/Screens/Compose/ComposePollView.swift delete mode 100644 Tusker/Screens/Compose/ComposeReplyView.swift delete mode 100644 Tusker/Screens/Compose/ComposeTextView.swift delete mode 100644 Tusker/Screens/Compose/ComposeTextViewCaretScrolling.swift delete mode 100644 Tusker/Screens/Compose/ComposeToolbar.swift delete mode 100644 Tusker/Screens/Compose/ComposeUIState.swift delete mode 100644 Tusker/Screens/Compose/ComposeView.swift delete mode 100644 Tusker/Screens/Compose/DraftsView.swift delete mode 100644 Tusker/Screens/Compose/MainComposeTextView.swift diff --git a/Packages/ComposeUI/Sources/ComposeUI/ComposeUIConfig.swift b/Packages/ComposeUI/Sources/ComposeUI/ComposeUIConfig.swift index f94f62df..0cc3694a 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/ComposeUIConfig.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/ComposeUIConfig.swift @@ -9,13 +9,14 @@ import SwiftUI import Pachyderm import PhotosUI import PencilKit +import TuskerComponents public struct ComposeUIConfig { public var backgroundColor = Color(uiColor: .systemBackground) public var groupedBackgroundColor = Color(uiColor: .systemGroupedBackground) public var groupedCellBackgroundColor = Color(uiColor: .systemBackground) public var fillColor = Color(uiColor: .systemFill) - public var avatarStyle = AvatarStyle.roundRect + public var avatarStyle = AvatarImageView.Style.roundRect public var useTwitterKeyboard = false public var contentType = StatusContentType.plain public var automaticallySaveDrafts = false @@ -31,16 +32,4 @@ public struct ComposeUIConfig { } extension ComposeUIConfig { - public enum AvatarStyle: Equatable { - case roundRect, circle - - var cornerRadiusFraction: CGFloat { - switch self { - case .roundRect: - return 0.1 - case .circle: - return 0.5 - } - } - } } diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteMentionsController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteMentionsController.swift index 2e63c5fe..0acfc1cd 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteMentionsController.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteMentionsController.swift @@ -8,6 +8,7 @@ import SwiftUI import Combine import Pachyderm +import TuskerComponents class AutocompleteMentionsController: ViewController { @@ -136,13 +137,19 @@ class AutocompleteMentionsController: ViewController { } private struct AutocompleteMentionButton: View { + @EnvironmentObject private var composeController: ComposeController @EnvironmentObject private var controller: AutocompleteMentionsController let account: AnyAccount var body: some View { Button(action: { controller.autocomplete(with: account) }) { HStack(spacing: 4) { - AvatarImageView(url: account.value.avatar, size: 30) + AvatarImageView( + url: account.value.avatar, + size: 30, + style: composeController.config.avatarStyle, + fetchAvatar: composeController.fetchAvatar + ) VStack(alignment: .leading) { controller.composeController.displayNameLabel(account.value, .subheadline, 14) diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift index 2088c456..ceb8a227 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift @@ -8,9 +8,9 @@ import SwiftUI import Combine import Pachyderm +import TuskerComponents public final class ComposeController: ViewController { - public typealias FetchAvatar = (URL) async -> UIImage? public typealias FetchStatus = (String) -> (any StatusProtocol)? public typealias DisplayNameLabel = (any AccountProtocol, Font.TextStyle, CGFloat) -> AnyView public typealias ReplyContentView = (any StatusProtocol, @escaping (CGFloat) -> Void) -> AnyView @@ -19,7 +19,7 @@ public final class ComposeController: ViewController { @Published public private(set) var draft: Draft @Published public var config: ComposeUIConfig let mastodonController: ComposeMastodonContext - let fetchAvatar: FetchAvatar + let fetchAvatar: AvatarImageView.FetchAvatar let fetchStatus: FetchStatus let displayNameLabel: DisplayNameLabel let replyContentView: ReplyContentView @@ -68,7 +68,7 @@ public final class ComposeController: ViewController { draft: Draft, config: ComposeUIConfig, mastodonController: ComposeMastodonContext, - fetchAvatar: @escaping FetchAvatar, + fetchAvatar: @escaping AvatarImageView.FetchAvatar, fetchStatus: @escaping FetchStatus, displayNameLabel: @escaping DisplayNameLabel, replyContentView: @escaping ReplyContentView, diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/AvatarImageView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/AvatarImageView.swift deleted file mode 100644 index 993e81fe..00000000 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/AvatarImageView.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// AvatarImageView.swift -// ComposeUI -// -// Created by Shadowfacts on 3/4/23. -// - -import SwiftUI - -struct AvatarImageView: View { - let url: URL? - let size: CGFloat - @State private var image: UIImage? - @EnvironmentObject private var controller: ComposeController - - var body: some View { - imageView - .resizable() - .frame(width: size, height: size) - .cornerRadius(controller.config.avatarStyle.cornerRadiusFraction * size) - .task { - if let url { - image = await controller.fetchAvatar(url) - } - } - // tell swiftui that this view has changed (and therefore the task needs to re-run) when the url changes - .id(url) - - } - - private var imageView: Image { - if let image { - return Image(uiImage: image) - } else { - return placeholder - } - } - - private var placeholder: Image { - Image(systemName: controller.config.avatarStyle == .roundRect ? "person.crop.square" : "person.crop.circle") - } -} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/CurrentAccountView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/CurrentAccountView.swift index 87f6b2af..16ef5aed 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/CurrentAccountView.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/CurrentAccountView.swift @@ -7,6 +7,7 @@ import SwiftUI import Pachyderm +import TuskerComponents struct CurrentAccountView: View { let account: (any AccountProtocol)? @@ -14,8 +15,13 @@ struct CurrentAccountView: View { var body: some View { HStack(alignment: .top) { - AvatarImageView(url: account?.avatar, size: 50) - .accessibilityHidden(true) + AvatarImageView( + url: account?.avatar, + size: 50, + style: controller.config.avatarStyle, + fetchAvatar: controller.fetchAvatar + ) + .accessibilityHidden(true) if let account { VStack(alignment: .leading) { diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/ReplyStatusView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/ReplyStatusView.swift index bf0e3b58..91706952 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/ReplyStatusView.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/ReplyStatusView.swift @@ -7,6 +7,7 @@ import SwiftUI import Pachyderm +import TuskerComponents struct ReplyStatusView: View { let status: any StatusProtocol @@ -75,9 +76,14 @@ struct ReplyStatusView: View { // once you scroll past the in-reply-to-content, the bottom of the avatar should be pinned to the bottom of the content offset = min(offset, maxOffset) - return AvatarImageView(url: status.account.avatar, size: 50) - .offset(x: 0, y: offset) - .accessibilityHidden(true) + return AvatarImageView( + url: status.account.avatar, + size: 50, + style: controller.config.avatarStyle, + fetchAvatar: controller.fetchAvatar + ) + .offset(x: 0, y: offset) + .accessibilityHidden(true) } } diff --git a/Packages/TuskerComponents/Sources/TuskerComponents/AvatarImageView.swift b/Packages/TuskerComponents/Sources/TuskerComponents/AvatarImageView.swift new file mode 100644 index 00000000..868c4d07 --- /dev/null +++ b/Packages/TuskerComponents/Sources/TuskerComponents/AvatarImageView.swift @@ -0,0 +1,65 @@ +// +// AvatarImageView.swift +// ComposeUI +// +// Created by Shadowfacts on 3/4/23. +// + +import SwiftUI + +public struct AvatarImageView: View { + public typealias FetchAvatar = (URL) async -> UIImage? + + let url: URL? + let size: CGFloat + let style: Style + let fetchAvatar: FetchAvatar + @State private var image: UIImage? + + public init(url: URL?, size: CGFloat, style: Style, fetchAvatar: @escaping FetchAvatar) { + self.url = url + self.size = size + self.style = style + self.fetchAvatar = fetchAvatar + } + + public var body: some View { + imageView + .resizable() + .frame(width: size, height: size) + .cornerRadius(style.cornerRadiusFraction * size) + .task { + if let url { + image = await fetchAvatar(url) + } + } + // tell swiftui that this view has changed (and therefore the task needs to re-run) when the url changes + .id(url) + + } + + private var imageView: Image { + if let image { + return Image(uiImage: image) + } else { + return placeholder + } + } + + private var placeholder: Image { + Image(systemName: style == .roundRect ? "person.crop.square" : "person.crop.circle") + } + + public enum Style: Equatable { + case roundRect, circle + + var cornerRadiusFraction: CGFloat { + switch self { + case .roundRect: + return 0.1 + case .circle: + return 0.5 + } + } + } +} diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index f27dd33b..7a33d5a4 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -81,13 +81,7 @@ D620483623D38075008A63EF /* ContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483523D38075008A63EF /* ContentTextView.swift */; }; D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483723D38190008A63EF /* StatusContentTextView.swift */; }; D621733328F1D5ED004C7DB1 /* ReblogService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D621733228F1D5ED004C7DB1 /* ReblogService.swift */; }; - D622757424EDF1CD00B82A16 /* ComposeAttachmentsList.swift in Sources */ = {isa = PBXBuildFile; fileRef = D622757324EDF1CD00B82A16 /* ComposeAttachmentsList.swift */; }; - D622757824EE133700B82A16 /* ComposeAssetPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = D622757724EE133700B82A16 /* ComposeAssetPicker.swift */; }; - D622757A24EE21D900B82A16 /* ComposeAttachmentRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D622757924EE21D900B82A16 /* ComposeAttachmentRow.swift */; }; - D62275A024F1677200B82A16 /* ComposeHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D622759F24F1677200B82A16 /* ComposeHostingController.swift */; }; - D62275A624F1C81800B82A16 /* ComposeReplyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62275A524F1C81800B82A16 /* ComposeReplyView.swift */; }; D62275A824F1CA2800B82A16 /* ComposeReplyContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */; }; - D62275AA24F1E01C00B82A16 /* ComposeTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62275A924F1E01C00B82A16 /* ComposeTextView.swift */; }; D623A53D2635F5590095BD04 /* StatusPollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D623A53C2635F5590095BD04 /* StatusPollView.swift */; }; D623A5412635FB3C0095BD04 /* PollOptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D623A5402635FB3C0095BD04 /* PollOptionView.swift */; }; D623A543263634100095BD04 /* PollOptionCheckboxView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D623A542263634100095BD04 /* PollOptionCheckboxView.swift */; }; @@ -104,7 +98,6 @@ D627944723A6AC9300D38C68 /* BasicTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D627944623A6AC9300D38C68 /* BasicTableViewCell.xib */; }; D627944D23A9A03D00D38C68 /* ListTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D627944C23A9A03D00D38C68 /* ListTimelineViewController.swift */; }; D627944F23A9C99800D38C68 /* EditListAccountsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D627944E23A9C99800D38C68 /* EditListAccountsViewController.swift */; }; - D627FF76217E923E00CC0648 /* DraftsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D627FF75217E923E00CC0648 /* DraftsManager.swift */; }; D6285B5321EA708700FE4B39 /* StatusFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6285B5221EA708700FE4B39 /* StatusFormat.swift */; }; D6289E84217B795D0003D1D7 /* LargeImageViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6289E83217B795D0003D1D7 /* LargeImageViewController.xib */; }; D62D2422217AA7E1005076CC /* UserActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62D2421217AA7E1005076CC /* UserActivityManager.swift */; }; @@ -168,7 +161,6 @@ D6620ACE2511A0ED00312CA0 /* StatusStateResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6620ACD2511A0ED00312CA0 /* StatusStateResolver.swift */; }; D662AEEF263A3B880082A153 /* PollFinishedTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D662AEED263A3B880082A153 /* PollFinishedTableViewCell.swift */; }; D662AEF0263A3B880082A153 /* PollFinishedTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D662AEEE263A3B880082A153 /* PollFinishedTableViewCell.xib */; }; - D662AEF2263A4BE10082A153 /* ComposePollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D662AEF1263A4BE10082A153 /* ComposePollView.swift */; }; D663626221360B1900C9CBA2 /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = D663626121360B1900C9CBA2 /* Preferences.swift */; }; D663626421360D2300C9CBA2 /* AvatarStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = D663626321360D2300C9CBA2 /* AvatarStyle.swift */; }; D663626C21361C6700C9CBA2 /* Account+Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = D663626B21361C6700C9CBA2 /* Account+Preferences.swift */; }; @@ -180,10 +172,6 @@ D66A77BB233838DC0058F1EC /* UIFont+Traits.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66A77BA233838DC0058F1EC /* UIFont+Traits.swift */; }; D66C900B28DAB7FD00217BF2 /* TimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */; }; D674A50927F9128D00BA03AC /* Pachyderm in Frameworks */ = {isa = PBXBuildFile; productRef = D674A50827F9128D00BA03AC /* Pachyderm */; }; - D677284824ECBCB100C732D3 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284724ECBCB100C732D3 /* ComposeView.swift */; }; - D677284A24ECBDF400C732D3 /* ComposeCurrentAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */; }; - D677284C24ECBE9100C732D3 /* ComposeAvatarImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284B24ECBE9100C732D3 /* ComposeAvatarImageView.swift */; }; - D677284E24ECC01D00C732D3 /* OldDraft.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284D24ECC01D00C732D3 /* OldDraft.swift */; }; D67895BC24671E6D00D4CD9E /* PKDrawing+Render.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67895BB24671E6D00D4CD9E /* PKDrawing+Render.swift */; }; D67895C0246870DE00D4CD9E /* LocalAccountAvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67895BF246870DE00D4CD9E /* LocalAccountAvatarView.swift */; }; D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67B506C250B291200FAECFB /* BlurHashDecode.swift */; }; @@ -248,7 +236,6 @@ D6A4DCCC2553667800D9DE31 /* FastAccountSwitcherViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A4DCCA2553667800D9DE31 /* FastAccountSwitcherViewController.swift */; }; D6A4DCCD2553667800D9DE31 /* FastAccountSwitcherViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6A4DCCB2553667800D9DE31 /* FastAccountSwitcherViewController.xib */; }; D6A4DCE525537C7A00D9DE31 /* FastSwitchingAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A4DCE425537C7A00D9DE31 /* FastSwitchingAccountView.swift */; }; - D6A57408255C53EC00674551 /* ComposeTextViewCaretScrolling.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A57407255C53EC00674551 /* ComposeTextViewCaretScrolling.swift */; }; D6A5BB2B23BAEF61003BF21D /* APIMocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A5BB2A23BAEF61003BF21D /* APIMocks.swift */; }; D6A5BB2D23BBA9C4003BF21D /* MyProfileTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A5BB2C23BBA9C4003BF21D /* MyProfileTests.swift */; }; D6A5BB2F23BBAC97003BF21D /* DelegatingResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A5BB2E23BBAC97003BF21D /* DelegatingResponse.swift */; }; @@ -292,9 +279,7 @@ D6BD395B29B64441005FFD2B /* NewComposeHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BD395A29B64441005FFD2B /* NewComposeHostingController.swift */; }; D6BEA245291A0EDE002F4D01 /* Duckable in Frameworks */ = {isa = PBXBuildFile; productRef = D6BEA244291A0EDE002F4D01 /* Duckable */; }; D6BEA247291A0F2D002F4D01 /* Duckable+Root.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BEA246291A0F2D002F4D01 /* Duckable+Root.swift */; }; - D6BEA249291C6118002F4D01 /* DraftsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BEA248291C6118002F4D01 /* DraftsView.swift */; }; D6BED174212667E900F02DA0 /* TimelineStatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */; }; - D6C143DA253510F4007DC240 /* ComposeEmojiTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */; }; D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */; }; D6C3F4F5298ED0890009FCFF /* LocalPredicateStatusesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F4F4298ED0890009FCFF /* LocalPredicateStatusesViewController.swift */; }; D6C3F4F7298ED7F70009FCFF /* FavoritesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F4F6298ED7F70009FCFF /* FavoritesViewController.swift */; }; @@ -309,7 +294,6 @@ D6C82B4125C5BB7E0017F1E6 /* ExploreViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C82B4025C5BB7E0017F1E6 /* ExploreViewController.swift */; }; D6C94D872139E62700CB5196 /* LargeImageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C94D862139E62700CB5196 /* LargeImageViewController.swift */; }; D6C94D892139E6EC00CB5196 /* AttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C94D882139E6EC00CB5196 /* AttachmentView.swift */; }; - D6C99FCB24FADC91005C74D3 /* MainComposeTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C99FCA24FADC91005C74D3 /* MainComposeTextView.swift */; }; D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */; }; D6CA6A94249FADE700AD45C1 /* GalleryPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */; }; D6CA8CDA2962231F0050C433 /* ArrayUniqueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA8CD92962231F0050C433 /* ArrayUniqueTests.swift */; }; @@ -319,7 +303,6 @@ D6D12B58292D5B2C00D528E1 /* StatusActionAccountListCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B57292D5B2C00D528E1 /* StatusActionAccountListCollectionViewController.swift */; }; D6D12B5A292D684600D528E1 /* AccountListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B59292D684600D528E1 /* AccountListViewController.swift */; }; D6D3F4C424FDB6B700EC4A6A /* View+ConditionalModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D3F4C324FDB6B700EC4A6A /* View+ConditionalModifier.swift */; }; - D6D3FDE224F46A8D00FF50A5 /* ComposeUIState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D3FDE124F46A8D00FF50A5 /* ComposeUIState.swift */; }; D6D4CC94250DB86A00FCCF8D /* ComposeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4CC93250DB86A00FCCF8D /* ComposeTests.swift */; }; D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4DDCF212518A000E1C4BB /* AppDelegate.swift */; }; D6D4DDD7212518A200E1C4BB /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D6D4DDD6212518A200E1C4BB /* Assets.xcassets */; }; @@ -331,7 +314,6 @@ D6D94955298963A900C59229 /* Colors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D94954298963A900C59229 /* Colors.swift */; }; D6D9498D298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */; }; D6D9498F298EB79400C59229 /* CopyableLable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498E298EB79400C59229 /* CopyableLable.swift */; }; - D6DD2A3F273C1F4900386A6C /* ComposeAttachmentImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD2A3E273C1F4900386A6C /* ComposeAttachmentImage.swift */; }; D6DD353D22F28CD000A9563A /* ContentWarningCopyMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */; }; D6DD353F22F502EC00A9563A /* Preferences+Notification.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD353E22F502EC00A9563A /* Preferences+Notification.swift */; }; D6DD8FFD298495A8002AD3FD /* LogoutService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD8FFC298495A8002AD3FD /* LogoutService.swift */; }; @@ -346,7 +328,6 @@ D6E343B0265AAD6B00C4AA01 /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D6E343AE265AAD6B00C4AA01 /* MainInterface.storyboard */; }; D6E343B4265AAD6B00C4AA01 /* OpenInTusker.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D6E343A8265AAD6B00C4AA01 /* OpenInTusker.appex */; platformFilter = ios; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; D6E343BA265AAD8C00C4AA01 /* Action.js in Resources */ = {isa = PBXBuildFile; fileRef = D6E343B9265AAD8C00C4AA01 /* Action.js */; }; - D6E4267725327FB400C02E1C /* ComposeAutocompleteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E4267625327FB400C02E1C /* ComposeAutocompleteView.swift */; }; D6E4269D2532A3E100C02E1C /* FuzzyMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E4269C2532A3E100C02E1C /* FuzzyMatcher.swift */; }; D6E426AD25334DA500C02E1C /* FuzzyMatcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E426AC25334DA500C02E1C /* FuzzyMatcherTests.swift */; }; D6E426B325337C7000C02E1C /* CustomEmojiImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */; }; @@ -355,7 +336,6 @@ D6E77D0B286D426E00D8B732 /* TrendingLinkCardCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E77D0A286D426E00D8B732 /* TrendingLinkCardCollectionViewCell.swift */; }; D6E77D0D286E6B7300D8B732 /* TrendingLinkCardCollectionViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6E77D0C286E6B7300D8B732 /* TrendingLinkCardCollectionViewCell.xib */; }; D6E77D0F286F773900D8B732 /* SplitNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E77D0E286F773900D8B732 /* SplitNavigationController.swift */; }; - D6E9CDA8281A427800BBC98E /* PostService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E9CDA7281A427800BBC98E /* PostService.swift */; }; D6EAE0DB2550CC8A002DB0AC /* FocusableTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EAE0DA2550CC8A002DB0AC /* FocusableTextField.swift */; }; D6EBF01523C55C0900AE061B /* UIApplication+Scenes.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EBF01423C55C0900AE061B /* UIApplication+Scenes.swift */; }; D6EBF01723C55E0D00AE061B /* UISceneSession+MastodonController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EBF01623C55E0D00AE061B /* UISceneSession+MastodonController.swift */; }; @@ -368,7 +348,6 @@ D6F6A552291F098700F496A8 /* RenameListService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A551291F098700F496A8 /* RenameListService.swift */; }; D6F6A554291F0D9600F496A8 /* DeleteListService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A553291F0D9600F496A8 /* DeleteListService.swift */; }; D6F6A557291F4F1600F496A8 /* MuteAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A556291F4F1600F496A8 /* MuteAccountView.swift */; }; - D6F6A5592920676800F496A8 /* ComposeToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A5582920676800F496A8 /* ComposeToolbar.swift */; }; D6F953F021251A2900CF0F2B /* MastodonController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F953EF21251A2900CF0F2B /* MastodonController.swift */; }; D6FA94E129B52898006AAC51 /* InstanceFeatures in Frameworks */ = {isa = PBXBuildFile; productRef = D6FA94E029B52898006AAC51 /* InstanceFeatures */; }; D6FF9860255C717400845181 /* AccountSwitchingContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6FF985F255C717400845181 /* AccountSwitchingContainerViewController.swift */; }; @@ -496,13 +475,7 @@ D620483523D38075008A63EF /* ContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentTextView.swift; sourceTree = ""; }; D620483723D38190008A63EF /* StatusContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentTextView.swift; sourceTree = ""; }; D621733228F1D5ED004C7DB1 /* ReblogService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReblogService.swift; sourceTree = ""; }; - D622757324EDF1CD00B82A16 /* ComposeAttachmentsList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAttachmentsList.swift; sourceTree = ""; }; - D622757724EE133700B82A16 /* ComposeAssetPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAssetPicker.swift; sourceTree = ""; }; - D622757924EE21D900B82A16 /* ComposeAttachmentRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAttachmentRow.swift; sourceTree = ""; }; - D622759F24F1677200B82A16 /* ComposeHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeHostingController.swift; sourceTree = ""; }; - D62275A524F1C81800B82A16 /* ComposeReplyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeReplyView.swift; sourceTree = ""; }; D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeReplyContentView.swift; sourceTree = ""; }; - D62275A924F1E01C00B82A16 /* ComposeTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeTextView.swift; sourceTree = ""; }; D623A53C2635F5590095BD04 /* StatusPollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusPollView.swift; sourceTree = ""; }; D623A5402635FB3C0095BD04 /* PollOptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionView.swift; sourceTree = ""; }; D623A542263634100095BD04 /* PollOptionCheckboxView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionCheckboxView.swift; sourceTree = ""; }; @@ -519,7 +492,6 @@ D627944623A6AC9300D38C68 /* BasicTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = BasicTableViewCell.xib; sourceTree = ""; }; D627944C23A9A03D00D38C68 /* ListTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListTimelineViewController.swift; sourceTree = ""; }; D627944E23A9C99800D38C68 /* EditListAccountsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditListAccountsViewController.swift; sourceTree = ""; }; - D627FF75217E923E00CC0648 /* DraftsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftsManager.swift; sourceTree = ""; }; D6285B5221EA708700FE4B39 /* StatusFormat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusFormat.swift; sourceTree = ""; }; D6289E83217B795D0003D1D7 /* LargeImageViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = LargeImageViewController.xib; sourceTree = ""; }; D62D2421217AA7E1005076CC /* UserActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserActivityManager.swift; sourceTree = ""; }; @@ -584,7 +556,6 @@ D6620ACD2511A0ED00312CA0 /* StatusStateResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusStateResolver.swift; sourceTree = ""; }; D662AEED263A3B880082A153 /* PollFinishedTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollFinishedTableViewCell.swift; sourceTree = ""; }; D662AEEE263A3B880082A153 /* PollFinishedTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = PollFinishedTableViewCell.xib; sourceTree = ""; }; - D662AEF1263A4BE10082A153 /* ComposePollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposePollView.swift; sourceTree = ""; }; D663626121360B1900C9CBA2 /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = ""; }; D663626321360D2300C9CBA2 /* AvatarStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarStyle.swift; sourceTree = ""; }; D663626B21361C6700C9CBA2 /* Account+Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Account+Preferences.swift"; sourceTree = ""; }; @@ -596,10 +567,6 @@ D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineViewController.swift; sourceTree = ""; }; D671A6BE299DA96100A81FEA /* Tusker-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Tusker-Bridging-Header.h"; sourceTree = ""; }; D674A50727F910F300BA03AC /* Pachyderm */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Pachyderm; path = Packages/Pachyderm; sourceTree = ""; }; - D677284724ECBCB100C732D3 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = ""; }; - D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeCurrentAccount.swift; sourceTree = ""; }; - D677284B24ECBE9100C732D3 /* ComposeAvatarImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAvatarImageView.swift; sourceTree = ""; }; - D677284D24ECC01D00C732D3 /* OldDraft.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OldDraft.swift; sourceTree = ""; }; D67895BB24671E6D00D4CD9E /* PKDrawing+Render.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PKDrawing+Render.swift"; sourceTree = ""; }; D67895BF246870DE00D4CD9E /* LocalAccountAvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAccountAvatarView.swift; sourceTree = ""; }; D67B506C250B291200FAECFB /* BlurHashDecode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurHashDecode.swift; sourceTree = ""; }; @@ -665,7 +632,6 @@ D6A4DCCA2553667800D9DE31 /* FastAccountSwitcherViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FastAccountSwitcherViewController.swift; sourceTree = ""; }; D6A4DCCB2553667800D9DE31 /* FastAccountSwitcherViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = FastAccountSwitcherViewController.xib; sourceTree = ""; }; D6A4DCE425537C7A00D9DE31 /* FastSwitchingAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FastSwitchingAccountView.swift; sourceTree = ""; }; - D6A57407255C53EC00674551 /* ComposeTextViewCaretScrolling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeTextViewCaretScrolling.swift; sourceTree = ""; }; D6A5BB2A23BAEF61003BF21D /* APIMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIMocks.swift; sourceTree = ""; }; D6A5BB2C23BBA9C4003BF21D /* MyProfileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyProfileTests.swift; sourceTree = ""; }; D6A5BB2E23BBAC97003BF21D /* DelegatingResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DelegatingResponse.swift; sourceTree = ""; }; @@ -710,9 +676,7 @@ D6BD395C29B789D5005FFD2B /* TuskerComponents */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = TuskerComponents; path = Packages/TuskerComponents; sourceTree = ""; }; D6BEA243291A0C83002F4D01 /* Duckable */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Duckable; path = Packages/Duckable; sourceTree = ""; }; D6BEA246291A0F2D002F4D01 /* Duckable+Root.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Duckable+Root.swift"; sourceTree = ""; }; - D6BEA248291C6118002F4D01 /* DraftsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftsView.swift; sourceTree = ""; }; D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStatusTableViewCell.swift; sourceTree = ""; }; - D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeEmojiTextField.swift; sourceTree = ""; }; D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCardView.swift; sourceTree = ""; }; D6C3F4F4298ED0890009FCFF /* LocalPredicateStatusesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalPredicateStatusesViewController.swift; sourceTree = ""; }; D6C3F4F6298ED7F70009FCFF /* FavoritesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesViewController.swift; sourceTree = ""; }; @@ -727,7 +691,6 @@ D6C82B4025C5BB7E0017F1E6 /* ExploreViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExploreViewController.swift; sourceTree = ""; }; D6C94D862139E62700CB5196 /* LargeImageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeImageViewController.swift; sourceTree = ""; }; D6C94D882139E6EC00CB5196 /* AttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentView.swift; sourceTree = ""; }; - D6C99FCA24FADC91005C74D3 /* MainComposeTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainComposeTextView.swift; sourceTree = ""; }; D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSessionHelper.swift; sourceTree = ""; }; D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryPlayerViewController.swift; sourceTree = ""; }; D6CA8CD92962231F0050C433 /* ArrayUniqueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArrayUniqueTests.swift; sourceTree = ""; }; @@ -737,7 +700,6 @@ D6D12B57292D5B2C00D528E1 /* StatusActionAccountListCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusActionAccountListCollectionViewController.swift; sourceTree = ""; }; D6D12B59292D684600D528E1 /* AccountListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountListViewController.swift; sourceTree = ""; }; D6D3F4C324FDB6B700EC4A6A /* View+ConditionalModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+ConditionalModifier.swift"; sourceTree = ""; }; - D6D3FDE124F46A8D00FF50A5 /* ComposeUIState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeUIState.swift; sourceTree = ""; }; D6D4CC93250DB86A00FCCF8D /* ComposeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeTests.swift; sourceTree = ""; }; D6D4DDCC212518A000E1C4BB /* Tusker.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Tusker.app; sourceTree = BUILT_PRODUCTS_DIR; }; D6D4DDCF212518A000E1C4BB /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -756,7 +718,6 @@ D6D94954298963A900C59229 /* Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Colors.swift; sourceTree = ""; }; D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingStatusCollectionViewCell.swift; sourceTree = ""; }; D6D9498E298EB79400C59229 /* CopyableLable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyableLable.swift; sourceTree = ""; }; - D6DD2A3E273C1F4900386A6C /* ComposeAttachmentImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAttachmentImage.swift; sourceTree = ""; }; D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentWarningCopyMode.swift; sourceTree = ""; }; D6DD353E22F502EC00A9563A /* Preferences+Notification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Preferences+Notification.swift"; sourceTree = ""; }; D6DD8FFC298495A8002AD3FD /* LogoutService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogoutService.swift; sourceTree = ""; }; @@ -773,7 +734,6 @@ D6E343B1265AAD6B00C4AA01 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; D6E343B5265AAD6B00C4AA01 /* OpenInTusker.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = OpenInTusker.entitlements; sourceTree = ""; }; D6E343B9265AAD8C00C4AA01 /* Action.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = Action.js; sourceTree = ""; }; - D6E4267625327FB400C02E1C /* ComposeAutocompleteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAutocompleteView.swift; sourceTree = ""; }; D6E4269C2532A3E100C02E1C /* FuzzyMatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FuzzyMatcher.swift; sourceTree = ""; }; D6E426AC25334DA500C02E1C /* FuzzyMatcherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FuzzyMatcherTests.swift; sourceTree = ""; }; D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiImageView.swift; sourceTree = ""; }; @@ -783,7 +743,6 @@ D6E77D0A286D426E00D8B732 /* TrendingLinkCardCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingLinkCardCollectionViewCell.swift; sourceTree = ""; }; D6E77D0C286E6B7300D8B732 /* TrendingLinkCardCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TrendingLinkCardCollectionViewCell.xib; sourceTree = ""; }; D6E77D0E286F773900D8B732 /* SplitNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitNavigationController.swift; sourceTree = ""; }; - D6E9CDA7281A427800BBC98E /* PostService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostService.swift; sourceTree = ""; }; D6EAE0DA2550CC8A002DB0AC /* FocusableTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusableTextField.swift; sourceTree = ""; }; D6EBF01423C55C0900AE061B /* UIApplication+Scenes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+Scenes.swift"; sourceTree = ""; }; D6EBF01623C55E0D00AE061B /* UISceneSession+MastodonController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UISceneSession+MastodonController.swift"; sourceTree = ""; }; @@ -796,7 +755,6 @@ D6F6A551291F098700F496A8 /* RenameListService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenameListService.swift; sourceTree = ""; }; D6F6A553291F0D9600F496A8 /* DeleteListService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteListService.swift; sourceTree = ""; }; D6F6A556291F4F1600F496A8 /* MuteAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MuteAccountView.swift; sourceTree = ""; }; - D6F6A5582920676800F496A8 /* ComposeToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeToolbar.swift; sourceTree = ""; }; D6F953EF21251A2900CF0F2B /* MastodonController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonController.swift; sourceTree = ""; }; D6FA94DF29B52891006AAC51 /* InstanceFeatures */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = InstanceFeatures; path = Packages/InstanceFeatures; sourceTree = ""; }; D6FF985F255C717400845181 /* AccountSwitchingContainerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSwitchingContainerViewController.swift; sourceTree = ""; }; @@ -881,8 +839,6 @@ D6285B5221EA708700FE4B39 /* StatusFormat.swift */, D63F9C6D241D2D85004C03CF /* CompositionAttachment.swift */, D626493423BD94CE00612E6E /* CompositionAttachmentData.swift */, - D677284D24ECC01D00C732D3 /* OldDraft.swift */, - D627FF75217E923E00CC0648 /* DraftsManager.swift */, D61F75AE293AF50C00C0B37F /* EditedFilter.swift */, D65B4B532971F71D00DABDFB /* EditedReport.swift */, D600891A29848289005B4D00 /* PinnedTimeline.swift */, @@ -1132,25 +1088,7 @@ isa = PBXGroup; children = ( D68232F62464F4FD00325FB8 /* ComposeDrawingViewController.swift */, - D6D3FDE124F46A8D00FF50A5 /* ComposeUIState.swift */, - D622759F24F1677200B82A16 /* ComposeHostingController.swift */, - D677284724ECBCB100C732D3 /* ComposeView.swift */, - D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */, - D677284B24ECBE9100C732D3 /* ComposeAvatarImageView.swift */, - D6A57407255C53EC00674551 /* ComposeTextViewCaretScrolling.swift */, - D62275A924F1E01C00B82A16 /* ComposeTextView.swift */, - D6C99FCA24FADC91005C74D3 /* MainComposeTextView.swift */, - D662AEF1263A4BE10082A153 /* ComposePollView.swift */, - D622757324EDF1CD00B82A16 /* ComposeAttachmentsList.swift */, - D622757924EE21D900B82A16 /* ComposeAttachmentRow.swift */, - D6DD2A3E273C1F4900386A6C /* ComposeAttachmentImage.swift */, - D622757724EE133700B82A16 /* ComposeAssetPicker.swift */, - D62275A524F1C81800B82A16 /* ComposeReplyView.swift */, D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */, - D6E4267625327FB400C02E1C /* ComposeAutocompleteView.swift */, - D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */, - D6F6A5582920676800F496A8 /* ComposeToolbar.swift */, - D6BEA248291C6118002F4D01 /* DraftsView.swift */, D6BD395A29B64441005FFD2B /* NewComposeHostingController.swift */, ); path = Compose; @@ -1693,7 +1631,6 @@ isa = PBXGroup; children = ( D6F953EF21251A2900CF0F2B /* MastodonController.swift */, - D6E9CDA7281A427800BBC98E /* PostService.swift */, D61ABEFD28F1C92600B29151 /* FavoriteService.swift */, D621733228F1D5ED004C7DB1 /* ReblogService.swift */, D6F6A54F291F058600F496A8 /* CreateListService.swift */, @@ -1982,7 +1919,6 @@ D662AEEF263A3B880082A153 /* PollFinishedTableViewCell.swift in Sources */, D691771729A710520054D7EF /* ProfileNoContentCollectionViewCell.swift in Sources */, D626493C23C1000300612E6E /* AlbumTableViewCell.swift in Sources */, - D62275A624F1C81800B82A16 /* ComposeReplyView.swift in Sources */, D60E2F292442372B005F8713 /* AccountMO.swift in Sources */, D6412B0324AFF6A600F5412E /* TabBarScrollableViewController.swift in Sources */, D6093FB725BE0CF3004811E6 /* TrendHistoryView.swift in Sources */, @@ -2009,13 +1945,11 @@ D61F759B29384F9C00C0B37F /* FilterMO.swift in Sources */, D68FEC4F232C5BC300C84F23 /* SegmentedPageViewController.swift in Sources */, D64AAE9126C80DC600FC57FB /* ToastView.swift in Sources */, - D62275AA24F1E01C00B82A16 /* ComposeTextView.swift in Sources */, 0450531F22B0097E00100BA2 /* Timline+UI.swift in Sources */, D6C7D27D22B6EBF800071952 /* AttachmentsContainerView.swift in Sources */, D61F75942936F0DA00C0B37F /* FollowedHashtag.swift in Sources */, D63CC7122911F57C000E19DE /* StatusBarTappableViewController.swift in Sources */, D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */, - D6D3FDE224F46A8D00FF50A5 /* ComposeUIState.swift in Sources */, D6B22A0F2560D52D004D82EF /* TabbedPageViewController.swift in Sources */, D6895DC228D65274006341DA /* CustomAlertController.swift in Sources */, D6F953F021251A2900CF0F2B /* MastodonController.swift in Sources */, @@ -2035,7 +1969,6 @@ D620483623D38075008A63EF /* ContentTextView.swift in Sources */, D651C5B42915B00400236EF6 /* ProfileFieldsView.swift in Sources */, D6F2E965249E8BFD005846BB /* IssueReporterViewController.swift in Sources */, - D6A57408255C53EC00674551 /* ComposeTextViewCaretScrolling.swift in Sources */, D6CA6A94249FADE700AD45C1 /* GalleryPlayerViewController.swift in Sources */, D6A3BC802321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.swift in Sources */, D627944D23A9A03D00D38C68 /* ListTimelineViewController.swift in Sources */, @@ -2055,8 +1988,6 @@ D60E2F272442372B005F8713 /* StatusMO.swift in Sources */, D61F758A2932E1FC00C0B37F /* SwipeActionsPrefsView.swift in Sources */, D60E2F2C24423EAD005F8713 /* LazilyDecoding.swift in Sources */, - D662AEF2263A4BE10082A153 /* ComposePollView.swift in Sources */, - D677284A24ECBDF400C732D3 /* ComposeCurrentAccount.swift in Sources */, D62D2424217ABF3F005076CC /* NSUserActivity+Extensions.swift in Sources */, D6E77D0F286F773900D8B732 /* SplitNavigationController.swift in Sources */, D6B30E09254BAF63009CAEE5 /* ImageGrayscalifier.swift in Sources */, @@ -2075,16 +2006,13 @@ D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */, D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */, D68A76EC295369A8001DA1B3 /* AboutView.swift in Sources */, - D62275A024F1677200B82A16 /* ComposeHostingController.swift in Sources */, 04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */, - D6BEA249291C6118002F4D01 /* DraftsView.swift in Sources */, D61F75AD293AF39000C0B37F /* Filter+Helpers.swift in Sources */, D6531DEE246B81C9000F9538 /* GifvAttachmentView.swift in Sources */, D6370B9C24421FF30092A7FF /* Tusker.xcdatamodeld in Sources */, D601FA83297EEC3F00A8E8B5 /* SuggestedProfileCardCollectionViewCell.swift in Sources */, D691772E29AA5D420054D7EF /* UserActivityHandlingContext.swift in Sources */, 04ED00B121481ED800567C53 /* SteppedProgressView.swift in Sources */, - D677284C24ECBE9100C732D3 /* ComposeAvatarImageView.swift in Sources */, D6A6C11B25B63CEE00298D0F /* MemoryCache.swift in Sources */, D6114E0D27F7FEB30080E273 /* TrendingStatusesViewController.swift in Sources */, D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */, @@ -2111,7 +2039,6 @@ D623A53D2635F5590095BD04 /* StatusPollView.swift in Sources */, D64AAE9726C88DC400FC57FB /* ToastConfiguration.swift in Sources */, D63661C02381C144004B9E16 /* PreferencesNavigationController.swift in Sources */, - D6E9CDA8281A427800BBC98E /* PostService.swift in Sources */, D65B4B5E2973040D00DABDFB /* ReportAddStatusView.swift in Sources */, D6B053A223BD2C0600A066FA /* AssetPickerViewController.swift in Sources */, D6EE63FB2551F7F60065485C /* StatusCollapseButton.swift in Sources */, @@ -2130,7 +2057,6 @@ D6538945214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift in Sources */, D6F0B17524A3A1AA001E48C3 /* MainSidebarViewController.swift in Sources */, D62E9985279CA23900C26176 /* URLSession+Development.swift in Sources */, - D622757424EDF1CD00B82A16 /* ComposeAttachmentsList.swift in Sources */, D61F75B7293C119700C0B37F /* Filterer.swift in Sources */, D64AAE9526C88C5000FC57FB /* ToastableViewController.swift in Sources */, D6895DE928D962C2006341DA /* TimelineLikeController.swift in Sources */, @@ -2142,8 +2068,6 @@ D6BED174212667E900F02DA0 /* TimelineStatusTableViewCell.swift in Sources */, D69693F42585941A00F4E116 /* UIWindowSceneDelegate+Close.swift in Sources */, D659F36229541065002D944A /* TTTView.swift in Sources */, - D6C143DA253510F4007DC240 /* ComposeEmojiTextField.swift in Sources */, - D6DD2A3F273C1F4900386A6C /* ComposeAttachmentImage.swift in Sources */, D65B4B6229771A3F00DABDFB /* FetchStatusService.swift in Sources */, D61F759029353B4300C0B37F /* FileManager+Size.swift in Sources */, 0427033822B30F5F000D31B6 /* BehaviorPrefsView.swift in Sources */, @@ -2152,7 +2076,6 @@ D6C693EF216192C2007D6A6D /* TuskerNavigationDelegate.swift in Sources */, D6C94D872139E62700CB5196 /* LargeImageViewController.swift in Sources */, D6B053AB23BD2F1400A066FA /* AssetCollectionViewCell.swift in Sources */, - D622757A24EE21D900B82A16 /* ComposeAttachmentRow.swift in Sources */, D6E4269D2532A3E100C02E1C /* FuzzyMatcher.swift in Sources */, D6969EA0240C8384002843CE /* EmojiLabel.swift in Sources */, D64BC18623C1253A000D0238 /* AssetPreviewViewController.swift in Sources */, @@ -2180,7 +2103,6 @@ D67895C0246870DE00D4CD9E /* LocalAccountAvatarView.swift in Sources */, D6ADB6E828E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift in Sources */, D6333B372137838300CE884A /* AttributedString+Helpers.swift in Sources */, - D622757824EE133700B82A16 /* ComposeAssetPicker.swift in Sources */, D681A29A249AD62D0085E54E /* LargeImageContentView.swift in Sources */, D6DFC69E242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift in Sources */, D61AC1D8232EA42D00C54D2D /* InstanceTableViewCell.swift in Sources */, @@ -2205,7 +2127,6 @@ D60089192981FEBA005B4D00 /* ConfettiView.swift in Sources */, D61F759929384D4D00C0B37F /* CustomizeTimelinesView.swift in Sources */, D61F759F29385AD800C0B37F /* SemiCaseSensitiveComparator.swift in Sources */, - D627FF76217E923E00CC0648 /* DraftsManager.swift in Sources */, 04586B4322B301470021BD04 /* AppearancePrefsView.swift in Sources */, D6FF9860255C717400845181 /* AccountSwitchingContainerViewController.swift in Sources */, D6E77D0B286D426E00D8B732 /* TrendingLinkCardCollectionViewCell.swift in Sources */, @@ -2235,9 +2156,7 @@ D61F75AF293AF50C00C0B37F /* EditedFilter.swift in Sources */, D691771929A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift in Sources */, D61F75B9293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift in Sources */, - D677284824ECBCB100C732D3 /* ComposeView.swift in Sources */, D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */, - D6F6A5592920676800F496A8 /* ComposeToolbar.swift in Sources */, 04586B4122B2FFB10021BD04 /* PreferencesView.swift in Sources */, D6620ACE2511A0ED00312CA0 /* StatusStateResolver.swift in Sources */, D6B9366B281EE77E00237D0E /* PollVoteButton.swift in Sources */, @@ -2259,11 +2178,8 @@ D681E4D9246E346E0053414F /* AccountActivityItemSource.swift in Sources */, D626493F23C101C500612E6E /* AlbumAssetCollectionViewController.swift in Sources */, D68015422401A74600D6103B /* MediaPrefsView.swift in Sources */, - D6C99FCB24FADC91005C74D3 /* MainComposeTextView.swift in Sources */, D6093F9B25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift in Sources */, - D6E4267725327FB400C02E1C /* ComposeAutocompleteView.swift in Sources */, D6E77D09286D25FA00D8B732 /* TrendingHashtagCollectionViewCell.swift in Sources */, - D677284E24ECC01D00C732D3 /* OldDraft.swift in Sources */, D667E5F12134D5050057A976 /* UIViewController+Delegates.swift in Sources */, D6BC8748219738E1006163F1 /* EnhancedTableViewController.swift in Sources */, D6E426B325337C7000C02E1C /* CustomEmojiImageView.swift in Sources */, diff --git a/Tusker/API/PostService.swift b/Tusker/API/PostService.swift deleted file mode 100644 index 72ef7281..00000000 --- a/Tusker/API/PostService.swift +++ /dev/null @@ -1,136 +0,0 @@ -// -// PostService.swift -// Tusker -// -// Created by Shadowfacts on 4/27/22. -// Copyright © 2022 Shadowfacts. All rights reserved. -// - -import Foundation -import Pachyderm -import UniformTypeIdentifiers - -class PostService: ObservableObject { - private let mastodonController: MastodonController - private let draft: OldDraft - let totalSteps: Int - - @Published var currentStep = 1 - - init(mastodonController: MastodonController, draft: OldDraft) { - self.mastodonController = mastodonController - self.draft = draft - // 2 steps (request data, then upload) for each attachment - self.totalSteps = 2 + (draft.attachments.count * 2) - } - - @MainActor - func post() async throws { - guard draft.hasContent else { - return - } - - // save before posting, so if a crash occurs during network request, the status won't be lost - OldDraftsManager.save() - - let uploadedAttachments = try await uploadAttachments() - - let contentWarning = draft.contentWarningEnabled ? draft.contentWarning : nil - let sensitive = contentWarning != nil - - let request = Client.createStatus( - text: textForPosting(), - contentType: Preferences.shared.statusContentType, - inReplyTo: draft.inReplyToID, - media: uploadedAttachments, - sensitive: sensitive, - spoilerText: contentWarning, - visibility: draft.visibility, - language: nil, - pollOptions: draft.poll?.options.map(\.text), - pollExpiresIn: draft.poll == nil ? nil : Int(draft.poll!.duration), - pollMultiple: draft.poll?.multiple, - localOnly: mastodonController.instanceFeatures.localOnlyPosts ? draft.localOnly : nil - ) - do { - let (_, _) = try await mastodonController.run(request) - currentStep += 1 - - OldDraftsManager.shared.remove(self.draft) - } catch let error as Client.Error { - throw Error.posting(error) - } - } - - private func uploadAttachments() async throws -> [Attachment] { - var attachments: [Attachment] = [] - attachments.reserveCapacity(draft.attachments.count) - for (index, attachment) in draft.attachments.enumerated() { - let data: Data - let utType: UTType - do { - (data, utType) = try await getData(for: attachment) - currentStep += 1 - } catch let error as CompositionAttachmentData.Error { - throw Error.attachmentData(index: index, cause: error) - } - do { - let uploaded = try await uploadAttachment(data: data, utType: utType, description: attachment.attachmentDescription) - attachments.append(uploaded) - currentStep += 1 - } catch let error as Client.Error { - throw Error.attachmentUpload(index: index, cause: error) - } - } - return attachments - } - - private func getData(for attachment: CompositionAttachment) async throws -> (Data, UTType) { - return try await withCheckedThrowingContinuation { continuation in - attachment.data.getData(features: mastodonController.instanceFeatures) { result in - switch result { - case let .success(res): - continuation.resume(returning: res) - case let .failure(error): - continuation.resume(throwing: error) - } - } - } - } - - private func uploadAttachment(data: Data, utType: UTType, description: String?) async throws -> Attachment { - let formAttachment = FormAttachment(mimeType: utType.preferredMIMEType!, data: data, fileName: "file.\(utType.preferredFilenameExtension!)") - let req = Client.upload(attachment: formAttachment, description: description) - return try await mastodonController.run(req).0 - } - - private func textForPosting() -> String { - var text = draft.text - // when using dictation, iOS sometimes leaves a U+FFFC OBJECT REPLACEMENT CHARACTER behind in the text, - // which we want to strip out before actually posting the status - text = text.replacingOccurrences(of: "\u{fffc}", with: "") - - if draft.localOnly && mastodonController.instanceFeatures.needsLocalOnlyEmojiHack { - text += " 👁" - } - - return text - } - - enum Error: Swift.Error, LocalizedError { - case attachmentData(index: Int, cause: CompositionAttachmentData.Error) - case attachmentUpload(index: Int, cause: Client.Error) - case posting(Client.Error) - - var localizedDescription: String { - switch self { - case let .attachmentData(index: index, cause: cause): - return "Attachment \(index + 1): \(cause.localizedDescription)" - case let .attachmentUpload(index: index, cause: cause): - return "Attachment \(index + 1): \(cause.localizedDescription)" - case let .posting(error): - return error.localizedDescription - } - } - } -} diff --git a/Tusker/Models/DraftsManager.swift b/Tusker/Models/DraftsManager.swift deleted file mode 100644 index 723d8a9e..00000000 --- a/Tusker/Models/DraftsManager.swift +++ /dev/null @@ -1,77 +0,0 @@ -// -// OldDraftsManager.swift -// Tusker -// -// Created by Shadowfacts on 10/22/18. -// Copyright © 2018 Shadowfacts. All rights reserved. -// - -import Foundation - -class OldDraftsManager: Codable, ObservableObject { - - private(set) static var shared: OldDraftsManager = load() - - private static var documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! - private static var archiveURL = OldDraftsManager.documentsDirectory.appendingPathComponent("drafts").appendingPathExtension("plist") - - static func save() { - DispatchQueue.global(qos: .utility).async { - let encoder = PropertyListEncoder() - let data = try? encoder.encode(shared) - try? data?.write(to: archiveURL, options: .noFileProtection) - } - } - - static func load() -> OldDraftsManager { - let decoder = PropertyListDecoder() - if let data = try? Data(contentsOf: archiveURL), - let OldDraftsManager = try? decoder.decode(OldDraftsManager.self, from: data) { - return OldDraftsManager - } - return OldDraftsManager() - } - - private init() {} - - required init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - - if let dict = try? container.decode([UUID: OldDraft].self, forKey: .drafts) { - self.drafts = dict - } else if let array = try? container.decode([OldDraft].self, forKey: .drafts) { - self.drafts = array.reduce(into: [:], { partialResult, draft in - partialResult[draft.id] = draft - }) - } else { - throw DecodingError.dataCorruptedError(forKey: .drafts, in: container, debugDescription: "expected drafts to be a dict or array of drafts") - } - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(drafts, forKey: .drafts) - } - - @Published private var drafts: [UUID: OldDraft] = [:] - var sorted: [OldDraft] { - return drafts.values.sorted(by: { $0.lastModified > $1.lastModified }) - } - - func add(_ draft: OldDraft) { - drafts[draft.id] = draft - } - - func remove(_ draft: OldDraft) { - drafts.removeValue(forKey: draft.id) - } - - func getBy(id: UUID) -> OldDraft? { - return drafts[id] - } - - enum CodingKeys: String, CodingKey { - case drafts - } - -} diff --git a/Tusker/Models/OldDraft.swift b/Tusker/Models/OldDraft.swift deleted file mode 100644 index 24ca5808..00000000 --- a/Tusker/Models/OldDraft.swift +++ /dev/null @@ -1,226 +0,0 @@ -// -// OldDraft.swift -// Tusker -// -// Created by Shadowfacts on 8/18/20. -// Copyright © 2020 Shadowfacts. All rights reserved. -// - -import Foundation -import Pachyderm - -class OldDraft: Codable, ObservableObject { - let id: UUID - var lastModified: Date - - @Published var accountID: String - @Published var text: String - @Published var contentWarningEnabled: Bool - @Published var contentWarning: String - @Published var attachments: [CompositionAttachment] - @Published var inReplyToID: String? - @Published var visibility: Visibility - @Published var poll: Poll? - @Published var localOnly: Bool - - var initialText: String - - var hasContent: Bool { - (!text.isEmpty && text != initialText) || - (contentWarningEnabled && !contentWarning.isEmpty) || - attachments.count > 0 || - poll?.hasContent == true - } - - init(accountID: String) { - self.id = UUID() - self.lastModified = Date() - - self.accountID = accountID - self.text = "" - self.contentWarningEnabled = false - self.contentWarning = "" - self.attachments = [] - self.inReplyToID = nil - self.visibility = Preferences.shared.defaultPostVisibility - self.poll = nil - self.localOnly = false - - self.initialText = "" - } - - required init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - - self.id = try container.decode(UUID.self, forKey: .id) - self.lastModified = try container.decode(Date.self, forKey: .lastModified) - - self.accountID = try container.decode(String.self, forKey: .accountID) - self.text = try container.decode(String.self, forKey: .text) - self.contentWarningEnabled = try container.decode(Bool.self, forKey: .contentWarningEnabled) - self.contentWarning = try container.decode(String.self, forKey: .contentWarning) - self.attachments = try container.decode([CompositionAttachment].self, forKey: .attachments) - self.inReplyToID = try container.decode(String?.self, forKey: .inReplyToID) - self.visibility = try container.decode(Visibility.self, forKey: .visibility) - self.poll = try container.decode(Poll?.self, forKey: .poll) - self.localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly) ?? false - - self.initialText = try container.decode(String.self, forKey: .initialText) - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - - try container.encode(id, forKey: .id) - try container.encode(lastModified, forKey: .lastModified) - - try container.encode(accountID, forKey: .accountID) - try container.encode(text, forKey: .text) - try container.encode(contentWarningEnabled, forKey: .contentWarningEnabled) - try container.encode(contentWarning, forKey: .contentWarning) - try container.encode(attachments, forKey: .attachments) - try container.encode(inReplyToID, forKey: .inReplyToID) - try container.encode(visibility, forKey: .visibility) - try container.encode(poll, forKey: .poll) - try container.encode(localOnly, forKey: .localOnly) - - try container.encode(initialText, forKey: .initialText) - } -} - -extension OldDraft: Equatable { - static func ==(lhs: OldDraft, rhs: OldDraft) -> Bool { - return lhs.id == rhs.id - } -} - -extension OldDraft: Identifiable {} - -extension OldDraft { - enum CodingKeys: String, CodingKey { - case id - case lastModified - - case accountID - case text - case contentWarningEnabled - case contentWarning - case attachments - case inReplyToID - case visibility - case poll - case localOnly - - case initialText - } -} - -extension OldDraft { - class Poll: Codable, ObservableObject { - @Published var options: [Option] - @Published var multiple: Bool - @Published var duration: TimeInterval - - var hasContent: Bool { - options.contains { !$0.text.isEmpty } - } - - init() { - self.options = [Option(""), Option("")] - self.multiple = false - self.duration = 24 * 60 * 60 // 1 day - } - - required init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.options = try container.decode([Option].self, forKey: .options) - self.multiple = try container.decode(Bool.self, forKey: .multiple) - self.duration = try container.decode(TimeInterval.self, forKey: .duration) - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(options, forKey: .options) - try container.encode(multiple, forKey: .multiple) - try container.encode(duration, forKey: .duration) - } - - private enum CodingKeys: String, CodingKey { - case options - case multiple - case duration - } - - class Option: Identifiable, Codable, ObservableObject { - let id = UUID() - @Published var text: String - - init(_ text: String) { - self.text = text - } - - required init(from decoder: Decoder) throws { - self.text = try decoder.singleValueContainer().decode(String.self) - } - - func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - try container.encode(text) - } - } - } -} - -extension MastodonController { - - func createOldDraft(inReplyToID: String? = nil, mentioningAcct: String? = nil) -> OldDraft { - var acctsToMention = [String]() - - var visibility = inReplyToID != nil ? Preferences.shared.defaultReplyVisibility.resolved : Preferences.shared.defaultPostVisibility - var localOnly = false - var contentWarning = "" - - if let inReplyToID = inReplyToID, - let inReplyTo = persistentContainer.status(for: inReplyToID) { - acctsToMention.append(inReplyTo.account.acct) - acctsToMention.append(contentsOf: inReplyTo.mentions.map(\.acct)) - visibility = min(visibility, inReplyTo.visibility) - localOnly = instanceFeatures.localOnlyPosts && inReplyTo.localOnly - - if !inReplyTo.spoilerText.isEmpty { - switch Preferences.shared.contentWarningCopyMode { - case .doNotCopy: - break - case .asIs: - contentWarning = inReplyTo.spoilerText - case .prependRe: - if inReplyTo.spoilerText.lowercased().starts(with: "re:") { - contentWarning = inReplyTo.spoilerText - } else { - contentWarning = "re: \(inReplyTo.spoilerText)" - } - } - } - } - if let mentioningAcct = mentioningAcct { - acctsToMention.append(mentioningAcct) - } - if let ownAccount = self.account { - acctsToMention.removeAll(where: { $0 == ownAccount.acct }) - } - acctsToMention = acctsToMention.uniques() - - let draft = OldDraft(accountID: accountInfo!.id) - draft.inReplyToID = inReplyToID - draft.text = acctsToMention.map { "@\($0) " }.joined() - draft.initialText = draft.text - draft.visibility = visibility - draft.localOnly = localOnly - draft.contentWarning = contentWarning - draft.contentWarningEnabled = !contentWarning.isEmpty - - OldDraftsManager.shared.add(draft) - return draft - } - -} diff --git a/Tusker/Scenes/ComposeSceneDelegate.swift b/Tusker/Scenes/ComposeSceneDelegate.swift index 7eb558eb..79c6fcae 100644 --- a/Tusker/Scenes/ComposeSceneDelegate.swift +++ b/Tusker/Scenes/ComposeSceneDelegate.swift @@ -77,12 +77,12 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDeleg } func sceneWillResignActive(_ scene: UIScene) { - OldDraftsManager.save() + DraftsManager.save() if let window = window, let nav = window.rootViewController as? UINavigationController, - let compose = nav.topViewController as? ComposeHostingController { - scene.userActivity = UserActivityManager.editDraftActivity(id: compose.draft.id, accountID: scene.session.mastodonController!.accountInfo!.id) + let compose = nav.topViewController as? NewComposeHostingController { + scene.userActivity = UserActivityManager.editDraftActivity(id: compose.controller.draft.id, accountID: scene.session.mastodonController!.accountInfo!.id) } } diff --git a/Tusker/Scenes/MainSceneDelegate.swift b/Tusker/Scenes/MainSceneDelegate.swift index 685874f2..61739176 100644 --- a/Tusker/Scenes/MainSceneDelegate.swift +++ b/Tusker/Scenes/MainSceneDelegate.swift @@ -12,6 +12,7 @@ import MessageUI import CoreData import Duckable import UserAccounts +import ComposeUI class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate { @@ -87,7 +88,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate // The scene may re-connect later, as its session was not neccessarily discarded (see `application:didDiscardSceneSessions` instead). Preferences.save() - OldDraftsManager.save() + DraftsManager.save() } func sceneDidBecomeActive(_ scene: UIScene) { @@ -100,7 +101,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate // This may occur due to temporary interruptions (ex. an incoming phone call). Preferences.save() - OldDraftsManager.save() + DraftsManager.save() } func stateRestorationActivity(for scene: UIScene) -> NSUserActivity? { diff --git a/Tusker/Screens/Compose/ComposeAssetPicker.swift b/Tusker/Screens/Compose/ComposeAssetPicker.swift deleted file mode 100644 index 4a0cf43b..00000000 --- a/Tusker/Screens/Compose/ComposeAssetPicker.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// ComposeAssetPicker.swift -// Tusker -// -// Created by Shadowfacts on 8/19/20. -// Copyright © 2020 Shadowfacts. All rights reserved. -// - -import SwiftUI -import ComposeUI - -struct ComposeAssetPicker: UIViewControllerRepresentable { - typealias UIViewControllerType = AssetPickerViewController - - @ObservedObject var draft: OldDraft - let delegate: AssetPickerViewControllerDelegate? - - @EnvironmentObject var mastodonController: MastodonController - - func makeUIViewController(context: Context) -> AssetPickerViewController { - let vc = AssetPickerViewController() - vc.assetPickerDelegate = delegate - vc.preferredContentSize = CGSize(width: 400, height: 600) - return vc - } - - func updateUIViewController(_ uiViewController: AssetPickerViewController, context: Context) { - } - -} diff --git a/Tusker/Screens/Compose/ComposeAttachmentImage.swift b/Tusker/Screens/Compose/ComposeAttachmentImage.swift deleted file mode 100644 index dd439d68..00000000 --- a/Tusker/Screens/Compose/ComposeAttachmentImage.swift +++ /dev/null @@ -1,123 +0,0 @@ -// -// ComposeAttachmentImage.swift -// Tusker -// -// Created by Shadowfacts on 11/10/21. -// Copyright © 2021 Shadowfacts. All rights reserved. -// - -import SwiftUI -import Photos -import TuskerComponents - -struct ComposeAttachmentImage: View { - let attachment: CompositionAttachment - let fullSize: Bool - - @State private var gifData: Data? = nil - @State private var image: UIImage? = nil - @State private var imageContentMode: ContentMode = .fill - @State private var imageBackgroundColor: Color = .black - - @Environment(\.colorScheme) private var colorScheme: ColorScheme - - var body: some View { - if let gifData { - GIFViewWrapper(gifData: gifData) - } else if let image { - Image(uiImage: image) - .resizable() - .aspectRatio(contentMode: imageContentMode) - .background(imageBackgroundColor) - } else { - Image(systemName: placeholderImageName) - .onAppear(perform: self.loadImage) - } - } - - private var placeholderImageName: String { - switch colorScheme { - case .light: - return "photo" - case .dark: - return "photo.fill" - @unknown default: - return "photo" - } - } - - private func loadImage() { - switch attachment.data { - case let .image(image): - self.image = image - case let .asset(asset): - let size: CGSize - if fullSize { - size = PHImageManagerMaximumSize - } else { - // currently only used as thumbnail in ComposeAttachmentRow - size = CGSize(width: 80, height: 80) - } - let isGIF = PHAssetResource.assetResources(for: asset).contains(where: { $0.uniformTypeIdentifier == UTType.gif.identifier }) - if isGIF { - PHImageManager.default().requestImageDataAndOrientation(for: asset, options: nil) { data, typeIdentifier, orientation, info in - if typeIdentifier == UTType.gif.identifier { - self.gifData = data - } else if let data { - let image = UIImage(data: data) - DispatchQueue.main.async { - self.image = image - } - } - } - } else { - PHImageManager.default().requestImage(for: asset, targetSize: size, contentMode: .aspectFill, options: nil) { (image, _) in - DispatchQueue.main.async { - self.image = image - } - } - } - case let .video(url): - let asset = AVURLAsset(url: url) - let imageGenerator = AVAssetImageGenerator(asset: asset) - if let cgImage = try? imageGenerator.copyCGImage(at: .zero, actualTime: nil) { - self.image = UIImage(cgImage: cgImage) - } - case let .drawing(drawing): - image = drawing.imageInLightMode(from: drawing.bounds) - imageContentMode = .fit - imageBackgroundColor = .white - case let .gif(data): - self.gifData = data - } - } -} - -private struct GIFViewWrapper: UIViewRepresentable { - typealias UIViewType = GIFImageView - - @State private var controller: GIFController - - init(gifData: Data) { - self._controller = State(wrappedValue: GIFController(gifData: gifData)) - } - - func makeUIView(context: Context) -> GIFImageView { - let view = GIFImageView() - controller.attach(to: view) - controller.startAnimating() - view.contentMode = .scaleAspectFit - view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - view.setContentCompressionResistancePriority(.defaultLow, for: .vertical) - return view - } - - func updateUIView(_ uiView: GIFImageView, context: Context) { - } -} - -struct ComposeAttachmentImage_Previews: PreviewProvider { - static var previews: some View { - ComposeAttachmentImage(attachment: CompositionAttachment(data: .image(UIImage())), fullSize: false) - } -} diff --git a/Tusker/Screens/Compose/ComposeAttachmentRow.swift b/Tusker/Screens/Compose/ComposeAttachmentRow.swift deleted file mode 100644 index 5839d760..00000000 --- a/Tusker/Screens/Compose/ComposeAttachmentRow.swift +++ /dev/null @@ -1,164 +0,0 @@ -// -// ComposeAttachmentRow.swift -// Tusker -// -// Created by Shadowfacts on 8/19/20. -// Copyright © 2020 Shadowfacts. All rights reserved. -// - -import SwiftUI -import Photos -import AVFoundation -import Vision - -struct ComposeAttachmentRow: View { - @ObservedObject var draft: OldDraft - @ObservedObject var attachment: CompositionAttachment - - @EnvironmentObject var mastodonController: MastodonController - @EnvironmentObject var uiState: ComposeUIState - @State private var mode: Mode = .allowEntry - @State private var isShowingTextRecognitionFailedAlert = false - @State private var textRecognitionErrorMessage: String? = nil - - var body: some View { - HStack(alignment: .center, spacing: 4) { - ComposeAttachmentImage(attachment: attachment, fullSize: false) - .frame(width: 80, height: 80) - .cornerRadius(8) - .contextMenu { - if case .drawing(_) = attachment.data { - Button(action: self.editDrawing) { - Label("Edit Drawing", systemImage: "hand.draw") - } - } else if attachment.data.type == .image { - Button(action: self.recognizeText) { - Label("Recognize Text", systemImage: "doc.text.viewfinder") - } - } - - Button(role: .destructive, action: self.removeAttachment) { - Label("Delete", systemImage: "trash") - } - } previewIfAvailable: { - ComposeAttachmentImage(attachment: attachment, fullSize: true) - } - - switch mode { - case .allowEntry: - ComposeTextView(text: $attachment.attachmentDescription, placeholder: Text("Describe for the visually impaired…"), minHeight: 80) - .backgroundColor(.clear) - - case .recognizingText: - ProgressView() - } - - - // todo: find a way to make this button not activated when the list row is selected, see FB8595628 -// Button(action: self.removeAttachment) { -// Image(systemName: "xmark.circle.fill") -// .foregroundColor(.blue) -// } - } - .onReceive(attachment.$attachmentDescription) { (newDesc) in - if newDesc.isEmpty { - uiState.attachmentsMissingDescriptions.insert(attachment.id) - } else { - uiState.attachmentsMissingDescriptions.remove(attachment.id) - } - } - .alert(isPresented: $isShowingTextRecognitionFailedAlert) { - Alert( - title: Text("Text Recognition Failed"), - message: Text(self.textRecognitionErrorMessage ?? ""), - dismissButton: .default(Text("OK")) - ) - } - } - - private func removeAttachment() { - withAnimation { - draft.attachments.removeAll { $0.id == attachment.id } - } - } - - private func editDrawing() { - uiState.composeDrawingMode = .edit(id: attachment.id) - uiState.delegate?.presentComposeDrawing() - } - - private func recognizeText() { - mode = .recognizingText - - DispatchQueue.global(qos: .userInitiated).async { - self.attachment.data.getData(features: mastodonController.instanceFeatures, skipAllConversion: true) { (result) in - let data: Data - do { - try data = result.get().0 - } catch { - DispatchQueue.main.async { - self.mode = .allowEntry - self.isShowingTextRecognitionFailedAlert = true - self.textRecognitionErrorMessage = error.localizedDescription - } - return - } - let handler = VNImageRequestHandler(data: data, options: [:]) - let request = VNRecognizeTextRequest { (request, error) in - DispatchQueue.main.async { - if let results = request.results as? [VNRecognizedTextObservation] { - var text = "" - for observation in results { - let result = observation.topCandidates(1).first! - text.append(result.string) - text.append("\n") - } - self.attachment.attachmentDescription = text - } - - self.mode = .allowEntry - } - } - request.recognitionLevel = .accurate - request.usesLanguageCorrection = true - DispatchQueue.global(qos: .userInitiated).async { - do { - try handler.perform([request]) - } catch { - // The perform call throws an error with code 1 if the request is cancelled, which we don't want to show an alert for. - guard (error as NSError).code != 1 else { return } - DispatchQueue.main.async { - self.mode = .allowEntry - self.isShowingTextRecognitionFailedAlert = true - self.textRecognitionErrorMessage = error.localizedDescription - } - } - } - } - } - } -} - -extension ComposeAttachmentRow { - enum Mode { - case allowEntry, recognizingText - } -} - -private extension View { - @available(iOS, obsoleted: 16.0) - @ViewBuilder - func contextMenu(@ViewBuilder menuItems: () -> M, @ViewBuilder previewIfAvailable preview: () -> P) -> some View { - if #available(iOS 16.0, *) { - self.contextMenu(menuItems: menuItems, preview: preview) - } else { - self.contextMenu(menuItems: menuItems) - } - } -} - -//struct ComposeAttachmentRow_Previews: PreviewProvider { -// static var previews: some View { -// ComposeAttachmentRow() -// } -//} diff --git a/Tusker/Screens/Compose/ComposeAttachmentsList.swift b/Tusker/Screens/Compose/ComposeAttachmentsList.swift deleted file mode 100644 index 011e2c92..00000000 --- a/Tusker/Screens/Compose/ComposeAttachmentsList.swift +++ /dev/null @@ -1,210 +0,0 @@ -// -// ComposeAttachmentsList.swift -// Tusker -// -// Created by Shadowfacts on 8/19/20. -// Copyright © 2020 Shadowfacts. All rights reserved. -// - -import SwiftUI - -struct ComposeAttachmentsList: View { - private let cellHeight: CGFloat = 80 - private let cellPadding: CGFloat = 12 - - @ObservedObject var draft: OldDraft - - @EnvironmentObject var mastodonController: MastodonController - @EnvironmentObject var uiState: ComposeUIState - @State var isShowingAssetPickerPopover = false - @State var isShowingCreateDrawing = false - - @Environment(\.colorScheme) var colorScheme: ColorScheme - @Environment(\.horizontalSizeClass) var horizontalSizeClass: UserInterfaceSizeClass? - - var body: some View { - Group { - ForEach(draft.attachments) { (attachment) in - ComposeAttachmentRow( - draft: draft, - attachment: attachment - ) - .listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2)) - .onDrag { NSItemProvider(object: attachment) } - } - .onMove(perform: self.moveAttachments) - .onDelete(perform: self.deleteAttachments) - .conditionally(canAddAttachment) { - $0.onInsert(of: CompositionAttachment.readableTypeIdentifiersForItemProvider, perform: self.insertAttachments) - } - - Button(action: self.addAttachment) { - Label("Add photo or video", systemImage: addButtonImageName) - } - .disabled(!canAddAttachment) - .foregroundColor(.accentColor) - .frame(height: cellHeight / 2) - .sheetOrPopover(isPresented: $isShowingAssetPickerPopover, content: self.assetPickerPopover) - .listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2)) - - Button(action: self.createDrawing) { - Label("Draw something", systemImage: "hand.draw") - } - .disabled(!canAddAttachment) - .foregroundColor(.accentColor) - .frame(height: cellHeight / 2) - .listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2)) - - Button(action: self.togglePoll) { - Label(draft.poll == nil ? "Add a poll" : "Remove poll", systemImage: "chart.bar.doc.horizontal") - } - .disabled(!canAddPoll) - .foregroundColor(.accentColor) - .frame(height: cellHeight / 2) - .listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2)) - } - .onAppear(perform: self.didAppear) - } - - private var addButtonImageName: String { - switch colorScheme { - case .dark: - return "photo.fill" - case .light: - return "photo" - @unknown default: - return "photo" - } - } - - private var canAddAttachment: Bool { - if mastodonController.instanceFeatures.mastodonAttachmentRestrictions { - return draft.attachments.count < 4 && draft.attachments.allSatisfy { $0.data.type == .image } && draft.poll == nil - } else { - return true - } - } - - private var canAddPoll: Bool { - if mastodonController.instanceFeatures.pollsAndAttachments { - return true - } else { - return draft.attachments.isEmpty - } - } - - private func didAppear() { - if #available(iOS 16.0, *) { - // these appearance proxy hacks are no longer necessary - } else { - let proxy = UITableView.appearance(whenContainedInInstancesOf: [ComposeHostingController.self]) - // enable drag and drop to reorder on iPhone - proxy.dragInteractionEnabled = true - proxy.isScrollEnabled = false - } - } - - private func assetPickerPopover() -> some View { - ComposeAssetPicker(draft: draft, delegate: uiState.delegate?.assetPickerDelegate) - .onDisappear { - // on iPadOS 16, this is necessary to dismiss the popover when collapsing from regular -> compact size class - // otherwise, the popover isn't visible but it's still "presented", so the sheet can't be shown - self.isShowingAssetPickerPopover = false - } - // on iPadOS 16, this is necessary to show the dark color in the popover arrow - .background(Color(.appBackground)) - .environment(\.colorScheme, .dark) - .edgesIgnoringSafeArea(.bottom) - .withSheetDetentsIfAvailable() - } - - private func addAttachment() { -// if #available(iOS 16.0, *) { -// isShowingAssetPickerPopover = true -// } else if horizontalSizeClass == .regular { -// isShowingAssetPickerPopover = true -// } else { - uiState.delegate?.presentAssetPickerSheet() -// } - } - - private func moveAttachments(from source: IndexSet, to destination: Int) { - draft.attachments.move(fromOffsets: source, toOffset: destination) - } - - private func deleteAttachments(at indices: IndexSet) { - draft.attachments.remove(atOffsets: indices) - } - - private func insertAttachments(at offset: Int, itemProviders: [NSItemProvider]) { - for provider in itemProviders where provider.canLoadObject(ofClass: CompositionAttachment.self) { - guard canAddAttachment else { break } - - provider.loadObject(ofClass: CompositionAttachment.self) { (object, error) in - guard let attachment = object as? CompositionAttachment else { return } - DispatchQueue.main.async { - self.draft.attachments.insert(attachment, at: offset) - } - } - } - } - - private func createDrawing() { - uiState.composeDrawingMode = .createNew - uiState.delegate?.presentComposeDrawing() - } - - private func togglePoll() { - UIApplication.shared.sendAction(#selector(UIView.resignFirstResponder), to: nil, from: nil, for: nil) - - withAnimation { - draft.poll = draft.poll == nil ? OldDraft.Poll() : nil - } - } -} - -fileprivate extension View { - @available(iOS, obsoleted: 16.0) - @ViewBuilder - func sheetOrPopover(isPresented: Binding, @ViewBuilder content: @escaping () -> some View) -> some View { - if #available(iOS 16.0, *) { - self.modifier(SheetOrPopover(isPresented: isPresented, view: content)) - } else { - self.popover(isPresented: isPresented, content: content) - } - } - - @available(iOS, obsoleted: 16.0) - @ViewBuilder - func withSheetDetentsIfAvailable() -> some View { - if #available(iOS 16.0, *) { - self - .presentationDetents([.medium, .large]) - .presentationDragIndicator(.visible) - } else { - self - } - } -} - -@available(iOS 16.0, *) -struct SheetOrPopover: ViewModifier { - @Binding var isPresented: Bool - @ViewBuilder let view: () -> V - - @Environment(\.horizontalSizeClass) var sizeClass - - func body(content: Content) -> some View { - if sizeClass == .compact { - content.sheet(isPresented: $isPresented, content: view) - } else { - content.popover(isPresented: $isPresented, content: view) - } - } -} - -//struct ComposeAttachmentsList_Previews: PreviewProvider { -// static var previews: some View { -// ComposeAttachmentsList() -// } -//} diff --git a/Tusker/Screens/Compose/ComposeAutocompleteView.swift b/Tusker/Screens/Compose/ComposeAutocompleteView.swift deleted file mode 100644 index 94a5a902..00000000 --- a/Tusker/Screens/Compose/ComposeAutocompleteView.swift +++ /dev/null @@ -1,424 +0,0 @@ -// -// ComposeAutocompleteView.swift -// Tusker -// -// Created by Shadowfacts on 10/10/20. -// Copyright © 2020 Shadowfacts. All rights reserved. -// - -import SwiftUI -import CoreData -import Pachyderm - -struct ComposeAutocompleteView: View { - let autocompleteState: ComposeUIState.AutocompleteState - - @Environment(\.colorScheme) var colorScheme: ColorScheme - - private var backgroundColor: Color { - Color(white: colorScheme == .light ? 0.98 : 0.15) - } - - private var borderColor: Color { - Color(white: colorScheme == .light ? 0.85 : 0.25) - } - - var body: some View { - suggestionsView - .background(backgroundColor) - .overlay(borderColor.frame(height: 0.5), alignment: .top) - } - - @ViewBuilder - private var suggestionsView: some View { - switch autocompleteState { - case .mention(_): - ComposeAutocompleteMentionsView() - case .emoji(_): - ComposeAutocompleteEmojisView() - case .hashtag(_): - ComposeAutocompleteHashtagsView() - } - } -} - -struct ComposeAutocompleteMentionsView: View { - @EnvironmentObject private var mastodonController: MastodonController - @EnvironmentObject private var uiState: ComposeUIState - @ObservedObject private var preferences = Preferences.shared - - // can't use AccountProtocol because of associated type requirements - @State private var accounts: [AnyAccount] = [] - - @State private var searchRequest: URLSessionTask? - - var body: some View { - ScrollView(.horizontal) { - // can't use LazyHStack because changing the contents of the ForEach causes the ScrollView to hang - HStack(spacing: 8) { - ForEach(accounts, id: \.value.id) { (account) in - Button { - uiState.currentInput?.autocomplete(with: "@\(account.value.acct)") - } label: { - HStack(spacing: 4) { - ComposeAvatarImageView(url: account.value.avatar) - .frame(width: 30, height: 30) - .cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 30) - - VStack(alignment: .leading) { - AccountDisplayNameLabel(account: account.value, textStyle: .subheadline, emojiSize: 14) - .foregroundColor(Color(UIColor.label)) - - Text(verbatim: "@\(account.value.acct)") - .font(.caption) - .foregroundColor(Color(UIColor.label)) - } - } - } - .frame(height: 30) - .padding(.vertical, 8) - } - .animation(.linear(duration: 0.1), value: accounts) - - Spacer() - } - .padding(.horizontal, 8) - } - .onReceive(uiState.$autocompleteState.debounce(for: .milliseconds(250), scheduler: DispatchQueue.main), perform: queryChanged) - .onDisappear { - searchRequest?.cancel() - } - } - - private func queryChanged(_ state: ComposeUIState.AutocompleteState?) { - guard case let .mention(query) = state, - !query.isEmpty else { - accounts = [] - return - } - - let localSearchWorkItem = DispatchWorkItem { - // todo: there's got to be something more efficient than this :/ - let wildcardedQuery = query.map { "*\($0)" }.joined() + "*" - let request: NSFetchRequest = AccountMO.fetchRequest() - request.predicate = NSPredicate(format: "displayName LIKE %@ OR acct LIKE %@", wildcardedQuery, wildcardedQuery) - - if let results = try? mastodonController.persistentContainer.viewContext.fetch(request) { - loadAccounts(results.map { .init(value: $0) }, query: query) - } - } - - // we only want to search locally if the search API call takes more than .25sec or it fails - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250), execute: localSearchWorkItem) - - if let oldRequest = searchRequest { - oldRequest.cancel() - } - - let apiRequest = Client.searchForAccount(query: query) - searchRequest = mastodonController.run(apiRequest) { (response) in - guard case let .success(accounts, _) = response else { return } - - localSearchWorkItem.cancel() - - // dispatch back to the main thread because loadAccounts uses CoreData - DispatchQueue.main.async { - // if the query has changed, don't bother loading the now-outdated results - if case .mention(query) = uiState.autocompleteState { - self.loadAccounts(accounts.map { .init(value: $0) }, query: query) - } - } - } - } - - private func loadAccounts(_ accounts: [AnyAccount], query: String) { - // when sorting account suggestions, ignore the domain component of the acct unless the user is typing it themself - let ignoreDomain = !query.contains("@") - - self.accounts = - accounts.map { (account) -> (AnyAccount, (matched: Bool, score: Int)) in - let fuzzyStr = ignoreDomain ? String(account.value.acct.split(separator: "@").first!) : account.value.acct - let res = (account, FuzzyMatcher.match(pattern: query, str: fuzzyStr)) - return res - } - .filter(\.1.matched) - .map { (account, res) -> (AnyAccount, Int) in - // give higher weight to accounts that the user follows or is followed by - var score = res.score - if let relationship = mastodonController.persistentContainer.relationship(forAccount: account.value.id) { - if relationship.following { - score += 3 - } - if relationship.followedBy { - score += 2 - } - } - return (account, score) - } - .sorted { $0.1 > $1.1 } - .map(\.0) - } - - private struct AnyAccount: Equatable { - let value: any AccountProtocol - - static func ==(lhs: AnyAccount, rhs: AnyAccount) -> Bool { - return lhs.value.id == rhs.value.id - } - } -} - -struct ComposeAutocompleteEmojisView: View { - @EnvironmentObject private var mastodonController: MastodonController - @EnvironmentObject private var uiState: ComposeUIState - - @State var expanded = false - @State private var emojis: [Emoji] = [] - @ScaledMetric private var emojiSize = 30 - - private var emojisBySection: [String: [Emoji]] { - var values: [String: [Emoji]] = [:] - for emoji in emojis { - let key = emoji.category ?? "" - if !values.keys.contains(key) { - values[key] = [emoji] - } else { - values[key]!.append(emoji) - } - } - return values - } - - var body: some View { - // When exapnded, the toggle button should be at the top. When collapsed, it should be centered. - HStack(alignment: expanded ? .top : .center, spacing: 0) { - if case let .emoji(query) = uiState.autocompleteState { - emojiList(query: query) - .transition(.move(edge: .bottom)) - .onReceive(uiState.$autocompleteState, perform: queryChanged) - .onAppear { - if uiState.shouldEmojiAutocompletionBeginExpanded { - expanded = true - uiState.shouldEmojiAutocompletionBeginExpanded = false - } - } - } else { - // when the autocomplete view is animating out, the autocomplete state is nil - // add a spacer so the expand button remains on the right - Spacer() - } - - toggleExpandedButton - .padding(.trailing, 8) - .padding(.top, expanded ? 8 : 0) - } - } - - @ViewBuilder - private func emojiList(query: String) -> some View { - if expanded { - verticalGrid - .frame(height: 150) - } else { - horizontalScrollView - } - } - - private var verticalGrid: some View { - ScrollView { - LazyVGrid(columns: [GridItem(.adaptive(minimum: emojiSize), spacing: 4)]) { - ForEach(emojisBySection.keys.sorted(), id: \.self) { section in - Section { - ForEach(emojisBySection[section]!, id: \.shortcode) { emoji in - Button { - uiState.currentInput?.autocomplete(with: ":\(emoji.shortcode):") - } label: { - CustomEmojiImageView(emoji: emoji) - .frame(height: emojiSize) - } - .accessibilityLabel(emoji.shortcode) - } - } header: { - if !section.isEmpty { - VStack(alignment: .leading, spacing: 2) { - Text(section) - .font(.caption) - - Rectangle() - .foregroundColor(Color(.separator)) - .frame(height: 0.5) - } - .padding(.top, 4) - } - } - } - } - .padding(.all, 8) - } - .frame(maxWidth: .infinity) - } - - private var horizontalScrollView: some View { - ScrollView(.horizontal) { - HStack(spacing: 8) { - ForEach(emojis, id: \.shortcode) { (emoji) in - Button { - uiState.currentInput?.autocomplete(with: ":\(emoji.shortcode):") - } label: { - HStack(spacing: 4) { - CustomEmojiImageView(emoji: emoji) - .frame(height: emojiSize) - Text(verbatim: ":\(emoji.shortcode):") - .foregroundColor(Color(UIColor.label)) - } - } - .accessibilityLabel(emoji.shortcode) - .frame(height: emojiSize) - } - .animation(.linear(duration: 0.2), value: emojis) - - Spacer(minLength: emojiSize) - } - .padding(.horizontal, 8) - .frame(height: emojiSize + 16) - } - } - - private var toggleExpandedButton: some View { - Button { - withAnimation { - expanded.toggle() - } - } label: { - Image(systemName: "chevron.down") - .resizable() - .aspectRatio(contentMode: .fit) - .rotationEffect(expanded ? .zero : .degrees(180)) - } - .accessibilityLabel(expanded ? "Collapse" : "Expand") - .frame(width: 20, height: 20) - } - - private func queryChanged(_ autocompleteState: ComposeUIState.AutocompleteState?) { - guard case let .emoji(query) = autocompleteState else { - emojis = [] - return - } - - mastodonController.getCustomEmojis { (emojis) in - var emojis = emojis - if !query.isEmpty { - emojis = - emojis.map { (emoji) -> (Emoji, (matched: Bool, score: Int)) in - (emoji, FuzzyMatcher.match(pattern: query, str: emoji.shortcode)) - } - .filter(\.1.matched) - .sorted { $0.1.score > $1.1.score } - .map(\.0) - } - var shortcodes = Set() - self.emojis = [] - for emoji in emojis where !shortcodes.contains(emoji.shortcode) { - self.emojis.append(emoji) - shortcodes.insert(emoji.shortcode) - } - } - } -} - -struct ComposeAutocompleteHashtagsView: View { - @EnvironmentObject private var mastodonController: MastodonController - @EnvironmentObject private var uiState: ComposeUIState - - @State private var hashtags: [Hashtag] = [] - @State private var trendingRequest: URLSessionTask? - @State private var searchRequest: URLSessionTask? - - var body: some View { - ScrollView(.horizontal) { - HStack(spacing: 8) { - ForEach(hashtags, id: \.name) { (hashtag) in - Button { - uiState.currentInput?.autocomplete(with: "#\(hashtag.name)") - } label: { - Text(verbatim: "#\(hashtag.name)") - .foregroundColor(Color(UIColor.label)) - } - .frame(height: 30) - .padding(.vertical, 8) - } - .animation(.linear(duration: 0.1), value: hashtags) - - Spacer() - } - .padding(.horizontal, 8) - } - .onReceive(uiState.$autocompleteState.debounce(for: .milliseconds(250), scheduler: DispatchQueue.main), perform: queryChanged) - .onDisappear { - trendingRequest?.cancel() - } - } - - private func queryChanged(_ autocompleteState: ComposeUIState.AutocompleteState?) { - guard case let .hashtag(query) = autocompleteState, - !query.isEmpty else { - hashtags = [] - return - } - - let onlySavedTagsWorkItem = DispatchWorkItem { - self.updateHashtags(searchResults: [], trendingTags: [], query: query) - } - - // we only want to do the local-only search if the trends API call takes more than .25sec or it fails - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250), execute: onlySavedTagsWorkItem) - - var trendingTags: [Hashtag] = [] - var searchedTags: [Hashtag] = [] - - let group = DispatchGroup() - - group.enter() - trendingRequest = mastodonController.run(Client.getTrendingHashtags()) { (response) in - defer { group.leave() } - guard case let .success(trends, _) = response else { return } - trendingTags = trends - } - - group.enter() - searchRequest = mastodonController.run(Client.search(query: "#\(query)", types: [.hashtags])) { (response) in - defer { group.leave() } - guard case let .success(results, _) = response else { return } - searchedTags = results.hashtags - } - - group.notify(queue: .main) { - onlySavedTagsWorkItem.cancel() - - // if the query has changed, don't bother loading the now-outdated results - if case .hashtag(query) = self.uiState.autocompleteState { - self.updateHashtags(searchResults: searchedTags, trendingTags: trendingTags, query: query) - } - } - } - - private func updateHashtags(searchResults: [Hashtag], trendingTags: [Hashtag], query: String) { - let req = SavedHashtag.fetchRequest(account: mastodonController.accountInfo!) - let savedTags = ((try? mastodonController.persistentContainer.viewContext.fetch(req)) ?? []) - .map { Hashtag(name: $0.name, url: $0.url) } - - hashtags = (searchResults + savedTags + trendingTags) - .map { (tag) -> (Hashtag, (matched: Bool, score: Int)) in - return (tag, FuzzyMatcher.match(pattern: query, str: tag.name)) - } - .filter(\.1.matched) - .sorted { $0.1.score > $1.1.score } - .map(\.0) - } -} - -struct ComposeAutocompleteView_Previews: PreviewProvider { - static var previews: some View { - ComposeAutocompleteView(autocompleteState: .mention("shadowfacts")) - } -} diff --git a/Tusker/Screens/Compose/ComposeAvatarImageView.swift b/Tusker/Screens/Compose/ComposeAvatarImageView.swift deleted file mode 100644 index eceb9c3a..00000000 --- a/Tusker/Screens/Compose/ComposeAvatarImageView.swift +++ /dev/null @@ -1,64 +0,0 @@ -// -// ComposeAvatarImageView.swift -// Tusker -// -// Created by Shadowfacts on 8/18/20. -// Copyright © 2020 Shadowfacts. All rights reserved. -// - -import SwiftUI - -struct ComposeAvatarImageView: View { - let url: URL? - @State var request: ImageCache.Request? = nil - @State var avatarImage: UIImage? = nil - @ObservedObject var preferences = Preferences.shared - - var body: some View { - image - .resizable() - .conditionally(url != nil) { - $0.onAppear(perform: self.loadImage) - } - .onDisappear(perform: self.cancelRequest) - } - - private var image: Image { - if let avatarImage = avatarImage { - return Image(uiImage: avatarImage).renderingMode(.original) - } else { - return placeholderImage - } - } - - private var placeholderImage: Image { - let imageName: String - switch preferences.avatarStyle { - case .circle: - imageName = "person.crop.circle" - case .roundRect: - imageName = "person.crop.square" - } - return Image(systemName: imageName) - } - - private func loadImage() { - guard let url = url else { return } - request = ImageCache.avatars.get(url) { (_, image) in - DispatchQueue.main.async { - self.request = nil - self.avatarImage = image - } - } - } - - private func cancelRequest() { - request?.cancel() - } -} - -struct ComposeAvatarImageView_Previews: PreviewProvider { - static var previews: some View { - ComposeAvatarImageView(url: URL(string: "https://social.shadowfacts.net/media/4b481afc591a8f3d11d0f5732e5cb320422dec72d7f223ebb5f35d5d0e821a9c.png")!) - } -} diff --git a/Tusker/Screens/Compose/ComposeCurrentAccount.swift b/Tusker/Screens/Compose/ComposeCurrentAccount.swift deleted file mode 100644 index 878c6139..00000000 --- a/Tusker/Screens/Compose/ComposeCurrentAccount.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// ComposeCurrentAccount.swift -// Tusker -// -// Created by Shadowfacts on 8/18/20. -// Copyright © 2020 Shadowfacts. All rights reserved. -// - -import SwiftUI -import Pachyderm - -struct ComposeCurrentAccount: View { - @EnvironmentObject var mastodonController: MastodonController - @ObservedObject private var preferences = Preferences.shared - - var account: Account? { - mastodonController.account - } - - var body: some View { - HStack(alignment: .top) { - ComposeAvatarImageView(url: account?.avatar) - .frame(width: 50, height: 50) - .cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 50) - .accessibilityHidden(true) - - if let id = account?.id, - let account = mastodonController.persistentContainer.account(for: id) { - VStack(alignment: .leading) { - AccountDisplayNameLabel(account: account, textStyle: .title2, emojiSize: 24) - .lineLimit(1) - - Text(verbatim: "@\(account.acct)") - .font(.body.weight(.light)) - .foregroundColor(.secondary) - .lineLimit(1) - } - } - - Spacer() - } - } -} - -//struct ComposeCurrentAccount_Previews: PreviewProvider { -// static var previews: some View { -// ComposeCurrentAccount(account: ) -// } -//} diff --git a/Tusker/Screens/Compose/ComposeEmojiTextField.swift b/Tusker/Screens/Compose/ComposeEmojiTextField.swift deleted file mode 100644 index cfaceb8d..00000000 --- a/Tusker/Screens/Compose/ComposeEmojiTextField.swift +++ /dev/null @@ -1,269 +0,0 @@ -// -// ComposeContentWarningTextField.swift -// Tusker -// -// Created by Shadowfacts on 10/12/20. -// Copyright © 2020 Shadowfacts. All rights reserved. -// - -import SwiftUI - -struct ComposeEmojiTextField: UIViewRepresentable { - typealias UIViewType = UITextField - - @EnvironmentObject private var uiState: ComposeUIState - - @Binding var text: String - let placeholder: String - let maxLength: Int? - let becomeFirstResponder: Binding? - let focusNextView: Binding? - private var didChange: ((String) -> Void)? = nil - private var didEndEditing: (() -> Void)? = nil - private var backgroundColor: UIColor? = nil - - init(text: Binding, placeholder: String, maxLength: Int? = nil, becomeFirstResponder: Binding? = nil, focusNextView: Binding? = nil) { - self._text = text - self.placeholder = placeholder - self.maxLength = maxLength - self.becomeFirstResponder = becomeFirstResponder - self.focusNextView = focusNextView - self.didChange = nil - self.didEndEditing = nil - } - - mutating func didChange(_ didChange: @escaping (String) -> Void) -> Self { - self.didChange = didChange - return self - } - - mutating func didEndEditing(_ didEndEditing: @escaping () -> Void) -> Self { - self.didEndEditing = didEndEditing - return self - } - - mutating func backgroundColor(_ color: UIColor) -> Self { - self.backgroundColor = color - return self - } - - func makeUIView(context: Context) -> UITextField { - let view = UITextField() - - view.placeholder = placeholder - view.borderStyle = .roundedRect - view.font = .preferredFont(forTextStyle: .body) - view.adjustsFontForContentSizeCategory = true - view.backgroundColor = backgroundColor - - view.delegate = context.coordinator - view.addTarget(context.coordinator, action: #selector(Coordinator.didChange(_:)), for: .editingChanged) - view.addTarget(context.coordinator, action: #selector(Coordinator.returnKeyPressed), for: .primaryActionTriggered) - - // otherwise when the text gets too wide it starts expanding the ComposeView - view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - - context.coordinator.textField = view - context.coordinator.uiState = uiState - context.coordinator.text = $text - - return view - } - - func updateUIView(_ uiView: UITextField, context: Context) { - if context.coordinator.skipSettingTextOnNextUpdate { - context.coordinator.skipSettingTextOnNextUpdate = false - } else { - uiView.text = text - } - context.coordinator.maxLength = maxLength - context.coordinator.didChange = didChange - context.coordinator.didEndEditing = didEndEditing - context.coordinator.focusNextView = focusNextView - - if becomeFirstResponder?.wrappedValue == true { - DispatchQueue.main.async { - uiView.becomeFirstResponder() - becomeFirstResponder?.wrappedValue = false - } - } - } - - func makeCoordinator() -> Coordinator { - return Coordinator() - } - - class Coordinator: NSObject, UITextFieldDelegate, ComposeInput { - weak var textField: UITextField? - var text: Binding! - // break retained cycle through ComposeUIState.currentInput - unowned var uiState: ComposeUIState! - var maxLength: Int? - var didChange: ((String) -> Void)? - var didEndEditing: (() -> Void)? - var focusNextView: Binding? - - var skipSettingTextOnNextUpdate = false - - var toolbarElements: [ComposeUIState.ToolbarElement] { - [.emojiPicker] - } - - @objc func didChange(_ textField: UITextField) { - text.wrappedValue = textField.text ?? "" - didChange?(text.wrappedValue) - } - - @objc func returnKeyPressed() { - focusNextView?.wrappedValue = true - } - - func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { - if let maxLength { - return ((textField.text ?? "") as NSString).replacingCharacters(in: range, with: string).count <= maxLength - } else { - return true - } - } - - func textFieldDidBeginEditing(_ textField: UITextField) { - uiState.currentInput = self - updateAutocompleteState(textField: textField) - } - - func textFieldDidEndEditing(_ textField: UITextField) { - uiState.currentInput = nil - updateAutocompleteState(textField: textField) - didEndEditing?() - } - - func textFieldDidChangeSelection(_ textField: UITextField) { - // see MainComposeTextView.Coordinator.textViewDidChangeSelection(_:) - skipSettingTextOnNextUpdate = true - self.updateAutocompleteState(textField: textField) - } - - func textField(_ textField: UITextField, editMenuForCharactersIn range: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? { - var actions = suggestedActions - if range.length == 0 { - actions.append(UIAction(title: "Insert Emoji", image: UIImage(systemName: "face.smiling"), handler: { [weak self] _ in - self?.uiState.shouldEmojiAutocompletionBeginExpanded = true - self?.beginAutocompletingEmoji() - })) - } - return UIMenu(children: actions) - } - - func beginAutocompletingEmoji() { - textField?.insertText(":") - } - - func applyFormat(_ format: StatusFormat) { - } - - func autocomplete(with string: String) { - guard let textField = textField, - let text = textField.text, - let selectedRange = textField.selectedTextRange, - let lastWordStartIndex = findAutocompleteLastWord(textField: textField) else { - return - } - - let distanceToEnd = textField.offset(from: selectedRange.start, to: textField.endOfDocument) - - let selectedRangeStartUTF16 = textField.offset(from: textField.beginningOfDocument, to: selectedRange.start) - let characterBeforeCursorIndex = text.utf16.index(text.startIndex, offsetBy: selectedRangeStartUTF16) - - let insertSpace: Bool - if distanceToEnd > 0 { - let charAfterCursor = text[characterBeforeCursorIndex] - insertSpace = charAfterCursor != " " && charAfterCursor != "\n" - } else { - insertSpace = true - } - let string = insertSpace ? string + " " : string - - textField.text!.replaceSubrange(lastWordStartIndex.. text.startIndex { - let c = text[text.index(before: lastWordStartIndex)] - if isPermittedForAutocomplete(c) || c == ":" { - uiState.autocompleteState = nil - return - } - } - - let selectedRangeStartUTF16 = textField.offset(from: textField.beginningOfDocument, to: selectedRange.start) - let cursorIndex = text.utf16.index(text.startIndex, offsetBy: selectedRangeStartUTF16) - - if lastWordStartIndex >= text.startIndex { - let lastWord = text[lastWordStartIndex.. Bool { - return (c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || (c >= "0" && c <= "9") || c == "_" - } - - private func findAutocompleteLastWord(textField: UITextField) -> String.Index? { - guard textField.isFirstResponder, - let selectedRange = textField.selectedTextRange, - selectedRange.isEmpty, - let text = textField.text, - !text.isEmpty else { - return nil - } - - let selectedRangeStartUTF16 = textField.offset(from: textField.beginningOfDocument, to: selectedRange.start) - let cursorIndex = text.utf16.index(text.startIndex, offsetBy: selectedRangeStartUTF16) - - guard cursorIndex != text.startIndex else { - return nil - } - - var lastWordStartIndex = text.index(before: cursorIndex) - while true { - let c = text[lastWordStartIndex] - - if !isPermittedForAutocomplete(c) { - break - } - - if lastWordStartIndex > text.startIndex { - lastWordStartIndex = text.index(before: lastWordStartIndex) - } else { - break - } - } - - return lastWordStartIndex - } - } - -} diff --git a/Tusker/Screens/Compose/ComposeHostingController.swift b/Tusker/Screens/Compose/ComposeHostingController.swift deleted file mode 100644 index 9a38cdc8..00000000 --- a/Tusker/Screens/Compose/ComposeHostingController.swift +++ /dev/null @@ -1,278 +0,0 @@ -// -// ComposeHostingController.swift -// Tusker -// -// Created by Shadowfacts on 8/22/20. -// Copyright © 2020 Shadowfacts. All rights reserved. -// - -import SwiftUI -import Combine -import Pachyderm -import PencilKit -import Duckable - -protocol ComposeHostingControllerDelegate: AnyObject { - func dismissCompose(mode: ComposeUIState.DismissMode) -> Bool -} - -class ComposeHostingController: UIHostingController, DuckableViewController { - - weak var delegate: ComposeHostingControllerDelegate? - weak var duckableDelegate: DuckableViewControllerDelegate? - - let mastodonController: MastodonController - - let uiState: ComposeUIState - - var draft: OldDraft { uiState.draft } - - private var cancellables = [AnyCancellable]() - - init(draft: OldDraft? = nil, mastodonController: MastodonController) { - self.mastodonController = mastodonController - let realDraft = draft ?? OldDraft(accountID: mastodonController.accountInfo!.id) - OldDraftsManager.shared.add(realDraft) - - self.uiState = ComposeUIState(draft: realDraft) - - let wrapper = Wrapper( - mastodonController: mastodonController, - uiState: uiState - ) - super.init(rootView: wrapper) - - self.uiState.delegate = self - pasteConfiguration = UIPasteConfiguration(forAccepting: CompositionAttachment.self) - userActivity = UserActivityManager.newPostActivity(accountID: mastodonController.accountInfo!.id) - - updateNavigationTitle(draft: uiState.draft) - - self.uiState.$draft - .flatMap(\.objectWillChange) - .debounce(for: .milliseconds(250), scheduler: DispatchQueue.global(qos: .utility)) - .sink { - OldDraftsManager.save() - } - .store(in: &cancellables) - - self.uiState.$draft - .sink { [unowned self] draft in - self.updateNavigationTitle(draft: draft) - } - .store(in: &cancellables) - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func updateNavigationTitle(draft: OldDraft) { - if let id = draft.inReplyToID, - let status = mastodonController.persistentContainer.status(for: id) { - navigationItem.title = "Reply to @\(status.account.acct)" - } else { - navigationItem.title = "New Post" - } - } - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - - if !draft.hasContent { - OldDraftsManager.shared.remove(draft) - } - OldDraftsManager.save() - } - - override func canPaste(_ itemProviders: [NSItemProvider]) -> Bool { - guard itemProviders.allSatisfy({ $0.canLoadObject(ofClass: CompositionAttachment.self) }) else { return false } - if mastodonController.instanceFeatures.mastodonAttachmentRestrictions { - guard draft.attachments.allSatisfy({ $0.data.type == .image }) else { return false } - // todo: if providers are videos, this technically allows invalid video/image combinations - return itemProviders.count + draft.attachments.count <= 4 - } else { - return true - } - } - - override func paste(itemProviders: [NSItemProvider]) { - for provider in itemProviders where provider.canLoadObject(ofClass: CompositionAttachment.self) { - provider.loadObject(ofClass: CompositionAttachment.self) { (object, error) in - guard let attachment = object as? CompositionAttachment else { return } - DispatchQueue.main.async { - self.draft.attachments.append(attachment) - } - } - } - } - - override func accessibilityPerformEscape() -> Bool { - dismissCompose(mode: .cancel) - return true - } - - // MARK: Duckable - - func duckableViewControllerWillAnimateDuck(withDuration duration: CGFloat, afterDelay delay: CGFloat) { - withAnimation(.linear(duration: duration).delay(delay)) { - uiState.isDucking = true - } - } - - func duckableViewControllerDidFinishAnimatingDuck() { - uiState.isDucking = false - } - - // MARK: Interaction - - @objc func cwButtonPressed() { - draft.contentWarningEnabled = !draft.contentWarningEnabled - } - - @objc func formatButtonPressed(_ sender: UIBarButtonItem) { - let format = StatusFormat.allCases[sender.tag] - uiState.currentInput?.applyFormat(format) - } - - @objc func emojiPickerButtonPressed() { - guard uiState.autocompleteState == nil else { - return - } - uiState.shouldEmojiAutocompletionBeginExpanded = true - uiState.currentInput?.beginAutocompletingEmoji() - } - - @objc func draftsButtonPresed() { - uiState.isShowingDraftsList = true - } - -} - -extension ComposeHostingController { - struct Wrapper: View { - let mastodonController: MastodonController - @ObservedObject var uiState: ComposeUIState - var draft: OldDraft { - uiState.draft - } - - var body: some View { - ComposeView() - .environmentObject(mastodonController) - .environmentObject(uiState) - .environmentObject(draft) - } - } -} - -extension ComposeHostingController: ComposeUIStateDelegate { - var assetPickerDelegate: AssetPickerViewControllerDelegate? { self } - - func dismissCompose(mode: ComposeUIState.DismissMode) { - let dismissed = delegate?.dismissCompose(mode: mode) ?? false - if !dismissed { - self.dismiss(animated: true) - self.duckableDelegate?.duckableViewControllerWillDismiss(animated: true) - } - } - - func presentAssetPickerSheet() { - let picker = AssetPickerViewController() - picker.assetPickerDelegate = self - picker.modalPresentationStyle = .pageSheet - picker.overrideUserInterfaceStyle = .dark - let sheet = picker.sheetPresentationController! - sheet.detents = [.medium(), .large()] - sheet.prefersEdgeAttachedInCompactHeight = true - self.present(picker, animated: true) - } - - func presentComposeDrawing() { - let drawing: PKDrawing - - if case let .edit(id) = uiState.composeDrawingMode, - let attachment = draft.attachments.first(where: { $0.id == id }), - case let .drawing(existingDrawing) = attachment.data { - drawing = existingDrawing - } else { - drawing = PKDrawing() - } - - present(ComposeDrawingNavigationController(editing: drawing, delegate: self), animated: true) - } - - func selectDraft(_ draft: OldDraft) { - if self.draft.hasContent { - OldDraftsManager.save() - } else { - OldDraftsManager.shared.remove(self.draft) - } - uiState.draft = draft - uiState.isShowingDraftsList = false - } -} - -extension ComposeHostingController: AssetPickerViewControllerDelegate { - func assetPicker(_ assetPicker: AssetPickerViewController, shouldAllowAssetOfType type: CompositionAttachmentData.AttachmentType) -> Bool { - if mastodonController.instanceFeatures.mastodonAttachmentRestrictions { - if (type == .video && draft.attachments.count > 0) || - draft.attachments.contains(where: { $0.data.type == .video }) || - assetPicker.currentCollectionSelectedAssets.contains(where: { $0.type == .video }) { - return false - } - return draft.attachments.count + assetPicker.currentCollectionSelectedAssets.count < 4 - } else { - return true - } - } - - func assetPicker(_ assetPicker: AssetPickerViewController, didSelectAttachments attachments: [CompositionAttachmentData]) { - let attachments = attachments.map { - CompositionAttachment(data: $0) - } - withAnimation { - draft.attachments.append(contentsOf: attachments) - } - } -} - -// superseded by duckable stuff -@available(iOS, obsoleted: 16.0) -extension ComposeHostingController: UIAdaptivePresentationControllerDelegate { - func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool { - return Preferences.shared.automaticallySaveDrafts || !draft.hasContent - } - - func presentationControllerWillDismiss(_ presentationController: UIPresentationController) { - UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: self, for: nil) - } - - func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) { - uiState.isShowingSaveDraftSheet = true - } - - func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { - OldDraftsManager.save() - } -} - -extension ComposeHostingController: ComposeDrawingViewControllerDelegate { - func composeDrawingViewControllerClose(_ drawingController: ComposeDrawingViewController) { - dismiss(animated: true) - } - - func composeDrawingViewController(_ drawingController: ComposeDrawingViewController, saveDrawing drawing: PKDrawing) { - switch uiState.composeDrawingMode { - case nil, .createNew: - let attachment = CompositionAttachment(data: .drawing(drawing)) - draft.attachments.append(attachment) - - case let .edit(id): - let existing = draft.attachments.first { $0.id == id } - existing?.data = .drawing(drawing) - } - - dismiss(animated: true) - } -} diff --git a/Tusker/Screens/Compose/ComposePollView.swift b/Tusker/Screens/Compose/ComposePollView.swift deleted file mode 100644 index 073b29e6..00000000 --- a/Tusker/Screens/Compose/ComposePollView.swift +++ /dev/null @@ -1,232 +0,0 @@ -// -// ComposePollView.swift -// Tusker -// -// Created by Shadowfacts on 4/28/21. -// Copyright © 2021 Shadowfacts. All rights reserved. -// - -import SwiftUI -import TuskerComponents - -struct ComposePollView: View { - private static let formatter: DateComponentsFormatter = { - let f = DateComponentsFormatter() - f.maximumUnitCount = 1 - f.unitsStyle = .full - f.allowedUnits = [.weekOfMonth, .day, .hour, .minute] - return f - }() - - @ObservedObject var draft: OldDraft - @ObservedObject var poll: OldDraft.Poll - - @EnvironmentObject var mastodonController: MastodonController - @Environment(\.colorScheme) var colorScheme: ColorScheme - - @State private var duration: Duration - - init(draft: OldDraft, poll: OldDraft.Poll) { - self.draft = draft - self.poll = poll - - self._duration = State(initialValue: .fromTimeInterval(poll.duration) ?? .oneDay) - } - - private var canAddOption: Bool { - if let pollConfig = mastodonController.instance?.pollsConfiguration { - return poll.options.count < pollConfig.maxOptions - } else { - return true - } - } - - var body: some View { - VStack { - HStack { - Text("Poll") - .font(.headline) - - Spacer() - - Button(action: self.removePoll) { - Image(systemName: "xmark") - .imageScale(.small) - .padding(4) - } - .accessibilityLabel("Remove poll") - .buttonStyle(.plain) - .accentColor(buttonForegroundColor) - .background(Circle().foregroundColor(buttonBackgroundColor)) - .hoverEffect() - } - - List { - ForEach(Array(poll.options.enumerated()), id: \.element.id) { (e) in - ComposePollOption(poll: poll, option: e.element, optionIndex: e.offset) - .frame(height: 36) - .listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 0)) - .listRowSeparator(.hidden) - .listRowBackground(Color.clear) - } - .onMove { indices, newIndex in - poll.options.move(fromOffsets: indices, toOffset: newIndex) - } - } - .listStyle(.plain) - .frame(height: 44 * CGFloat(poll.options.count)) - - Button(action: self.addOption) { - Label { - Text("Add Option") - } icon: { - Image(systemName: "plus") - .foregroundColor(.accentColor) - } - } - .buttonStyle(.borderless) - .disabled(!canAddOption) - - HStack { - MenuPicker(selection: $poll.multiple, options: [ - .init(value: true, title: "Allow multiple"), - .init(value: false, title: "Single choice"), - ]) - .frame(maxWidth: .infinity) - - MenuPicker(selection: $duration, options: Duration.allCases.map { - .init(value: $0, title: ComposePollView.formatter.string(from: $0.timeInterval)!) - }) - .frame(maxWidth: .infinity) - } - } - .padding(8) - .background( - backgroundColor - .cornerRadius(10) - ) - .onChange(of: duration, perform: { (value) in - poll.duration = value.timeInterval - }) - } - - private var backgroundColor: Color { - // in light mode, .secondarySystemBackground has a blue-ish hue, which we don't want - colorScheme == .dark ? Color.appFill : Color(white: 0.95) - } - - private var buttonBackgroundColor: Color { - Color(white: colorScheme == .dark ? 0.1 : 0.8) - } - - private var buttonForegroundColor: Color { - Color(UIColor.label) - } - - private func removePoll() { - withAnimation { - self.draft.poll = nil - } - } - - private func addOption() { - poll.options.append(OldDraft.Poll.Option("")) - } -} - -extension ComposePollView { - enum Duration: Hashable, Equatable, CaseIterable { - case fiveMinutes, thirtyMinutes, oneHour, sixHours, oneDay, threeDays, sevenDays - - static func fromTimeInterval(_ ti: TimeInterval) -> Duration? { - for it in allCases where it.timeInterval == ti { - return it - } - return nil - } - - var timeInterval: TimeInterval { - switch self { - case .fiveMinutes: - return 5 * 60 - case .thirtyMinutes: - return 30 * 60 - case .oneHour: - return 60 * 60 - case .sixHours: - return 6 * 60 * 60 - case .oneDay: - return 24 * 60 * 60 - case .threeDays: - return 3 * 24 * 60 * 60 - case .sevenDays: - return 7 * 24 * 60 * 60 - } - } - } -} - -struct ComposePollOption: View { - @ObservedObject var poll: OldDraft.Poll - @ObservedObject var option: OldDraft.Poll.Option - let optionIndex: Int - - @EnvironmentObject private var mastodonController: MastodonController - - var body: some View { - HStack(spacing: 4) { - Checkbox(radiusFraction: poll.multiple ? 0.1 : 0.5, borderWidth: 2) - .animation(.default, value: poll.multiple) - - textField - - Button(action: self.removeOption) { - Image(systemName: "minus.circle.fill") - } - .buttonStyle(.plain) - .foregroundColor(poll.options.count == 1 ? .gray : .red) - .disabled(poll.options.count == 1) - .hoverEffect() - } - } - - private var textField: some View { - var field = ComposeEmojiTextField(text: $option.text, placeholder: "Option \(optionIndex + 1)", maxLength: mastodonController.instance?.pollsConfiguration?.maxCharactersPerOption) - return field.backgroundColor(.appBackground) - } - - private func removeOption() { - poll.options.remove(at: optionIndex) - } - - struct Checkbox: View { - private let radiusFraction: CGFloat - private let size: CGFloat = 20 - private let innerSize: CGFloat - - init(radiusFraction: CGFloat, borderWidth: CGFloat) { - self.radiusFraction = radiusFraction - self.innerSize = self.size - 2 * borderWidth - } - - var body: some View { - ZStack { - Rectangle() - .foregroundColor(.gray) - .frame(width: size, height: size) - .cornerRadius(radiusFraction * size) - - Rectangle() - .foregroundColor(Color(UIColor.appBackground)) - .frame(width: innerSize, height: innerSize) - .cornerRadius(radiusFraction * innerSize) - } - } - } -} - -//struct ComposePollView_Previews: PreviewProvider { -// static var previews: some View { -// ComposePollView() -// } -//} diff --git a/Tusker/Screens/Compose/ComposeReplyView.swift b/Tusker/Screens/Compose/ComposeReplyView.swift deleted file mode 100644 index 48d95247..00000000 --- a/Tusker/Screens/Compose/ComposeReplyView.swift +++ /dev/null @@ -1,100 +0,0 @@ -// -// ComposeReplyView.swift -// Tusker -// -// Created by Shadowfacts on 8/22/20. -// Copyright © 2020 Shadowfacts. All rights reserved. -// - -import SwiftUI - -struct ComposeReplyView: View { - let status: StatusMO - let rowTopInset: CGFloat - let globalFrameOutsideList: CGRect - - @State private var displayNameHeight: CGFloat? - @State private var contentHeight: CGFloat? - - @EnvironmentObject private var mastodonController: MastodonController - @ObservedObject private var preferences = Preferences.shared - - private let horizSpacing: CGFloat = 8 - - var body: some View { - HStack(alignment: .top, spacing: horizSpacing) { - GeometryReader(content: self.replyAvatarImage) - .frame(width: 50) - - VStack(alignment: .leading, spacing: 0) { - HStack { - AccountDisplayNameLabel(account: status.account, textStyle: .body, emojiSize: 17) - .lineLimit(1) - .layoutPriority(1) - - Text(verbatim: "@\(status.account.acct)") - .font(.system(size: 17, weight: .light)) - .foregroundColor(.secondary) - .lineLimit(1) - - Spacer() - } - .background(GeometryReader { proxy in - Color.clear - .preference(key: DisplayNameHeightPrefKey.self, value: proxy.size.height) - .onPreferenceChange(DisplayNameHeightPrefKey.self) { newValue in - displayNameHeight = newValue - } - }) - - ComposeReplyContentView(status: status, mastodonController: mastodonController) { newHeight in - // otherwise, with long in-reply-to statuses, the main content text view position seems not to update - // and it ends up partially behind the header - DispatchQueue.main.async { - contentHeight = newHeight - } - } - .frame(height: contentHeight ?? 0) - } - } - .frame(minHeight: 50, alignment: .top) - } - - private func replyAvatarImage(geometry: GeometryProxy) -> some View { - // using a coordinate space declared outside of the List doesn't work, so we do the math ourselves - let globalFrame = geometry.frame(in: .global) - let scrollOffset = -(globalFrame.minY - globalFrameOutsideList.minY) - - // add rowTopInset so that the image is always at least rowTopInset away from the top - var offset = scrollOffset + rowTopInset - - // offset can never be less than 0 (i.e., above the top of the in-reply-to content) - offset = max(offset, 0) - - // subtract 50, because we care about where the bottom of the view is but the offset is relative to the top of the view - let maxOffset = max((contentHeight ?? 0) + (displayNameHeight ?? 0) - 50, 0) - - // once you scroll past the in-reply-to-content, the bottom of the avatar should be pinned to the bottom of the content - offset = min(offset, maxOffset) - - return ComposeAvatarImageView(url: status.account.avatar) - .frame(width: 50, height: 50) - .cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 50) - .offset(x: 0, y: offset) - .accessibilityHidden(true) - } - -} - -private struct DisplayNameHeightPrefKey: PreferenceKey { - static var defaultValue: CGFloat = 0 - static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { - value = nextValue() - } -} - -//struct ComposeReplyView_Previews: PreviewProvider { -// static var previews: some View { -// ComposeReplyView() -// } -//} diff --git a/Tusker/Screens/Compose/ComposeTextView.swift b/Tusker/Screens/Compose/ComposeTextView.swift deleted file mode 100644 index b5ab6d59..00000000 --- a/Tusker/Screens/Compose/ComposeTextView.swift +++ /dev/null @@ -1,137 +0,0 @@ -// -// ComposeTextView.swift -// Tusker -// -// Created by Shadowfacts on 8/18/20. -// Copyright © 2020 Shadowfacts. All rights reserved. -// - -import SwiftUI - -struct ComposeTextView: View { - @Binding private var text: String - private let placeholder: Text? - private let minHeight: CGFloat - - private var heightDidChange: ((CGFloat) -> Void)? - private var backgroundColor = UIColor.secondarySystemBackground - - @State private var height: CGFloat? - - init(text: Binding, placeholder: Text?, minHeight: CGFloat = 150) { - self._text = text - self.placeholder = placeholder - self.minHeight = minHeight - } - - var body: some View { - ZStack(alignment: .topLeading) { - Color(backgroundColor) - - if text.isEmpty, let placeholder = placeholder { - placeholder - .font(.body) - .foregroundColor(.secondary) - .offset(x: 4, y: 8) - } - - WrappedTextView( - text: $text, - textDidChange: self.textDidChange, - font: .preferredFont(forTextStyle: .body) - ) - .frame(height: height ?? minHeight) - } - } - - private func textDidChange(textView: UITextView) { - height = max(minHeight, textView.contentSize.height) - heightDidChange?(height!) - } - - func heightDidChange(_ callback: @escaping (CGFloat) -> Void) -> Self { - var copy = self - copy.heightDidChange = callback - return copy - } - - func backgroundColor(_ color: UIColor) -> Self { - var copy = self - copy.backgroundColor = color - return copy - } -} - -struct WrappedTextView: UIViewRepresentable { - typealias UIViewType = UITextView - - @Binding var text: String - var textDidChange: ((UITextView) -> Void)? - var font = UIFont.systemFont(ofSize: 20) - - @Environment(\.isEnabled) private var isEnabled: Bool - - func makeUIView(context: Context) -> UITextView { - let textView = UITextView() - textView.delegate = context.coordinator - textView.isEditable = true - textView.backgroundColor = .clear - textView.font = font - textView.adjustsFontForContentSizeCategory = true - textView.textContainer.lineBreakMode = .byWordWrapping - return textView - } - - func updateUIView(_ uiView: UITextView, context: Context) { - uiView.text = text - uiView.isEditable = isEnabled - context.coordinator.textView = uiView - context.coordinator.text = $text - context.coordinator.didChange = textDidChange - // wait until the next runloop iteration so that SwiftUI view updates have finished and - // the text view knows its new content size - DispatchQueue.main.async { - self.textDidChange?(uiView) - } - } - - func makeCoordinator() -> Coordinator { - return Coordinator(text: $text, didChange: textDidChange) - } - - class Coordinator: NSObject, UITextViewDelegate, ComposeTextViewCaretScrolling { - weak var textView: UITextView? - var text: Binding - var didChange: ((UITextView) -> Void)? - var caretScrollPositionAnimator: UIViewPropertyAnimator? - - init(text: Binding, didChange: ((UITextView) -> Void)?) { - self.text = text - self.didChange = didChange - - super.init() - - NotificationCenter.default.addObserver(self, selector: #selector(keyboardDidShow), name: UIResponder.keyboardDidShowNotification, object: nil) - } - - @objc private func keyboardDidShow() { - guard let textView, - textView.isFirstResponder else { return } - ensureCursorVisible(textView: textView) - } - - func textViewDidChange(_ textView: UITextView) { - text.wrappedValue = textView.text - didChange?(textView) - - ensureCursorVisible(textView: textView) - } - } -} - - -//struct ComposeTextView_Previews: PreviewProvider { -// static var previews: some View { -// ComposeTextView() -// } -//} diff --git a/Tusker/Screens/Compose/ComposeTextViewCaretScrolling.swift b/Tusker/Screens/Compose/ComposeTextViewCaretScrolling.swift deleted file mode 100644 index b2a68561..00000000 --- a/Tusker/Screens/Compose/ComposeTextViewCaretScrolling.swift +++ /dev/null @@ -1,60 +0,0 @@ -// -// ComposeTextViewCaretScrolling.swift -// Tusker -// -// Created by Shadowfacts on 11/11/20. -// Copyright © 2020 Shadowfacts. All rights reserved. -// - -import UIKit - -protocol ComposeTextViewCaretScrolling: AnyObject { - var caretScrollPositionAnimator: UIViewPropertyAnimator? { get set } -} - -extension ComposeTextViewCaretScrolling { - func ensureCursorVisible(textView: UITextView) { - guard textView.isFirstResponder, - let range = textView.selectedTextRange, - let scrollView = findParentScrollView(of: textView) else { - return - } - - // We use a UIViewProperty animator to change the scroll view position so that we can store the currently - // running one on the Coordinator. This allows us to cancel the running one, preventing multiple animations - // from attempting to change the scroll view offset simultaneously, causing it to jitter around. This can - // happen if the user is pressing return and quickly creating many new lines. - - if let existing = caretScrollPositionAnimator { - existing.stopAnimation(true) - } - - let cursorRect = textView.caretRect(for: range.start) - var rectToMakeVisible = textView.convert(cursorRect, to: scrollView) - - // expand the rect to be three times the cursor height centered on the cursor so that there's - // some space between the bottom of the line of text being edited and the top of the keyboard - rectToMakeVisible.origin.y -= cursorRect.height - rectToMakeVisible.size.height *= 3 - - let animator = UIViewPropertyAnimator(duration: 0.2, curve: .easeInOut) { - scrollView.scrollRectToVisible(rectToMakeVisible, animated: false) - } - self.caretScrollPositionAnimator = animator - animator.startAnimation() - } - - private func findParentScrollView(of view: UIView) -> UIScrollView? { - var current: UIView = view - while let superview = current.superview { - if let scrollView = superview as? UIScrollView, - scrollView.isScrollEnabled { - return scrollView - } else { - current = superview - } - } - - return nil - } -} diff --git a/Tusker/Screens/Compose/ComposeToolbar.swift b/Tusker/Screens/Compose/ComposeToolbar.swift deleted file mode 100644 index 62132a75..00000000 --- a/Tusker/Screens/Compose/ComposeToolbar.swift +++ /dev/null @@ -1,149 +0,0 @@ -// -// ComposeToolbar.swift -// Tusker -// -// Created by Shadowfacts on 11/12/22. -// Copyright © 2022 Shadowfacts. All rights reserved. -// - -import SwiftUI -import Pachyderm -import TuskerComponents - -struct ComposeToolbar: View { - static let height: CGFloat = 44 - private static let visibilityOptions: [MenuPicker.Option] = Visibility.allCases.map { vis in - .init(value: vis, title: vis.displayName, subtitle: vis.subtitle, image: UIImage(systemName: vis.unfilledImageName), accessibilityLabel: "Visibility: \(vis.displayName)") - } - - @ObservedObject var draft: OldDraft - - @EnvironmentObject private var uiState: ComposeUIState - @EnvironmentObject private var mastodonController: MastodonController - @ObservedObject private var preferences = Preferences.shared - @ScaledMetric(relativeTo: .body) private var imageSize: CGFloat = 22 - @State private var minWidth: CGFloat? - @State private var realWidth: CGFloat? - - var body: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 0) { - Button("CW") { - draft.contentWarningEnabled.toggle() - } - .accessibilityLabel(draft.contentWarningEnabled ? "Remove content warning" : "Add content warning") - .padding(5) - .hoverEffect() - - MenuPicker(selection: $draft.visibility, options: Self.visibilityOptions, buttonStyle: .iconOnly) -// // the button has a bunch of extra space by default, but combined with what we add it's too much - .padding(.horizontal, -8) - - if mastodonController.instanceFeatures.localOnlyPosts { - MenuPicker(selection: $draft.localOnly, options: [ - .init(value: true, title: "Local-only", subtitle: "Only \(mastodonController.accountInfo!.instanceURL.host!)", image: UIImage(named: "link.broken")), - .init(value: false, title: "Federated", image: UIImage(systemName: "link")) - ], buttonStyle: .iconOnly) - .padding(.horizontal, -8) - } - - if let currentInput = uiState.currentInput, currentInput.toolbarElements.contains(.emojiPicker) { - Button(action: self.emojiPickerButtonPressed) { - Label("Insert custom emoji", systemImage: "face.smiling") - } - .labelStyle(.iconOnly) - .font(.system(size: imageSize)) - .padding(5) - .hoverEffect() - .transition(.opacity.animation(.linear(duration: 0.2))) - } - - if let currentInput = uiState.currentInput, - currentInput.toolbarElements.contains(.formattingButtons), - preferences.statusContentType != .plain { - Spacer() - - ForEach(StatusFormat.allCases, id: \.rawValue) { format in - Button(action: self.formatAction(format)) { - if let imageName = format.imageName { - Image(systemName: imageName) - .font(.system(size: imageSize)) - } else if let (str, attrs) = format.title { - let container = try! AttributeContainer(attrs, including: \.uiKit) - Text(AttributedString(str, attributes: container)) - } - } - .accessibilityLabel(format.accessibilityLabel) - .padding(5) - .hoverEffect() - .transition(.opacity.animation(.linear(duration: 0.2))) - } - } - - Spacer() - } - .padding(.horizontal, 16) - .frame(minWidth: minWidth) - .background(GeometryReader { proxy in - Color.clear - .preference(key: ToolbarWidthPrefKey.self, value: proxy.size.width) - .onPreferenceChange(ToolbarWidthPrefKey.self) { width in - realWidth = width - } - }) - } - .scrollDisabledIfAvailable(realWidth ?? 0 <= minWidth ?? 0) - .frame(height: Self.height) - .frame(maxWidth: .infinity) - .background(.regularMaterial, ignoresSafeAreaEdges: .bottom) - .overlay(alignment: .top) { - Divider() - } - .background(GeometryReader { proxy in - Color.clear - .preference(key: ToolbarWidthPrefKey.self, value: proxy.size.width) - .onPreferenceChange(ToolbarWidthPrefKey.self) { width in - minWidth = width - } - }) - } - - private func emojiPickerButtonPressed() { - guard uiState.autocompleteState == nil else { - return - } - uiState.shouldEmojiAutocompletionBeginExpanded = true - uiState.currentInput?.beginAutocompletingEmoji() - } - - private func formatAction(_ format: StatusFormat) -> () -> Void { - { - uiState.currentInput?.applyFormat(format) - } - } -} - -private struct ToolbarWidthPrefKey: PreferenceKey { - static var defaultValue: CGFloat? = nil - static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) { - value = nextValue() - } -} - -private extension View { - @available(iOS, obsoleted: 16.0) - @ViewBuilder - func scrollDisabledIfAvailable(_ disabled: Bool) -> some View { - if #available(iOS 16.0, *) { - self.scrollDisabled(disabled) - } else { - self - } - } -} - -struct ComposeToolbar_Previews: PreviewProvider { - static var previews: some View { - ComposeToolbar(draft: OldDraft(accountID: "")) - } -} diff --git a/Tusker/Screens/Compose/ComposeUIState.swift b/Tusker/Screens/Compose/ComposeUIState.swift deleted file mode 100644 index 46e93f7f..00000000 --- a/Tusker/Screens/Compose/ComposeUIState.swift +++ /dev/null @@ -1,80 +0,0 @@ -// -// ComposeUIState.swift -// Tusker -// -// Created by Shadowfacts on 8/24/20. -// Copyright © 2020 Shadowfacts. All rights reserved. -// - -import SwiftUI - -protocol ComposeUIStateDelegate: AnyObject { - var assetPickerDelegate: AssetPickerViewControllerDelegate? { get } - - func dismissCompose(mode: ComposeUIState.DismissMode) - // @available(iOS, obsoleted: 16.0) - func presentAssetPickerSheet() - func presentComposeDrawing() - func selectDraft(_ draft: OldDraft) - func paste(itemProviders: [NSItemProvider]) -} - -class ComposeUIState: ObservableObject { - - weak var delegate: ComposeUIStateDelegate? - - @Published var draft: OldDraft - @Published var isShowingSaveDraftSheet = false - @Published var isShowingDraftsList = false - @Published var attachmentsMissingDescriptions = Set() - @Published var autocompleteState: AutocompleteState? = nil - @Published var isDucking = false - - var composeDrawingMode: ComposeDrawingMode? - - var shouldEmojiAutocompletionBeginExpanded = false - @Published var currentInput: ComposeInput? - - init(draft: OldDraft) { - self.draft = draft - } - -} - -extension ComposeUIState { - enum ComposeDrawingMode { - case createNew - case edit(id: UUID) - } -} - -extension ComposeUIState { - enum AutocompleteState: Equatable { - case mention(String) - case emoji(String) - case hashtag(String) - } -} - -extension ComposeUIState { - enum DismissMode { - case cancel, post - } -} - -protocol ComposeInput: AnyObject { - var toolbarElements: [ComposeUIState.ToolbarElement] { get } - - func autocomplete(with string: String) - - func applyFormat(_ format: StatusFormat) - - func beginAutocompletingEmoji() -} - -extension ComposeUIState { - enum ToolbarElement { - case emojiPicker - case formattingButtons - } -} diff --git a/Tusker/Screens/Compose/ComposeView.swift b/Tusker/Screens/Compose/ComposeView.swift deleted file mode 100644 index dfc811f0..00000000 --- a/Tusker/Screens/Compose/ComposeView.swift +++ /dev/null @@ -1,376 +0,0 @@ -// -// ComposeView.swift -// Tusker -// -// Created by Shadowfacts on 8/18/20. -// Copyright © 2020 Shadowfacts. All rights reserved. -// - -import SwiftUI -import Pachyderm -import Combine -import ComposeUI - -@propertyWrapper struct OptionalStateObject: DynamicProperty { - private class Republisher: ObservableObject { - var cancellable: AnyCancellable? - var wrapped: T? { - didSet { - cancellable?.cancel() - cancellable = wrapped?.objectWillChange - .receive(on: RunLoop.main) - .sink { [unowned self] _ in - self.objectWillChange.send() - } - } - } - } - - @StateObject private var republisher = Republisher() - @State private var object: T? - var wrappedValue: T? { - get { - object - } - nonmutating set { - object = newValue - } - } - - func update() { - republisher.wrapped = wrappedValue - } -} - -struct ComposeView: View { - @EnvironmentObject var mastodonController: MastodonController - @EnvironmentObject var uiState: ComposeUIState - @EnvironmentObject var draft: OldDraft - - @State private var globalFrameOutsideList: CGRect = .zero - @State private var contentWarningBecomeFirstResponder = false - @State private var mainComposeTextViewBecomeFirstResponder = false - @StateObject private var keyboardReader = KeyboardReader() - - @OptionalStateObject private var poster: PostService? - @State private var isShowingPostErrorAlert = false - @State private var postError: PostService.Error? - private var isPosting: Bool { - poster != nil - } - - private let stackPadding: CGFloat = 8 - - private var charactersRemaining: Int { - let limit = mastodonController.instanceFeatures.maxStatusChars - let cwCount = draft.contentWarningEnabled ? draft.contentWarning.count : 0 - return limit - (cwCount + CharacterCounter.count(text: draft.text, for: mastodonController.instanceFeatures)) - } - - private var requiresAttachmentDescriptions: Bool { - guard Preferences.shared.requireAttachmentDescriptions else { return false } - let attachmentIds = draft.attachments.map(\.id) - return attachmentIds.contains { uiState.attachmentsMissingDescriptions.contains($0) } - } - - private var validAttachmentCombination: Bool { - if !mastodonController.instanceFeatures.mastodonAttachmentRestrictions { - return true - } else if draft.attachments.contains(where: { $0.data.type == .video }) && draft.attachments.count > 1 { - return false - } else if draft.attachments.count > 4 { - return false - } - return true - } - - private var postButtonEnabled: Bool { - draft.hasContent - && charactersRemaining >= 0 - && !isPosting - && !requiresAttachmentDescriptions - && validAttachmentCombination - && (draft.poll == nil || draft.poll!.options.allSatisfy { !$0.text.isEmpty }) - } - - var body: some View { - ZStack(alignment: .top) { - // just using .background doesn't work; for some reason it gets inset immediately after the software keyboard is dismissed - Color.appBackground - .edgesIgnoringSafeArea(.all) - - mainList - .scrollDismissesKeyboardInteractivelyIfAvailable() - - if let poster = poster { - // can't use SwiftUI.ProgressView because there's no UIProgressView.Style.bar equivalent, see FB8587149 - WrappedProgressView(value: poster.currentStep, total: poster.totalSteps) - } - } - .safeAreaInset(edge: .bottom, spacing: 0) { - if !uiState.isDucking { - VStack(spacing: 0) { - autocompleteSuggestions - .transition(.move(edge: .bottom)) - .animation(.default, value: uiState.autocompleteState) - - ComposeToolbar(draft: draft) - } - // on iPadOS15, the toolbar ends up below the keyboard's toolbar without this - .padding(.bottom, keyboardInset) - .transition(.move(edge: .bottom)) - } - } - .background(GeometryReader { proxy in - Color.clear - .preference(key: GlobalFrameOutsideListPrefKey.self, value: proxy.frame(in: .global)) - .onPreferenceChange(GlobalFrameOutsideListPrefKey.self) { frame in - globalFrameOutsideList = frame - } - }) - .sheet(isPresented: $uiState.isShowingDraftsList) { - DraftsRepresentable(currentDraft: draft, mastodonController: mastodonController) - } - .actionSheet(isPresented: $uiState.isShowingSaveDraftSheet, content: self.saveAndCloseSheet) - .alert(isPresented: $isShowingPostErrorAlert) { - Alert( - title: Text("Error Posting Status"), - message: Text(postError?.localizedDescription ?? ""), - dismissButton: .default(Text("OK")) - ) - } - .toolbar { - ToolbarItem(placement: .cancellationAction) { cancelButton } - ToolbarItem(placement: .confirmationAction) { postButton } - } - } - - @available(iOS, obsoleted: 16.0) - private var keyboardInset: CGFloat { - if #unavailable(iOS 16.0), - UIDevice.current.userInterfaceIdiom == .pad, - keyboardReader.isVisible { - return 44 - } else { - return 0 - } - } - - @ViewBuilder - private var autocompleteSuggestions: some View { - if let state = uiState.autocompleteState { - ComposeAutocompleteView(autocompleteState: state) - } - } - - private var mainList: some View { - List { - if let id = draft.inReplyToID, - let status = mastodonController.persistentContainer.status(for: id) { - ComposeReplyView( - status: status, - rowTopInset: 8, - globalFrameOutsideList: globalFrameOutsideList - ) - .listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8)) - .listRowSeparator(.hidden) - .listRowBackground(Color.appBackground) - } - - header - .listRowInsets(EdgeInsets(top: draft.inReplyToID == nil ? 8 : 4, leading: 8, bottom: 4, trailing: 8)) - .listRowSeparator(.hidden) - .listRowBackground(Color.appBackground) - - if uiState.draft.contentWarningEnabled { - ComposeEmojiTextField( - text: $uiState.draft.contentWarning, - placeholder: "Write your warning here", - becomeFirstResponder: $contentWarningBecomeFirstResponder, - focusNextView: $mainComposeTextViewBecomeFirstResponder - ) - .listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8)) - .listRowSeparator(.hidden) - .listRowBackground(Color.appBackground) - } - - MainComposeTextView( - draft: draft, - becomeFirstResponder: $mainComposeTextViewBecomeFirstResponder - ) - .listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8)) - .listRowSeparator(.hidden) - .listRowBackground(Color.appBackground) - - if let poll = draft.poll { - ComposePollView(draft: draft, poll: poll) - .listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8)) - .listRowSeparator(.hidden) - .listRowBackground(Color.appBackground) - } - - ComposeAttachmentsList( - draft: draft - ) - .listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 8, trailing: 8)) - .listRowBackground(Color.appBackground) - } - .animation(.default, value: draft.poll?.options.count) - .scrollDismissesKeyboardInteractivelyIfAvailable() - .listStyle(.plain) - .disabled(isPosting) - .onChange(of: draft.contentWarningEnabled) { newValue in - if newValue { - contentWarningBecomeFirstResponder = true - } - } - } - - private var header: some View { - HStack(alignment: .top) { - ComposeCurrentAccount() - .accessibilitySortPriority(1) - - Spacer() - - Text(verbatim: charactersRemaining.description) - .foregroundColor(charactersRemaining < 0 ? .red : .secondary) - .font(Font.body.monospacedDigit()) - .accessibility(label: Text(charactersRemaining < 0 ? "\(-charactersRemaining) characters too many" : "\(charactersRemaining) characters remaining")) - // this should come first, so VO users can back to it from the main compose text view - .accessibilitySortPriority(0) - }.frame(height: 50) - } - - private var cancelButton: some View { - Button(action: self.cancel) { - Text("Cancel") - // otherwise all Buttons in the nav bar are made semibold - .font(.system(size: 17, weight: .regular)) - } - } - - @ViewBuilder - private var postButton: some View { - if draft.hasContent { - Button { - Task { - await self.postStatus() - } - } label: { - Text("Post") - } - .keyboardShortcut(.return, modifiers: .command) - .disabled(!postButtonEnabled) - } else { - Button { - uiState.isShowingDraftsList = true - } label: { - Text("Drafts") - } - } - } - - private func cancel() { - if Preferences.shared.automaticallySaveDrafts { - // draft is already stored in drafts manager, drafts manager is saved by ComposeHostingController.viewWillDisappear - uiState.delegate?.dismissCompose(mode: .cancel) - } else { - // if the draft doesn't have content, it doesn't need to be saved - if draft.hasContent { - uiState.isShowingSaveDraftSheet = true - } else { - OldDraftsManager.shared.remove(draft) - uiState.delegate?.dismissCompose(mode: .cancel) - } - } - } - - private func saveAndCloseSheet() -> ActionSheet { - ActionSheet(title: Text("Do you want to save the current post as a draft?"), buttons: [ - .default(Text("Save Draft"), action: { - // draft is already stored in drafts manager, drafts manager is saved by ComposeHostingController.viewWillDisappear - uiState.isShowingSaveDraftSheet = false - uiState.delegate?.dismissCompose(mode: .cancel) - }), - .destructive(Text("Delete Draft"), action: { - OldDraftsManager.shared.remove(draft) - uiState.isShowingSaveDraftSheet = false - uiState.delegate?.dismissCompose(mode: .cancel) - }), - .cancel(), - ]) - } - - private func postStatus() async { - guard !isPosting, - draft.hasContent else { - return - } - - let poster = PostService(mastodonController: mastodonController, draft: draft) - self.poster = poster - - do { - try await poster.post() - - // wait .25 seconds so the user can see the progress bar has completed - try? await Task.sleep(nanoseconds: 250_000_000) - - uiState.delegate?.dismissCompose(mode: .post) - - } catch let error as PostService.Error { - self.isShowingPostErrorAlert = true - self.postError = error - } catch { - fatalError("Unreachable") - } - - self.poster = nil - } -} - -extension View { - @available(iOS, obsoleted: 16.0) - @ViewBuilder - func scrollDismissesKeyboardInteractivelyIfAvailable() -> some View { - if #available(iOS 16.0, *) { - self.scrollDismissesKeyboard(.interactively) - } else { - self - } - } -} - -private struct GlobalFrameOutsideListPrefKey: PreferenceKey { - static var defaultValue: CGRect = .zero - static func reduce(value: inout CGRect, nextValue: () -> CGRect) { - value = nextValue() - } -} - -@available(iOS, obsoleted: 16.0) -private class KeyboardReader: ObservableObject { - @Published var isVisible = false - - init() { - NotificationCenter.default.addObserver(self, selector: #selector(willShow), name: UIResponder.keyboardWillShowNotification, object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(willHide), name: UIResponder.keyboardWillHideNotification, object: nil) - } - - @objc func willShow(_ notification: Foundation.Notification) { - // when a hardware keyboard is connected, the height is very short, so we don't consider that being "visible" - let endFrame = notification.userInfo![UIResponder.keyboardFrameEndUserInfoKey] as! CGRect - isVisible = endFrame.height > 72 - } - - @objc func willHide() { - isVisible = false - } -} - -//struct ComposeView_Previews: PreviewProvider { -// static var previews: some View { -// ComposeView() -// } -//} diff --git a/Tusker/Screens/Compose/DraftsView.swift b/Tusker/Screens/Compose/DraftsView.swift deleted file mode 100644 index 97dc58b1..00000000 --- a/Tusker/Screens/Compose/DraftsView.swift +++ /dev/null @@ -1,145 +0,0 @@ -// -// DraftsView.swift -// Tusker -// -// Created by Shadowfacts on 11/9/22. -// Copyright © 2022 Shadowfacts. All rights reserved. -// - -import SwiftUI - -@available(iOS, obsoleted: 16.0) -struct DraftsRepresentable: UIViewControllerRepresentable { - typealias UIViewControllerType = UIHostingController - - let currentDraft: OldDraft - let mastodonController: MastodonController - - func makeUIViewController(context: Context) -> UIHostingController { - return UIHostingController(rootView: DraftsView(currentDraft: currentDraft, mastodonController: mastodonController)) - } - - func updateUIViewController(_ uiViewController: UIHostingController, context: Context) { - } -} - -struct DraftsView: View { - let currentDraft: OldDraft - // don't pass this in via the environment b/c it crashes on macOS (at least, in Designed for iPad mode) since the environment doesn't get propagated through the modal popup window or something - let mastodonController: MastodonController - @EnvironmentObject var uiState: ComposeUIState - @StateObject private var draftsManager = OldDraftsManager.shared - @State private var draftForDifferentReply: OldDraft? - - private var visibleDrafts: [OldDraft] { - draftsManager.sorted.filter { - $0.accountID == mastodonController.accountInfo!.id && $0.id != currentDraft.id - } - } - - var body: some View { -// NavigationView { -// List { -// ForEach(visibleDrafts) { draft in -// Button { -// maybeSelectDraft(draft) -// } label: { -// DraftView(draft: draft) -// } -// .contextMenu { -// Button(role: .destructive) { -// OldDraftsManager.remove(draft) -// } label: { -// Label("Delete Draft", systemImage: "trash") -// } -// } -// .onDrag { -// let activity = UserActivityManager.editDraftActivity(id: draft.id, accountID: mastodonController.accountInfo!.id) -// activity.displaysAuxiliaryScene = true -// return NSItemProvider(object: activity) -// } -// } -// .onDelete { indices in -// indices -// .map { visibleDrafts[$0] } -// .forEach { OldDraftsManager.remove($0) } -// } -// .appGroupedListRowBackground() -// } -// .listStyle(.plain) -// .appGroupedListBackground(container: DraftsRepresentable.UIViewControllerType.self) -// .navigationTitle(Text("Drafts")) -// .navigationBarTitleDisplayMode(.inline) -// .toolbar { -// ToolbarItem(placement: .cancellationAction) { -// Button("Cancel") { -// uiState.isShowingDraftsList = false -// } -// } -// } -// } -// .alertWithData("Different Reply", data: $draftForDifferentReply) { draft in -// Button("Cancel", role: .cancel) { -// draftForDifferentReply = nil -// } -// Button("Restore Draft") { -// uiState.delegate?.selectDraft(draft) -// } -// } message: { draft in -// Text("The selected draft is a reply to a different post, do you wish to use it?") -// } - Text("drafts") - } - - private func maybeSelectDraft(_ draft: OldDraft) { - if draft.inReplyToID != currentDraft.inReplyToID, - currentDraft.hasContent { - draftForDifferentReply = draft - } else { - uiState.delegate?.selectDraft(draft) - } - } -} - -struct DraftView: View { - @ObservedObject private var draft: OldDraft - - init(draft: OldDraft) { - self._draft = ObservedObject(wrappedValue: draft) - } - - var body: some View { - HStack { - VStack(alignment: .leading) { - if draft.contentWarningEnabled { - Text(draft.contentWarning) - .font(.body.bold()) - .foregroundColor(.secondary) - } - - Text(draft.text) - .font(.body) - - HStack(spacing: 8) { - ForEach(draft.attachments) { attachment in - ComposeAttachmentImage(attachment: attachment, fullSize: false) - .frame(width: 50, height: 50) - .cornerRadius(5) - } - } - } - - Spacer() - - Text(draft.lastModified.timeAgoString()) - .font(.body) - .foregroundColor(.secondary) - } - } -} - -//struct DraftsView_Previews: PreviewProvider { -// static var previews: some View { -// DraftsView(currentDraft: Draft(accountID: "")) -// } -//} diff --git a/Tusker/Screens/Compose/MainComposeTextView.swift b/Tusker/Screens/Compose/MainComposeTextView.swift deleted file mode 100644 index f7d29904..00000000 --- a/Tusker/Screens/Compose/MainComposeTextView.swift +++ /dev/null @@ -1,443 +0,0 @@ -// -// MainComposeTextView.swift -// Tusker -// -// Created by Shadowfacts on 8/29/20. -// Copyright © 2020 Shadowfacts. All rights reserved. -// - -import SwiftUI -import Pachyderm - -struct MainComposeTextView: View, PlaceholderViewProvider { - @ObservedObject var draft: OldDraft - @State private var placeholder: PlaceholderView = Self.placeholderView() - - let minHeight: CGFloat = 150 - @State private var height: CGFloat? - @Binding var becomeFirstResponder: Bool - @State private var hasFirstAppeared = false - @ScaledMetric private var fontSize = 20 - @Environment(\.colorScheme) private var colorScheme - - var body: some View { - ZStack(alignment: .topLeading) { - colorScheme == .dark ? Color.appFill : Color(uiColor: .secondarySystemBackground) - - if draft.text.isEmpty { - placeholder - .font(.system(size: fontSize)) - .foregroundColor(.secondary) - .offset(x: 4, y: 8) - .accessibilityHidden(true) - } - - MainComposeWrappedTextView( - text: $draft.text, - visibility: draft.visibility, - becomeFirstResponder: $becomeFirstResponder - ) { (textView) in - self.height = max(textView.contentSize.height, minHeight) - } - } - .frame(height: height ?? minHeight) - .onAppear { - if !hasFirstAppeared { - hasFirstAppeared = true - becomeFirstResponder = true - } - } - } - - @ViewBuilder - static func placeholderView() -> some View { - let components = Calendar.current.dateComponents([.month, .day], from: Date()) - if components.month == 3 && components.day == 14, - Date().formatted(date: .numeric, time: .omitted).starts(with: "3") { - Text("Happy π day!") - } else if components.month == 4 && components.day == 1 { - Text("April Fool's!").rotationEffect(.radians(.pi), anchor: .center) - } else if components.month == 9 && components.day == 5 { - // https://weirder.earth/@noracodes/109276419847254552 - // https://retrocomputing.stackexchange.com/questions/14763/what-warning-was-given-on-attempting-to-post-to-usenet-circa-1990 - Text("This program posts news to thousands of machines throughout the entire populated world. Please be sure you know what you are doing.").italic() - } else if components.month == 9 && components.day == 21 { - Text("Do you remember?") - } else if components.month == 10 && components.day == 31 { - if .random() { - Text("Post something spooky!") - } else { - Text("Any questions?") - } - } else { - Text("What's on your mind?") - } - } -} - -// exists to provide access to the type alias since the @State property needs it to be explicit -private protocol PlaceholderViewProvider { - associatedtype PlaceholderView: View - @ViewBuilder - static func placeholderView() -> PlaceholderView -} - -struct MainComposeWrappedTextView: UIViewRepresentable { - typealias UIViewType = UITextView - - @Binding var text: String - let visibility: Pachyderm.Visibility - @Binding var becomeFirstResponder: Bool - var textDidChange: (UITextView) -> Void - - @EnvironmentObject var uiState: ComposeUIState - @EnvironmentObject var mastodonController: MastodonController - @ObservedObject var preferences = Preferences.shared - @Environment(\.isEnabled) var isEnabled: Bool - - func makeUIView(context: Context) -> UITextView { - let textView = WrappedTextView(uiState: uiState) - textView.delegate = context.coordinator - textView.isEditable = true - textView.backgroundColor = .clear - textView.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 20)) - textView.adjustsFontForContentSizeCategory = true - textView.textContainer.lineBreakMode = .byWordWrapping - context.coordinator.textView = textView - return textView - } - - func updateUIView(_ uiView: UITextView, context: Context) { - if context.coordinator.skipSettingTextOnNextUpdate { - context.coordinator.skipSettingTextOnNextUpdate = false - } else { - context.coordinator.skipNextAutocompleteUpdate = true - uiView.text = text - } - - uiView.isEditable = isEnabled - uiView.keyboardType = preferences.useTwitterKeyboard ? .twitter : .default - - context.coordinator.text = $text - context.coordinator.didChange = textDidChange - context.coordinator.uiState = uiState - - // wait until the next runloop iteration so that SwiftUI view updates have finished and - // the text view knows its new content size - DispatchQueue.main.async { - self.textDidChange(uiView) - - if becomeFirstResponder { - // calling becomeFirstResponder during the SwiftUI update causes a crash on iOS 13 - uiView.becomeFirstResponder() - // can't update @State vars during the SwiftUI update - becomeFirstResponder = false - } - } - } - - func makeCoordinator() -> Coordinator { - return Coordinator(text: $text, uiState: uiState, didChange: textDidChange) - } - - class WrappedTextView: UITextView { - private let formattingActions = [#selector(toggleBoldface(_:)), #selector(toggleItalics(_:))] - unowned var uiState: ComposeUIState - - init(uiState: ComposeUIState) { - self.uiState = uiState - super.init(frame: .zero, textContainer: nil) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { - if formattingActions.contains(action) { - return Preferences.shared.statusContentType != .plain - } - - return super.canPerformAction(action, withSender: sender) - } - - override func toggleBoldface(_ sender: Any?) { - (delegate as! Coordinator).applyFormat(.bold) - } - - override func toggleItalics(_ sender: Any?) { - (delegate as! Coordinator).applyFormat(.italics) - } - - override func validate(_ command: UICommand) { - super.validate(command) - - if formattingActions.contains(command.action), - Preferences.shared.statusContentType != .plain { - command.attributes.remove(.disabled) - } - } - - override func paste(_ sender: Any?) { - // we deliberately exclude the other CompositionAttachment readable type identifiers, because that's too overzealous with the conversion - // and things like URLs end up pasting as attachments - if UIPasteboard.general.contains(pasteboardTypes: UIImage.readableTypeIdentifiersForItemProvider) { - uiState.delegate?.paste(itemProviders: UIPasteboard.general.itemProviders) - } else { - super.paste(sender) - } - } - - } - - class Coordinator: NSObject, UITextViewDelegate, ComposeInput, ComposeTextViewCaretScrolling { - weak var textView: UITextView? - var text: Binding - var didChange: (UITextView) -> Void - // break retained cycle through ComposeUIState.currentInput - unowned var uiState: ComposeUIState - var caretScrollPositionAnimator: UIViewPropertyAnimator? - - var skipSettingTextOnNextUpdate = false - var skipNextAutocompleteUpdate = false - - var toolbarElements: [ComposeUIState.ToolbarElement] { - [.emojiPicker, .formattingButtons] - } - - init(text: Binding, uiState: ComposeUIState, didChange: @escaping (UITextView) -> Void) { - self.text = text - self.didChange = didChange - self.uiState = uiState - - super.init() - - NotificationCenter.default.addObserver(self, selector: #selector(keyboardDidShow), name: UIResponder.keyboardDidShowNotification, object: nil) - } - - @objc private func keyboardDidShow() { - guard let textView, - textView.isFirstResponder else { return } - ensureCursorVisible(textView: textView) - } - - func textViewDidChange(_ textView: UITextView) { - text.wrappedValue = textView.text - didChange(textView) - - ensureCursorVisible(textView: textView) - } - - func applyFormat(_ format: StatusFormat) { - guard let textView = textView, - textView.isFirstResponder, - let insertionResult = format.insertionResult else { - return - } - - let currentSelectedRange = textView.selectedRange - if currentSelectedRange.length == 0 { - textView.insertText(insertionResult.prefix + insertionResult.suffix) - textView.selectedRange = NSRange(location: currentSelectedRange.location + insertionResult.insertionPoint, length: 0) - } else { - let start = textView.text.index(textView.text.startIndex, offsetBy: currentSelectedRange.lowerBound) - let end = textView.text.index(textView.text.startIndex, offsetBy: currentSelectedRange.upperBound) - let selectedText = textView.text[start.. UIMenu? { - var actions = suggestedActions - if Preferences.shared.statusContentType != .plain, - let index = suggestedActions.firstIndex(where: { ($0 as? UIMenu)?.identifier.rawValue == "com.apple.menu.format" }) { - if range.length > 0 { - let formatMenu = suggestedActions[index] as! UIMenu - let newFormatMenu = formatMenu.replacingChildren(StatusFormat.allCases.map { fmt in - var image: UIImage? - if let imageName = fmt.imageName { - image = UIImage(systemName: imageName) - } - return UIAction(title: fmt.accessibilityLabel, image: image) { [weak self] _ in - self?.applyFormat(fmt) - } - }) - actions[index] = newFormatMenu - } else { - actions.remove(at: index) - } - } - if range.length == 0 { - actions.append(UIAction(title: "Insert Emoji", image: UIImage(systemName: "face.smiling"), handler: { [weak self] _ in - self?.uiState.shouldEmojiAutocompletionBeginExpanded = true - self?.beginAutocompletingEmoji() - })) - } - return UIMenu(children: actions) - } - - func beginAutocompletingEmoji() { - guard let textView = textView else { - return - } - var insertSpace = false - if let text = textView.text, - textView.selectedRange.upperBound > 0 { - let characterBeforeCursorIndex = text.utf16.index(before: text.utf16.index(text.startIndex, offsetBy: textView.selectedRange.upperBound)) - insertSpace = !text[characterBeforeCursorIndex].isWhitespace - } - textView.insertText((insertSpace ? " " : "") + ":") - } - - func autocomplete(with string: String) { - guard let textView = textView, - let text = textView.text, - let (lastWordStartIndex, _) = findAutocompleteLastWord() else { - return - } - - let distanceToEnd = text.utf16.count - textView.selectedRange.upperBound - - let characterBeforeCursorIndex = text.utf16.index(text.startIndex, offsetBy: textView.selectedRange.upperBound) - - let insertSpace: Bool - if distanceToEnd > 0 { - let charAfterCursor = text[characterBeforeCursorIndex] - insertSpace = charAfterCursor != " " && charAfterCursor != "\n" - } else { - insertSpace = true - } - let string = insertSpace ? string + " " : string - - textView.text.replaceSubrange(lastWordStartIndex.. text.startIndex { - // if the character before the "word" beginning is a valid part of a "word", - // we aren't able to autocomplete - let c = text[text.index(before: lastWordStartIndex)] - if isPermittedForAutocomplete(c) || triggerChars.contains(c) { - uiState.autocompleteState = nil - return - } - } - - let characterBeforeCursorIndex = text.utf16.index(text.startIndex, offsetBy: textView.selectedRange.upperBound) - - if lastWordStartIndex >= text.startIndex { - let lastWord = text[lastWordStartIndex.. Bool { - return (c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || (c >= "0" && c <= "9") || c == "_" - } - - private func findAutocompleteLastWord() -> (index: String.Index, foundFirstAtSign: Bool)? { - guard let textView = textView, - textView.isFirstResponder, - textView.selectedRange.length == 0, - textView.selectedRange.upperBound > 0, - let text = textView.text, - text.count > 0 else { - return nil - } - - let characterBeforeCursorIndex = text.utf16.index(text.startIndex, offsetBy: textView.selectedRange.upperBound) - - var lastWordStartIndex = text.index(before: characterBeforeCursorIndex) - var foundFirstAtSign = false - while true { - let c = text[lastWordStartIndex] - - if !isPermittedForAutocomplete(c) { - if foundFirstAtSign { - if c != "@" { - // move the index forward by 1, so that the first char of the substring is the 1st @ instead of whatever comes before it - lastWordStartIndex = text.index(after: lastWordStartIndex) - } - break - } else { - if c == "@" { - foundFirstAtSign = true - } else if c != "." { - // periods are allowed for domain names in mentions - break - } - } - } - - if lastWordStartIndex > text.startIndex { - lastWordStartIndex = text.index(before: lastWordStartIndex) - } else { - break - } - } - - return (lastWordStartIndex, foundFirstAtSign) - } - } -} diff --git a/Tusker/Screens/Customize Timelines/EditFilterView.swift b/Tusker/Screens/Customize Timelines/EditFilterView.swift index 26cdb64f..cd2e5820 100644 --- a/Tusker/Screens/Customize Timelines/EditFilterView.swift +++ b/Tusker/Screens/Customize Timelines/EditFilterView.swift @@ -215,6 +215,18 @@ private struct FilterContextToggleStyle: ToggleStyle { } } +private extension View { + @available(iOS, obsoleted: 16.0) + @ViewBuilder + func scrollDismissesKeyboardInteractivelyIfAvailable() -> some View { + if #available(iOS 16.0, *) { + self.scrollDismissesKeyboard(.interactively) + } else { + self + } + } +} + //struct EditFilterView_Previews: PreviewProvider { // static var previews: some View { // EditFilterView() diff --git a/Tusker/Screens/Main/MainTabBarViewController.swift b/Tusker/Screens/Main/MainTabBarViewController.swift index eefdfc3b..f989444e 100644 --- a/Tusker/Screens/Main/MainTabBarViewController.swift +++ b/Tusker/Screens/Main/MainTabBarViewController.swift @@ -224,7 +224,7 @@ extension MainTabBarViewController { case .notifications: return NotificationsPageViewController(mastodonController: mastodonController) case .compose: - return ComposeHostingController(mastodonController: mastodonController) + return NewComposeHostingController(draft: nil, mastodonController: mastodonController) case .explore: return ExploreViewController(mastodonController: mastodonController) case .myProfile: @@ -274,8 +274,9 @@ extension MainTabBarViewController: StateRestorableViewController { func stateRestorationActivity() -> NSUserActivity? { var activity: NSUserActivity? if let presentedNav = presentedViewController as? UINavigationController, - let compose = presentedNav.viewControllers.first as? ComposeHostingController { - activity = UserActivityManager.editDraftActivity(id: compose.draft.id, accountID: compose.draft.accountID) + let compose = presentedNav.viewControllers.first as? NewComposeHostingController { + let draft = compose.controller.draft + activity = UserActivityManager.editDraftActivity(id: draft.id, accountID: draft.accountID) } else if let vc = (selectedViewController as! UINavigationController).topViewController as? StateRestorableViewController { activity = vc.stateRestorationActivity() } diff --git a/Tusker/Screens/Mute/MuteAccountView.swift b/Tusker/Screens/Mute/MuteAccountView.swift index e6903b97..954f1711 100644 --- a/Tusker/Screens/Mute/MuteAccountView.swift +++ b/Tusker/Screens/Mute/MuteAccountView.swift @@ -57,9 +57,12 @@ struct MuteAccountView: View { Form { Section { HStack { - ComposeAvatarImageView(url: account.avatar) - .frame(width: 50, height: 50) - .cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 50) + AvatarImageView( + url: account.avatar, + size: 50, + style: Preferences.shared.avatarStyle == .circle ? .circle : .roundRect, + fetchAvatar: { await ImageCache.avatars.get($0).1 } + ) VStack(alignment: .leading) { AccountDisplayNameLabel(account: account, textStyle: .headline, emojiSize: 17) diff --git a/Tusker/Screens/Report/ReportView.swift b/Tusker/Screens/Report/ReportView.swift index 5b85a79c..14b5ede9 100644 --- a/Tusker/Screens/Report/ReportView.swift +++ b/Tusker/Screens/Report/ReportView.swift @@ -7,6 +7,7 @@ // import SwiftUI +import TuskerComponents struct ReportView: View { @@ -46,10 +47,13 @@ struct ReportView: View { Form { Section { HStack { - ComposeAvatarImageView(url: account.avatar) - .frame(width: 50, height: 50) - .cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 50) - + AvatarImageView( + url: account.avatar, + size: 50, + style: Preferences.shared.avatarStyle == .circle ? .circle : .roundRect, + fetchAvatar: { await ImageCache.avatars.get($0).1 } + ) + VStack(alignment: .leading) { AccountDisplayNameLabel(account: account, textStyle: .headline, emojiSize: 17) Text("@\(account.acct)") @@ -97,9 +101,19 @@ struct ReportView: View { .appGroupedListRowBackground() Section { - ComposeTextView(text: $report.comment, placeholder: Text("Add any additional comments")) - .backgroundColor(.clear) - .listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 8)) + ZStack(alignment: .topLeading) { + if report.comment.isEmpty { + Text("Add any additional comments") + .offset(x: 4, y: 8) + .foregroundColor(.secondary) + } + + TextEditor(text: $report.comment) + .background(.clear) + .frame(minHeight: 100) + } + .font(.body) + .listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 8)) } .appGroupedListRowBackground() From 30449a2875cb9deeaccf8058d96707420a675f01 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sun, 16 Apr 2023 13:47:48 -0400 Subject: [PATCH 11/11] Rename NewComposeHostingController to ComposeHostingController --- Tusker.xcodeproj/project.pbxproj | 8 ++++---- Tusker/Scenes/ComposeSceneDelegate.swift | 6 +++--- ...ntroller.swift => ComposeHostingController.swift} | 12 ++++++------ Tusker/Screens/Main/Duckable+Root.swift | 2 +- Tusker/Screens/Main/MainTabBarViewController.swift | 4 ++-- Tusker/TuskerNavigationDelegate.swift | 2 +- 6 files changed, 17 insertions(+), 17 deletions(-) rename Tusker/Screens/Compose/{NewComposeHostingController.swift => ComposeHostingController.swift} (95%) diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 7a33d5a4..22ddb86f 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -276,7 +276,7 @@ D6BC9DD7232D7811002CA326 /* TimelinesPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */; }; D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */; }; D6BD395929B64426005FFD2B /* ComposeUI in Frameworks */ = {isa = PBXBuildFile; productRef = D6BD395829B64426005FFD2B /* ComposeUI */; }; - D6BD395B29B64441005FFD2B /* NewComposeHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BD395A29B64441005FFD2B /* NewComposeHostingController.swift */; }; + D6BD395B29B64441005FFD2B /* ComposeHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BD395A29B64441005FFD2B /* ComposeHostingController.swift */; }; D6BEA245291A0EDE002F4D01 /* Duckable in Frameworks */ = {isa = PBXBuildFile; productRef = D6BEA244291A0EDE002F4D01 /* Duckable */; }; D6BEA247291A0F2D002F4D01 /* Duckable+Root.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BEA246291A0F2D002F4D01 /* Duckable+Root.swift */; }; D6BED174212667E900F02DA0 /* TimelineStatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */; }; @@ -672,7 +672,7 @@ D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinesPageViewController.swift; sourceTree = ""; }; D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsViewController.swift; sourceTree = ""; }; D6BD395729B6441F005FFD2B /* ComposeUI */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = ComposeUI; path = Packages/ComposeUI; sourceTree = ""; }; - D6BD395A29B64441005FFD2B /* NewComposeHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewComposeHostingController.swift; sourceTree = ""; }; + D6BD395A29B64441005FFD2B /* ComposeHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeHostingController.swift; sourceTree = ""; }; D6BD395C29B789D5005FFD2B /* TuskerComponents */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = TuskerComponents; path = Packages/TuskerComponents; sourceTree = ""; }; D6BEA243291A0C83002F4D01 /* Duckable */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Duckable; path = Packages/Duckable; sourceTree = ""; }; D6BEA246291A0F2D002F4D01 /* Duckable+Root.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Duckable+Root.swift"; sourceTree = ""; }; @@ -1089,7 +1089,7 @@ children = ( D68232F62464F4FD00325FB8 /* ComposeDrawingViewController.swift */, D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */, - D6BD395A29B64441005FFD2B /* NewComposeHostingController.swift */, + D6BD395A29B64441005FFD2B /* ComposeHostingController.swift */, ); path = Compose; sourceTree = ""; @@ -1923,7 +1923,7 @@ D6412B0324AFF6A600F5412E /* TabBarScrollableViewController.swift in Sources */, D6093FB725BE0CF3004811E6 /* TrendHistoryView.swift in Sources */, D6EBF01723C55E0D00AE061B /* UISceneSession+MastodonController.swift in Sources */, - D6BD395B29B64441005FFD2B /* NewComposeHostingController.swift in Sources */, + D6BD395B29B64441005FFD2B /* ComposeHostingController.swift in Sources */, D61F759229365C6C00C0B37F /* CollectionViewController.swift in Sources */, D6DD996B2998611A0015C962 /* SuggestedProfilesViewController.swift in Sources */, D6DD8FFD298495A8002AD3FD /* LogoutService.swift in Sources */, diff --git a/Tusker/Scenes/ComposeSceneDelegate.swift b/Tusker/Scenes/ComposeSceneDelegate.swift index 79c6fcae..339c6253 100644 --- a/Tusker/Scenes/ComposeSceneDelegate.swift +++ b/Tusker/Scenes/ComposeSceneDelegate.swift @@ -59,7 +59,7 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDeleg session.mastodonController = controller controller.initialize() - let composeVC = NewComposeHostingController(draft: draft, mastodonController: controller) + let composeVC = ComposeHostingController(draft: draft, mastodonController: controller) composeVC.delegate = self let nav = EnhancedNavigationViewController(rootViewController: composeVC) @@ -81,7 +81,7 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDeleg if let window = window, let nav = window.rootViewController as? UINavigationController, - let compose = nav.topViewController as? NewComposeHostingController { + let compose = nav.topViewController as? ComposeHostingController { scene.userActivity = UserActivityManager.editDraftActivity(id: compose.controller.draft.id, accountID: scene.session.mastodonController!.accountInfo!.id) } } @@ -109,7 +109,7 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDeleg } -extension ComposeSceneDelegate: NewComposeHostingControllerDelegate { +extension ComposeSceneDelegate: ComposeHostingControllerDelegate { func dismissCompose(mode: DismissMode) -> Bool { let animation: UIWindowScene.DismissalAnimation switch mode { diff --git a/Tusker/Screens/Compose/NewComposeHostingController.swift b/Tusker/Screens/Compose/ComposeHostingController.swift similarity index 95% rename from Tusker/Screens/Compose/NewComposeHostingController.swift rename to Tusker/Screens/Compose/ComposeHostingController.swift index 77e3441a..8ccfdc9c 100644 --- a/Tusker/Screens/Compose/NewComposeHostingController.swift +++ b/Tusker/Screens/Compose/ComposeHostingController.swift @@ -1,5 +1,5 @@ // -// NewComposeHostingController.swift +// ComposeHostingController.swift // Tusker // // Created by Shadowfacts on 3/6/23. @@ -15,13 +15,13 @@ import Pachyderm import CoreData import Duckable -protocol NewComposeHostingControllerDelegate: AnyObject { +protocol ComposeHostingControllerDelegate: AnyObject { func dismissCompose(mode: DismissMode) -> Bool } -class NewComposeHostingController: UIHostingController, DuckableViewController { +class ComposeHostingController: UIHostingController, DuckableViewController { - weak var delegate: NewComposeHostingControllerDelegate? + weak var delegate: ComposeHostingControllerDelegate? weak var duckableDelegate: DuckableViewControllerDelegate? let controller: ComposeController @@ -200,7 +200,7 @@ extension MastodonController: ComposeMastodonContext { } } -extension NewComposeHostingController: PHPickerViewControllerDelegate { +extension ComposeHostingController: PHPickerViewControllerDelegate { func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { dismiss(animated: true) @@ -209,7 +209,7 @@ extension NewComposeHostingController: PHPickerViewControllerDelegate { } } -extension NewComposeHostingController: ComposeDrawingViewControllerDelegate { +extension ComposeHostingController: ComposeDrawingViewControllerDelegate { func composeDrawingViewControllerClose(_ drawingController: ComposeDrawingViewController) { dismiss(animated: true) drawingCompletion = nil diff --git a/Tusker/Screens/Main/Duckable+Root.swift b/Tusker/Screens/Main/Duckable+Root.swift index a10e40bd..b349f104 100644 --- a/Tusker/Screens/Main/Duckable+Root.swift +++ b/Tusker/Screens/Main/Duckable+Root.swift @@ -14,7 +14,7 @@ import ComposeUI extension DuckableContainerViewController: TuskerRootViewController { func stateRestorationActivity() -> NSUserActivity? { var activity = (child as? TuskerRootViewController)?.stateRestorationActivity() - if let compose = duckedViewController as? NewComposeHostingController, + if let compose = duckedViewController as? ComposeHostingController, compose.controller.draft.hasContent { activity = UserActivityManager.addDuckedDraft(to: activity, draft: compose.controller.draft) } diff --git a/Tusker/Screens/Main/MainTabBarViewController.swift b/Tusker/Screens/Main/MainTabBarViewController.swift index f989444e..6fb73312 100644 --- a/Tusker/Screens/Main/MainTabBarViewController.swift +++ b/Tusker/Screens/Main/MainTabBarViewController.swift @@ -224,7 +224,7 @@ extension MainTabBarViewController { case .notifications: return NotificationsPageViewController(mastodonController: mastodonController) case .compose: - return NewComposeHostingController(draft: nil, mastodonController: mastodonController) + return ComposeHostingController(draft: nil, mastodonController: mastodonController) case .explore: return ExploreViewController(mastodonController: mastodonController) case .myProfile: @@ -274,7 +274,7 @@ extension MainTabBarViewController: StateRestorableViewController { func stateRestorationActivity() -> NSUserActivity? { var activity: NSUserActivity? if let presentedNav = presentedViewController as? UINavigationController, - let compose = presentedNav.viewControllers.first as? NewComposeHostingController { + let compose = presentedNav.viewControllers.first as? ComposeHostingController { let draft = compose.controller.draft activity = UserActivityManager.editDraftActivity(id: draft.id, accountID: draft.accountID) } else if let vc = (selectedViewController as! UINavigationController).topViewController as? StateRestorableViewController { diff --git a/Tusker/TuskerNavigationDelegate.swift b/Tusker/TuskerNavigationDelegate.swift index 29b57cde..f2f86b1c 100644 --- a/Tusker/TuskerNavigationDelegate.swift +++ b/Tusker/TuskerNavigationDelegate.swift @@ -98,7 +98,7 @@ extension TuskerNavigationDelegate { options.preferredPresentationStyle = .prominent UIApplication.shared.requestSceneSessionActivation(nil, userActivity: compose, options: options, errorHandler: nil) } else { - let compose = NewComposeHostingController(draft: draft, mastodonController: apiController) + let compose = ComposeHostingController(draft: draft, mastodonController: apiController) if #available(iOS 16.0, *), presentDuckable(compose, animated: animated, isDucked: isDucked) { return