diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ea588983c..b2979d002 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -19,8 +19,8 @@ jobs: steps: - name: checkout uses: actions/checkout@v2 - - name: force Xcode 12.5.1 - run: sudo xcode-select -switch /Applications/Xcode_12.5.1.app + - name: force Xcode 13.1 + run: sudo xcode-select -switch /Applications/Xcode_13.1.app - name: setup run: exec ./.github/scripts/setup.sh - name: build diff --git a/.gitignore b/.gitignore index 24e748a9e..a605c524d 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,7 @@ Packages/ .swiftpm .build/ +!**/swiftpm/Package.resolved # CocoaPods # We recommend against adding the Pods directory to your .gitignore. However @@ -121,5 +122,4 @@ xcuserdata Localization/StringsConvertor/input Localization/StringsConvertor/output -.DS_Store -/Mastodon.xcworkspace/xcshareddata/swiftpm +.DS_Store \ No newline at end of file diff --git a/AppShared/Info.plist b/AppShared/Info.plist index 889a6a010..9fe845c60 100644 --- a/AppShared/Info.plist +++ b/AppShared/Info.plist @@ -15,8 +15,8 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.0.8 + 1.2.0 CFBundleVersion - 60 + 88 diff --git a/AppShared/UserDefaults+Notification.swift b/AppShared/UserDefaults+Notification.swift new file mode 100644 index 000000000..e743e70a0 --- /dev/null +++ b/AppShared/UserDefaults+Notification.swift @@ -0,0 +1,40 @@ +// +// UserDefaults+Notification.swift +// AppShared +// +// Created by Cirno MainasuK on 2021-10-9. +// + +import UIKit +import CryptoKit + +extension UserDefaults { + // always use hash value (SHA256) from accessToken as key + private static func deriveKey(from accessToken: String, prefix: String) -> String { + let digest = SHA256.hash(data: Data(accessToken.utf8)) + let bytes = [UInt8](digest) + let hex = bytes.toHexString() + let key = prefix + "@" + hex + return key + } + + private static let notificationCountKeyPrefix = "notification_count" + + public func getNotificationCountWithAccessToken(accessToken: String) -> Int { + let prefix = UserDefaults.notificationCountKeyPrefix + let key = UserDefaults.deriveKey(from: accessToken, prefix: prefix) + return integer(forKey: key) + } + + public func setNotificationCountWithAccessToken(accessToken: String, value: Int) { + let prefix = UserDefaults.notificationCountKeyPrefix + let key = UserDefaults.deriveKey(from: accessToken, prefix: prefix) + setValue(value, forKey: key) + } + + public func increaseNotificationCount(accessToken: String) { + let count = getNotificationCountWithAccessToken(accessToken: accessToken) + setNotificationCountWithAccessToken(accessToken: accessToken, value: count + 1) + } + +} diff --git a/AppShared/UserDefaults.swift b/AppShared/UserDefaults.swift index 9cecdcf60..753a3284f 100644 --- a/AppShared/UserDefaults.swift +++ b/AppShared/UserDefaults.swift @@ -10,3 +10,4 @@ import UIKit extension UserDefaults { public static let shared = UserDefaults(suiteName: AppName.groupID)! } + diff --git a/CoreDataStack/CoreData.xcdatamodeld/.xccurrentversion b/CoreDataStack/CoreData.xcdatamodeld/.xccurrentversion new file mode 100644 index 000000000..3d5e5761c --- /dev/null +++ b/CoreDataStack/CoreData.xcdatamodeld/.xccurrentversion @@ -0,0 +1,8 @@ + + + + + _XCCurrentVersionName + CoreData 2.xcdatamodel + + diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData 2.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData 2.xcdatamodel/contents new file mode 100644 index 000000000..6d576ca15 --- /dev/null +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData 2.xcdatamodel/contents @@ -0,0 +1,306 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/CoreDataStack/CoreDataStack.swift b/CoreDataStack/CoreDataStack.swift index 2d6224e72..2dfa0c38c 100644 --- a/CoreDataStack/CoreDataStack.swift +++ b/CoreDataStack/CoreDataStack.swift @@ -13,6 +13,8 @@ import AppShared public final class CoreDataStack { + static let logger = Logger(subsystem: "CoreDataStack", category: "DB") + private(set) var storeDescriptions: [NSPersistentStoreDescription] public let didFinishLoad = CurrentValueSubject(false) @@ -90,8 +92,22 @@ public final class CoreDataStack { container.viewContext.automaticallyMergesChangesFromParent = true os_log("%{public}s[%{public}ld], %{public}s: %s", ((#file as NSString).lastPathComponent), #line, #function, storeDescription.debugDescription) - + callback() + + #if DEBUG + do { + let storeURL = URL.storeURL(for: AppName.groupID, databaseName: "shared") + let data = try Data(contentsOf: storeURL) + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useMB] + formatter.countStyle = .file + let size = formatter.string(fromByteCount: Int64(data.count)) + CoreDataStack.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): Database size: \(size)") + } catch { + CoreDataStack.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): Cannot get database size") + } + #endif }) } diff --git a/CoreDataStack/Entity/Instance.swift b/CoreDataStack/Entity/Instance.swift new file mode 100644 index 000000000..8976097ef --- /dev/null +++ b/CoreDataStack/Entity/Instance.swift @@ -0,0 +1,70 @@ +// +// Instance.swift +// CoreDataStack +// +// Created by Cirno MainasuK on 2021-10-9. +// + +import Foundation +import CoreData + +public final class Instance: NSManagedObject { + @NSManaged public var domain: String + + @NSManaged public private(set) var createdAt: Date + @NSManaged public private(set) var updatedAt: Date + + @NSManaged public private(set) var configurationRaw: Data? + + // MARK: one-to-many relationships + @NSManaged public var authentications: Set +} + +extension Instance { + public override func awakeFromInsert() { + super.awakeFromInsert() + let now = Date() + setPrimitiveValue(now, forKey: #keyPath(Instance.createdAt)) + setPrimitiveValue(now, forKey: #keyPath(Instance.updatedAt)) + } + + @discardableResult + public static func insert( + into context: NSManagedObjectContext, + property: Property + ) -> Instance { + let instance: Instance = context.insertObject() + instance.domain = property.domain + return instance + } + + public func update(configurationRaw: Data?) { + self.configurationRaw = configurationRaw + } + + public func didUpdate(at networkDate: Date) { + self.updatedAt = networkDate + } +} + +extension Instance { + public struct Property { + public let domain: String + + public init(domain: String) { + self.domain = domain + } + } +} + +extension Instance: Managed { + public static var defaultSortDescriptors: [NSSortDescriptor] { + return [NSSortDescriptor(keyPath: \Instance.createdAt, ascending: false)] + } +} + +extension Instance { + public static func predicate(domain: String) -> NSPredicate { + return NSPredicate(format: "%K == %@", #keyPath(Instance.domain), domain) + } +} diff --git a/CoreDataStack/Entity/MastodonAuthentication.swift b/CoreDataStack/Entity/MastodonAuthentication.swift index 0ee0e343b..7aafd65a4 100644 --- a/CoreDataStack/Entity/MastodonAuthentication.swift +++ b/CoreDataStack/Entity/MastodonAuthentication.swift @@ -30,6 +30,9 @@ final public class MastodonAuthentication: NSManagedObject { // one-to-one relationship @NSManaged public private(set) var user: MastodonUser + // many-to-one relationship + @NSManaged public private(set) var instance: Instance? + } extension MastodonAuthentication { @@ -97,6 +100,12 @@ extension MastodonAuthentication { } } + public func update(instance: Instance) { + if self.instance != instance { + self.instance = instance + } + } + public func didUpdate(at networkDate: Date) { self.updatedAt = networkDate } @@ -143,7 +152,7 @@ extension MastodonAuthentication: Managed { extension MastodonAuthentication { - static func predicate(domain: String) -> NSPredicate { + public static func predicate(domain: String) -> NSPredicate { return NSPredicate(format: "%K == %@", #keyPath(MastodonAuthentication.domain), domain) } @@ -158,4 +167,8 @@ extension MastodonAuthentication { ]) } + public static func predicate(userAccessToken: String) -> NSPredicate { + return NSPredicate(format: "%K == %@", #keyPath(MastodonAuthentication.userAccessToken), userAccessToken) + } + } diff --git a/CoreDataStack/Entity/MastodonUser.swift b/CoreDataStack/Entity/MastodonUser.swift index b7a101152..913aa1f16 100644 --- a/CoreDataStack/Entity/MastodonUser.swift +++ b/CoreDataStack/Entity/MastodonUser.swift @@ -43,11 +43,11 @@ final public class MastodonUser: NSManagedObject { // one-to-one relationship @NSManaged public private(set) var pinnedStatus: Status? @NSManaged public private(set) var mastodonAuthentication: MastodonAuthentication? - @NSManaged public private(set) var searchHistory: SearchHistory? // one-to-many relationship @NSManaged public private(set) var statuses: Set? @NSManaged public private(set) var notifications: Set? + @NSManaged public private(set) var searchHistories: Set // many-to-many relationship @NSManaged public private(set) var favourite: Set? @@ -274,6 +274,15 @@ extension MastodonUser { } +extension MastodonUser { + public func findSearchHistory(domain: String, userID: MastodonUser.ID) -> SearchHistory? { + return searchHistories.first { searchHistory in + return searchHistory.domain == domain + && searchHistory.userID == userID + } + } +} + extension MastodonUser { public struct Property { public let identifier: String diff --git a/CoreDataStack/Entity/SearchHistory.swift b/CoreDataStack/Entity/SearchHistory.swift index 3894d1c1b..05e441906 100644 --- a/CoreDataStack/Entity/SearchHistory.swift +++ b/CoreDataStack/Entity/SearchHistory.swift @@ -16,7 +16,7 @@ public final class SearchHistory: NSManagedObject { @NSManaged public private(set) var createAt: Date @NSManaged public private(set) var updatedAt: Date - // one-to-one relationship + // many-to-one relationship @NSManaged public private(set) var account: MastodonUser? @NSManaged public private(set) var hashtag: Tag? @NSManaged public private(set) var status: Status? @@ -31,10 +31,10 @@ extension SearchHistory { setPrimitiveValue(Date(), forKey: #keyPath(SearchHistory.updatedAt)) } - public override func willSave() { - super.willSave() - setPrimitiveValue(Date(), forKey: #keyPath(SearchHistory.updatedAt)) - } +// public override func willSave() { +// super.willSave() +// setPrimitiveValue(Date(), forKey: #keyPath(SearchHistory.updatedAt)) +// } @discardableResult public static func insert( diff --git a/CoreDataStack/Entity/Setting.swift b/CoreDataStack/Entity/Setting.swift index 27971157f..94ee50959 100644 --- a/CoreDataStack/Entity/Setting.swift +++ b/CoreDataStack/Entity/Setting.swift @@ -13,7 +13,7 @@ public final class Setting: NSManagedObject { @NSManaged public var domain: String @NSManaged public var userID: String - @NSManaged public var appearanceRaw: String +// @NSManaged public var appearanceRaw: String @NSManaged public var preferredTrueBlackDarkMode: Bool @NSManaged public var preferredStaticAvatar: Bool @NSManaged public var preferredStaticEmoji: Bool @@ -41,17 +41,17 @@ extension Setting { property: Property ) -> Setting { let setting: Setting = context.insertObject() - setting.appearanceRaw = property.appearanceRaw +// setting.appearanceRaw = property.appearanceRaw setting.domain = property.domain setting.userID = property.userID return setting } - public func update(appearanceRaw: String) { - guard appearanceRaw != self.appearanceRaw else { return } - self.appearanceRaw = appearanceRaw - didUpdate(at: Date()) - } +// public func update(appearanceRaw: String) { +// guard appearanceRaw != self.appearanceRaw else { return } +// self.appearanceRaw = appearanceRaw +// didUpdate(at: Date()) +// } public func update(preferredTrueBlackDarkMode: Bool) { guard preferredTrueBlackDarkMode != self.preferredTrueBlackDarkMode else { return } @@ -87,12 +87,16 @@ extension Setting { public struct Property { public let domain: String public let userID: String - public let appearanceRaw: String +// public let appearanceRaw: String - public init(domain: String, userID: String, appearanceRaw: String) { + public init( + domain: String, + userID: String +// appearanceRaw: String + ) { self.domain = domain self.userID = userID - self.appearanceRaw = appearanceRaw +// self.appearanceRaw = appearanceRaw } } } diff --git a/CoreDataStack/Entity/Status.swift b/CoreDataStack/Entity/Status.swift index 717e54ab7..ee168e418 100644 --- a/CoreDataStack/Entity/Status.swift +++ b/CoreDataStack/Entity/Status.swift @@ -52,18 +52,18 @@ public final class Status: NSManagedObject { // one-to-one relationship @NSManaged public private(set) var pinnedBy: MastodonUser? @NSManaged public private(set) var poll: Poll? - @NSManaged public private(set) var searchHistory: SearchHistory? // one-to-many relationship @NSManaged public private(set) var reblogFrom: Set? @NSManaged public private(set) var mentions: Set? - @NSManaged public private(set) var tags: Set? @NSManaged public private(set) var homeTimelineIndexes: Set? @NSManaged public private(set) var mediaAttachments: Set? @NSManaged public private(set) var replyFrom: Set? @NSManaged public private(set) var inNotifications: Set? + @NSManaged public private(set) var searchHistories: Set + @NSManaged public private(set) var updatedAt: Date @NSManaged public private(set) var deletedAt: Date? @NSManaged public private(set) var revealedAt: Date? @@ -81,7 +81,6 @@ extension Status { replyTo: Status?, poll: Poll?, mentions: [Mention]?, - tags: [Tag]?, mediaAttachments: [Attachment]?, favouritedBy: MastodonUser?, rebloggedBy: MastodonUser?, @@ -126,9 +125,6 @@ extension Status { if let mentions = mentions { status.mutableSetValue(forKey: #keyPath(Status.mentions)).addObjects(from: mentions) } - if let tags = tags { - status.mutableSetValue(forKey: #keyPath(Status.tags)).addObjects(from: tags) - } if let mediaAttachments = mediaAttachments { status.mutableSetValue(forKey: #keyPath(Status.mediaAttachments)).addObjects(from: mediaAttachments) } diff --git a/CoreDataStack/Entity/Tag.swift b/CoreDataStack/Entity/Tag.swift index 6aeee520e..fa9e098de 100644 --- a/CoreDataStack/Entity/Tag.swift +++ b/CoreDataStack/Entity/Tag.swift @@ -18,13 +18,12 @@ public final class Tag: NSManagedObject { @NSManaged public private(set) var url: String // one-to-one relationship - @NSManaged public private(set) var searchHistory: SearchHistory? // many-to-many relationship - @NSManaged public private(set) var statuses: Set? // one-to-many relationship @NSManaged public private(set) var histories: Set? + @NSManaged public private(set) var searchHistories: Set } public extension Tag { @@ -55,6 +54,15 @@ public extension Tag { } } +extension Tag { + public func findSearchHistory(domain: String, userID: MastodonUser.ID) -> SearchHistory? { + return searchHistories.first { searchHistory in + return searchHistory.domain == domain + && searchHistory.userID == userID + } + } +} + public extension Tag { struct Property { public let name: String diff --git a/CoreDataStack/Info.plist b/CoreDataStack/Info.plist index 889a6a010..9fe845c60 100644 --- a/CoreDataStack/Info.plist +++ b/CoreDataStack/Info.plist @@ -15,8 +15,8 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.0.8 + 1.2.0 CFBundleVersion - 60 + 88 diff --git a/CoreDataStackTests/Info.plist b/CoreDataStackTests/Info.plist index 889a6a010..9fe845c60 100644 --- a/CoreDataStackTests/Info.plist +++ b/CoreDataStackTests/Info.plist @@ -15,8 +15,8 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.0.8 + 1.2.0 CFBundleVersion - 60 + 88 diff --git a/Localization/Localizable.stringsdict b/Localization/Localizable.stringsdict index 44fb608bc..ce358b439 100644 --- a/Localization/Localizable.stringsdict +++ b/Localization/Localizable.stringsdict @@ -2,6 +2,28 @@ + a11y.plural.count.unread.notification + + NSStringLocalizedFormatKey + %#@notification_count_unread_notification@ + notification_count_unread_notification + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + zero + no unread notification + one + 1 unread notification + few + %ld unread notifications + many + %ld unread notification + other + %ld unread notification + + a11y.plural.count.input_limit_exceeds NSStringLocalizedFormatKey diff --git a/Localization/StringsConvertor/Intents/input/ar_SA/Intents.strings b/Localization/StringsConvertor/Intents/input/ar_SA/Intents.strings index 6877490ba..cde27dc97 100644 --- a/Localization/StringsConvertor/Intents/input/ar_SA/Intents.strings +++ b/Localization/StringsConvertor/Intents/input/ar_SA/Intents.strings @@ -1,51 +1,51 @@ -"16wxgf" = "Post on Mastodon"; +"16wxgf" = "النَشر على ماستودون"; -"751xkl" = "Text Content"; +"751xkl" = "محتوى نصي"; -"CsR7G2" = "Post on Mastodon"; +"CsR7G2" = "انشر على ماستدون"; -"HZSGTr" = "What content to post?"; +"HZSGTr" = "ما المُحتوى المُراد نشره؟"; -"HdGikU" = "Posting failed"; +"HdGikU" = "فَشَلَ النشر"; -"KDNTJ4" = "Failure Reason"; +"KDNTJ4" = "سبب الإخفاق"; -"RHxKOw" = "Send Post with text content"; +"RHxKOw" = "إرسال مَنشور يَحوي نص"; -"RxSqsb" = "Post"; +"RxSqsb" = "مَنشور"; -"WCIR3D" = "Post ${content} on Mastodon"; +"WCIR3D" = "نَشر ${content} على ماستودون"; -"ZKJSNu" = "Post"; +"ZKJSNu" = "مَنشور"; "ZS1XaK" = "${content}"; -"ZbSjzC" = "Visibility"; +"ZbSjzC" = "مدى الظهور"; -"Zo4jgJ" = "Post Visibility"; +"Zo4jgJ" = "مدى ظهور المنشور"; -"apSxMG-dYQ5NN" = "There are ${count} options matching ‘Public’."; +"apSxMG-dYQ5NN" = "هُناك عدد ${count} خِيار مُطابق لِـ\"عام\"."; -"apSxMG-ehFLjY" = "There are ${count} options matching ‘Followers Only’."; +"apSxMG-ehFLjY" = "هُناك عدد ${count} خِيار مُطابق لِـ\"المُتابِعُون فقط\"."; -"ayoYEb-dYQ5NN" = "${content}, Public"; +"ayoYEb-dYQ5NN" = "${content}، عام"; -"ayoYEb-ehFLjY" = "${content}, Followers Only"; +"ayoYEb-ehFLjY" = "${content}، المُتابِعُون فقط"; -"dUyuGg" = "Post on Mastodon"; +"dUyuGg" = "النشر على ماستدون"; -"dYQ5NN" = "Public"; +"dYQ5NN" = "للعامة"; -"ehFLjY" = "Followers Only"; +"ehFLjY" = "لمتابعيك فقط"; -"gfePDu" = "Posting failed. ${failureReason}"; +"gfePDu" = "فَشَلَ النشر، ${failureReason}"; -"k7dbKQ" = "Post was sent successfully."; +"k7dbKQ" = "تمَّ إرسال المنشور بِنجاح."; -"oGiqmY-dYQ5NN" = "Just to confirm, you wanted ‘Public’?"; +"oGiqmY-dYQ5NN" = "للتأكيد، هل تَريد \"عام\"؟"; -"oGiqmY-ehFLjY" = "Just to confirm, you wanted ‘Followers Only’?"; +"oGiqmY-ehFLjY" = "للتأكيد، هل تُريد \"للمُتابِعين فقط\"؟"; -"rM6dvp" = "URL"; +"rM6dvp" = "عنوان URL"; -"ryJLwG" = "Post was sent successfully. "; +"ryJLwG" = "تم إرسال المنشور بنجاح. "; diff --git a/Localization/StringsConvertor/Intents/input/ar_SA/Intents.stringsdict b/Localization/StringsConvertor/Intents/input/ar_SA/Intents.stringsdict index f273a551d..e44e666ae 100644 --- a/Localization/StringsConvertor/Intents/input/ar_SA/Intents.stringsdict +++ b/Localization/StringsConvertor/Intents/input/ar_SA/Intents.stringsdict @@ -5,7 +5,7 @@ There are ${count} options matching ‘${content}’. - 2 NSStringLocalizedFormatKey - There are %#@count_option@ matching ‘${content}’. + هُناك %#@count_option@ تتطابق مَعَ '${content}'. count_option NSStringFormatSpecTypeKey @@ -13,23 +13,23 @@ NSStringFormatValueTypeKey %ld zero - %ld options + لا خيار one - 1 option + خيار واحد two - %ld options + خياران few - %ld options + %ld خيارات many - %ld options + %ld خيارًا other - %ld options + %ld خيار There are ${count} options matching ‘${visibility}’. NSStringLocalizedFormatKey - There are %#@count_option@ matching ‘${visibility}’. + هُناك %#@count_option@ تتطابق مَعَ '${visibility}'. count_option NSStringFormatSpecTypeKey @@ -37,17 +37,17 @@ NSStringFormatValueTypeKey %ld zero - %ld options + لا خيار one - 1 option + خيار واحد two - %ld options + خياران few - %ld options + %ld خيارات many - %ld options + %ld خيارًا other - %ld options + %ld خيار diff --git a/Localization/StringsConvertor/Intents/input/fr_FR/Intents.strings b/Localization/StringsConvertor/Intents/input/fr_FR/Intents.strings index 6877490ba..f4fec3000 100644 --- a/Localization/StringsConvertor/Intents/input/fr_FR/Intents.strings +++ b/Localization/StringsConvertor/Intents/input/fr_FR/Intents.strings @@ -1,28 +1,28 @@ -"16wxgf" = "Post on Mastodon"; +"16wxgf" = "Publier sur Mastodon"; -"751xkl" = "Text Content"; +"751xkl" = "Contenu textuel"; -"CsR7G2" = "Post on Mastodon"; +"CsR7G2" = "Publier sur Mastodon"; -"HZSGTr" = "What content to post?"; +"HZSGTr" = "Quel contenu à publier ?"; -"HdGikU" = "Posting failed"; +"HdGikU" = "Échec lors de la publication"; -"KDNTJ4" = "Failure Reason"; +"KDNTJ4" = "Raison de l’échec"; -"RHxKOw" = "Send Post with text content"; +"RHxKOw" = "Envoyer une publication avec du contenu texte"; "RxSqsb" = "Post"; -"WCIR3D" = "Post ${content} on Mastodon"; +"WCIR3D" = "Publier du ${content} sur Mastodon"; -"ZKJSNu" = "Post"; +"ZKJSNu" = "Publication"; "ZS1XaK" = "${content}"; -"ZbSjzC" = "Visibility"; +"ZbSjzC" = "Visibilité"; -"Zo4jgJ" = "Post Visibility"; +"Zo4jgJ" = "Visibilité de la publication"; "apSxMG-dYQ5NN" = "There are ${count} options matching ‘Public’."; @@ -30,22 +30,22 @@ "ayoYEb-dYQ5NN" = "${content}, Public"; -"ayoYEb-ehFLjY" = "${content}, Followers Only"; +"ayoYEb-ehFLjY" = "${content}, abonné·e·s seulement"; -"dUyuGg" = "Post on Mastodon"; +"dUyuGg" = "Publier sur Mastodon"; "dYQ5NN" = "Public"; -"ehFLjY" = "Followers Only"; +"ehFLjY" = "Abonné·e·s seulement"; -"gfePDu" = "Posting failed. ${failureReason}"; +"gfePDu" = "Échec lors de la publication. ${failureReason}"; -"k7dbKQ" = "Post was sent successfully."; +"k7dbKQ" = "Message publié avec succès."; -"oGiqmY-dYQ5NN" = "Just to confirm, you wanted ‘Public’?"; +"oGiqmY-dYQ5NN" = "Juste pour confirmer, vous vouliez « Public » ?"; -"oGiqmY-ehFLjY" = "Just to confirm, you wanted ‘Followers Only’?"; +"oGiqmY-ehFLjY" = "Juste pour confirmer, vous vouliez bien diffuser vers « abonné·e·s uniquement » ?"; "rM6dvp" = "URL"; -"ryJLwG" = "Post was sent successfully. "; +"ryJLwG" = "La publication a été envoyée avec succès. "; diff --git a/Localization/StringsConvertor/Intents/input/fr_FR/Intents.stringsdict b/Localization/StringsConvertor/Intents/input/fr_FR/Intents.stringsdict index 18422c772..53d39da2e 100644 --- a/Localization/StringsConvertor/Intents/input/fr_FR/Intents.stringsdict +++ b/Localization/StringsConvertor/Intents/input/fr_FR/Intents.stringsdict @@ -5,7 +5,7 @@ There are ${count} options matching ‘${content}’. - 2 NSStringLocalizedFormatKey - There are %#@count_option@ matching ‘${content}’. + Il y a %#@count_option@ correspondant à « ${content} ». count_option NSStringFormatSpecTypeKey @@ -21,7 +21,7 @@ There are ${count} options matching ‘${visibility}’. NSStringLocalizedFormatKey - There are %#@count_option@ matching ‘${visibility}’. + Il y a %#@count_option@ correspondant à « ${visibility} ». count_option NSStringFormatSpecTypeKey diff --git a/Localization/StringsConvertor/Intents/input/gd_GB/Intents.strings b/Localization/StringsConvertor/Intents/input/gd_GB/Intents.strings index 0f8ef5edc..526defecd 100644 --- a/Localization/StringsConvertor/Intents/input/gd_GB/Intents.strings +++ b/Localization/StringsConvertor/Intents/input/gd_GB/Intents.strings @@ -24,9 +24,9 @@ "Zo4jgJ" = "Faicsinneachd a’ phuist"; -"apSxMG-dYQ5NN" = "There are ${count} options matching ‘Public’."; +"apSxMG-dYQ5NN" = "Tha ${count} roghainn(ean) dha “Poblach” ann."; -"apSxMG-ehFLjY" = "There are ${count} options matching ‘Followers Only’."; +"apSxMG-ehFLjY" = "Tha ${count} roghainn(ean) dha “Luchd-leantainn a-mhàin” ann."; "ayoYEb-dYQ5NN" = "${content}, poblach"; diff --git a/Localization/StringsConvertor/Intents/input/gd_GB/Intents.stringsdict b/Localization/StringsConvertor/Intents/input/gd_GB/Intents.stringsdict index 9a4c7df17..4b93628d7 100644 --- a/Localization/StringsConvertor/Intents/input/gd_GB/Intents.stringsdict +++ b/Localization/StringsConvertor/Intents/input/gd_GB/Intents.stringsdict @@ -13,11 +13,11 @@ NSStringFormatValueTypeKey %ld one - 1 option + %ld roghainn two - %ld options + %ld roghainn few - %ld options + %ld roghainnean other %ld roghainn @@ -33,11 +33,11 @@ NSStringFormatValueTypeKey %ld one - 1 option + %ld roghainn two - %ld options + %ld roghainn few - %ld options + %ld roghainnean other %ld roghainn diff --git a/Localization/StringsConvertor/Intents/input/kmr_TR/Intents.strings b/Localization/StringsConvertor/Intents/input/kmr_TR/Intents.strings new file mode 100644 index 000000000..13a86e0c0 --- /dev/null +++ b/Localization/StringsConvertor/Intents/input/kmr_TR/Intents.strings @@ -0,0 +1,51 @@ +"16wxgf" = "Di Mastodon de biweşîne"; + +"751xkl" = "Naveroka nivîsê"; + +"CsR7G2" = "Di Mastodon de biweşîne"; + +"HZSGTr" = "Kîjan naverok bila bê şandin?"; + +"HdGikU" = "Şandin têkçû"; + +"KDNTJ4" = "Sedema têkçûnê"; + +"RHxKOw" = "Bi naveroka nivîsî şandiyan bişîne"; + +"RxSqsb" = "Şandî"; + +"WCIR3D" = "${content} biweşîne di Mastodon de"; + +"ZKJSNu" = "Şandî"; + +"ZS1XaK" = "${content}"; + +"ZbSjzC" = "Xuyanî"; + +"Zo4jgJ" = "Xuyaniya şandiyê"; + +"apSxMG-dYQ5NN" = "Vebijarkên ${count} hene ku li gorî 'Gelemperî' ne."; + +"apSxMG-ehFLjY" = "Vebijarkên ${count} hene ku li gorî 'Tenê Şopaneran' hene."; + +"ayoYEb-dYQ5NN" = "${content}, Gelemperî"; + +"ayoYEb-ehFLjY" = "${content}, Tenê şopînêr"; + +"dUyuGg" = "Di Mastodon de biweşîne"; + +"dYQ5NN" = "Gelemperî"; + +"ehFLjY" = "Tenê şopîneran"; + +"gfePDu" = "Weşandin bi ser neket. ${failureReason}"; + +"k7dbKQ" = "Şandî bi serkeftî hate şandin."; + +"oGiqmY-dYQ5NN" = "Tenê ji bo pejirandinê, te 'Gelemperî' dixwest?"; + +"oGiqmY-ehFLjY" = "Tenê ji bo pejirandinê, te 'Tenê Şopîner' dixwest?"; + +"rM6dvp" = "Girêdan"; + +"ryJLwG" = "Şandî bi serkeftî hate şandin. "; diff --git a/Localization/StringsConvertor/Intents/input/kmr_TR/Intents.stringsdict b/Localization/StringsConvertor/Intents/input/kmr_TR/Intents.stringsdict new file mode 100644 index 000000000..2f001aaa9 --- /dev/null +++ b/Localization/StringsConvertor/Intents/input/kmr_TR/Intents.stringsdict @@ -0,0 +1,38 @@ + + + + + There are ${count} options matching ‘${content}’. - 2 + + NSStringLocalizedFormatKey + %#@count_option@ heye ku bi ‘${content}’ re têkildar e. + count_option + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + %ld + one + 1 vebijêrk + other + %ld vebijêrk + + + There are ${count} options matching ‘${visibility}’. + + NSStringLocalizedFormatKey + %#@count_option@ heye ku bi ‘${visibility}’ re têkildar e. + count_option + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + %ld + one + 1 vebijêrk + other + %ld vebijêrk + + + + diff --git a/Localization/StringsConvertor/Intents/input/nl_NL/Intents.stringsdict b/Localization/StringsConvertor/Intents/input/nl_NL/Intents.stringsdict index 18422c772..9043720db 100644 --- a/Localization/StringsConvertor/Intents/input/nl_NL/Intents.stringsdict +++ b/Localization/StringsConvertor/Intents/input/nl_NL/Intents.stringsdict @@ -5,7 +5,7 @@ There are ${count} options matching ‘${content}’. - 2 NSStringLocalizedFormatKey - There are %#@count_option@ matching ‘${content}’. + Er zijn %#@count_option@ die overeenkomen met ‘${content}’. count_option NSStringFormatSpecTypeKey @@ -13,7 +13,7 @@ NSStringFormatValueTypeKey %ld one - 1 option + 1 optie other %ld options @@ -21,7 +21,7 @@ There are ${count} options matching ‘${visibility}’. NSStringLocalizedFormatKey - There are %#@count_option@ matching ‘${visibility}’. + Er zijn %#@count_option@ die overeenkomen met ‘${visibility}’. count_option NSStringFormatSpecTypeKey @@ -29,7 +29,7 @@ NSStringFormatValueTypeKey %ld one - 1 option + 1 optie other %ld options diff --git a/Localization/StringsConvertor/Sources/StringsConvertor/main.swift b/Localization/StringsConvertor/Sources/StringsConvertor/main.swift index 124612e5c..6507986be 100644 --- a/Localization/StringsConvertor/Sources/StringsConvertor/main.swift +++ b/Localization/StringsConvertor/Sources/StringsConvertor/main.swift @@ -51,6 +51,7 @@ private func map(language: String) -> String? { case "fr_FR": return "fr" // French case "de_DE": return "de" // German case "ja_JP": return "ja" // Japanese + case "kmr_TR": return "ku-TR" // Kurmanji (Kurdish) case "ru_RU": return "ru" // Russian case "gd_GB": return "gd-GB" // Scottish Gaelic case "es_ES": return "es" // Spanish diff --git a/Localization/StringsConvertor/input/ar_SA/Localizable.stringsdict b/Localization/StringsConvertor/input/ar_SA/Localizable.stringsdict index 537064efb..0b28c577a 100644 --- a/Localization/StringsConvertor/input/ar_SA/Localizable.stringsdict +++ b/Localization/StringsConvertor/input/ar_SA/Localizable.stringsdict @@ -2,10 +2,34 @@ + a11y.plural.count.unread.notification + + NSStringLocalizedFormatKey + %#@notification_count_unread_notification@ + notification_count_unread_notification + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + zero + لا إشعار غير مقروء + one + إشعار واحِد غير مقروء + two + إشعاران غير مقروءان + few + %ld إشعارات غير مقروءة + many + %ld إشعارًا غيرَ مقروء + other + %ld إشعار غير مقروء + + a11y.plural.count.input_limit_exceeds NSStringLocalizedFormatKey - Input limit exceeds %#@character_count@ + تمَّ تجاوز حدّ الإدخال %#@character_count@ character_count NSStringFormatSpecTypeKey @@ -13,23 +37,23 @@ NSStringFormatValueTypeKey ld zero - %ld characters + لا حرف one - 1 character + حرفٌ واحِد two - %ld characters + حرفان اثنان few - %ld characters + %ld حُرُوف many - %ld characters + %ld حرفًا other - %ld characters + %ld حَرف a11y.plural.count.input_limit_remains NSStringLocalizedFormatKey - Input limit remains %#@character_count@ + يتبقَّى على حدّ الإدخال %#@character_count@ character_count NSStringFormatSpecTypeKey @@ -37,17 +61,17 @@ NSStringFormatValueTypeKey ld zero - %ld characters + لا حرف one - 1 character + حرفٌ واحِد two - %ld characters + حرفان اثنان few - %ld characters + %ld حُرُوف many - %ld characters + %ld حرفًا other - %ld characters + %ld حَرف plural.count.metric_formatted.post @@ -61,17 +85,17 @@ NSStringFormatValueTypeKey ld zero - posts + لا منشور one - post + منشور two - posts + منشوران few - posts + منشورات many - posts + منشورًا other - posts + منشور plural.count.post @@ -85,17 +109,17 @@ NSStringFormatValueTypeKey ld zero - %ld posts + لا منشور one - 1 post + منشورٌ واحِد two - %ld posts + منشورانِ اثنان few - %ld posts + %ld منشورات many - %ld posts + %ld منشورًا other - %ld posts + %ld منشور plural.count.favorite @@ -109,17 +133,17 @@ NSStringFormatValueTypeKey ld zero - %ld favorites + لا إعجاب one - 1 favorite + إعجابٌ واحِد two - %ld favorites + إعجابانِ اثنان few - %ld favorites + %ld إعجابات many - %ld favorites + %ld إعجابًا other - %ld favorites + %ld إعجاب plural.count.reblog @@ -133,17 +157,17 @@ NSStringFormatValueTypeKey ld zero - %ld reblogs + لا إعاد تدوين one - 1 reblog + إعادةُ تدوينٍ واحِدة two - %ld reblogs + إعادتا تدوين few - %ld reblogs + %ld إعاداتِ تدوين many - %ld reblogs + %ld إعادةٍ للتدوين other - %ld reblogs + %ld إعادة تدوين plural.count.vote @@ -157,17 +181,17 @@ NSStringFormatValueTypeKey ld zero - %ld votes + لا صوت one - 1 vote + صوتٌ واحِد two - %ld votes + صوتانِ اثنان few - %ld votes + %ld أصوات many - %ld votes + %ld صوتًا other - %ld votes + %ld صوت plural.count.voter @@ -181,17 +205,17 @@ NSStringFormatValueTypeKey ld zero - %ld voters + لا مُصوِّتون one - 1 voter + مُصوِّتٌ واحِد two - %ld voters + مُصوِّتانِ اثنان few - %ld voters + %ld مُصوِّتين many - %ld voters + %ld مُصوِّتًا other - %ld voters + %ld مُصوِّت plural.people_talking @@ -205,17 +229,17 @@ NSStringFormatValueTypeKey ld zero - %ld people talking + لا أحَدَ يتحدَّث one - 1 people talking + شخصٌ واحدٌ يتحدَّث two - %ld people talking + شخصانِ اثنان يتحدَّثا few - %ld people talking + %ld أشخاصٍ يتحدَّثون many - %ld people talking + %ld شخصًا يتحدَّثون other - %ld people talking + %ld شخصٍ يتحدَّثون plural.count.following @@ -229,17 +253,17 @@ NSStringFormatValueTypeKey ld zero - %ld following + لا مُتابَع one - 1 following + مُتابَعٌ واحد two - %ld following + مُتابَعانِ few - %ld following + %ld مُتابَعين many - %ld following + %ld مُتابَعًا other - %ld following + %ld مُتابَع plural.count.follower @@ -253,17 +277,17 @@ NSStringFormatValueTypeKey ld zero - %ld followers + لا مُتابِع one - 1 follower + مُتابِعٌ واحد two - %ld followers + مُتابِعانِ اثنان few - %ld followers + %ld مُتابِعين many - %ld followers + %ld مُتابِعًا other - %ld followers + %ld مُتابِع date.year.left @@ -277,17 +301,17 @@ NSStringFormatValueTypeKey ld zero - %ld years left + تتبقى لَحظة one - 1 year left + تتبقى سنة two - %ld years left + تتبقى سنتين few - %ld years left + تتبقى %ld سنوات many - %ld years left + تتبقى %ld سنةً other - %ld years left + تتبقى %ld سنة date.month.left @@ -301,17 +325,17 @@ NSStringFormatValueTypeKey ld zero - %ld months left + تتبقى لَحظة one - 1 months left + يتبقى شهر two - %ld months left + يتبقى شهرين few - %ld months left + يتبقى %ld أشهر many - %ld months left + يتبقى %ld شهرًا other - %ld months left + يتبقى %ld شهر date.day.left @@ -325,17 +349,17 @@ NSStringFormatValueTypeKey ld zero - %ld days left + تتبقى لحظة one - 1 day left + يتبقى يوم two - %ld days left + يتبقى يومين few - %ld days left + يتبقى %ld أيام many - %ld days left + يتبقى %ld يومًا other - %ld days left + يتبقى %ld يوم date.hour.left @@ -349,17 +373,17 @@ NSStringFormatValueTypeKey ld zero - %ld hours left + تتبقى لَحظة one - 1 hour left + تتبقى ساعة two - %ld hours left + تتبقى ساعتين few - %ld hours left + تتبقى %ld ساعات many - %ld hours left + تتبقى %ld ساعةً other - %ld hours left + تتبقى %ld ساعة date.minute.left @@ -373,17 +397,17 @@ NSStringFormatValueTypeKey ld zero - %ld minutes left + تتبقى لَحظة one - 1 minute left + تتبقى دقيقة two - %ld minutes left + تتبقى دقيقتين few - %ld minutes left + تتبقى %ld دقائق many - %ld minutes left + تتبقى %ld دقيقةً other - %ld minutes left + تتبقى %ld دقيقة date.second.left @@ -397,17 +421,17 @@ NSStringFormatValueTypeKey ld zero - %ld seconds left + تتبقى لَحظة one - 1 second left + تتبقى ثانية two - %ld seconds left + تتبقى ثانيتين few - %ld seconds left + تتبقى %ld ثوان many - %ld seconds left + تتبقى %ld ثانيةً other - %ld seconds left + تتبقى %ld ثانية date.year.ago.abbr @@ -421,17 +445,17 @@ NSStringFormatValueTypeKey ld zero - %ldy ago + مُنذُ لَحظة one - 1y ago + مُنذُ سنة two - %ldy ago + مُنذُ سنتين few - %ldy ago + مُنذُ %ld سنين many - %ldy ago + مُنذُ %ld سنةً other - %ldy ago + مُنذُ %ld سنة date.month.ago.abbr @@ -445,17 +469,17 @@ NSStringFormatValueTypeKey ld zero - %ldM ago + مُنذُ لَحظة one - 1M ago + مُنذُ شهر two - %ldM ago + مُنذُ شهرين few - %ldM ago + مُنذُ %ld أشهُر many - %ldM ago + مُنذُ %ld شهرًا other - %ldM ago + مُنذُ %ld شهر date.day.ago.abbr @@ -469,17 +493,17 @@ NSStringFormatValueTypeKey ld zero - %ldd ago + مُنذُ لَحظة one - 1d ago + مُنذُ يوم two - %ldd ago + مُنذُ يومين few - %ldd ago + مُنذُ %ld أيام many - %ldd ago + مُنذُ %ld يومًا other - %ldd ago + مُنذُ %ld يوم date.hour.ago.abbr @@ -493,17 +517,17 @@ NSStringFormatValueTypeKey ld zero - %ldh ago + مُنذُ لَحظة one - 1h ago + مُنذُ ساعة two - %ldh ago + مُنذُ ساعتين few - %ldh ago + مُنذُ %ld ساعات many - %ldh ago + مُنذُ %ld ساعةًَ other - %ldh ago + مُنذُ %ld ساعة date.minute.ago.abbr @@ -517,17 +541,17 @@ NSStringFormatValueTypeKey ld zero - %ldm ago + مُنذُ لَحظة one - 1m ago + مُنذُ دقيقة two - %ldm ago + مُنذُ دقيقتان few - %ldm ago + مُنذُ %ld دقائق many - %ldm ago + مُنذُ %ld دقيقةً other - %ldm ago + مُنذُ %ld دقيقة date.second.ago.abbr @@ -541,17 +565,17 @@ NSStringFormatValueTypeKey ld zero - %lds ago + مُنذُ لَحظة one - 1s ago + مُنذُ ثانية two - %lds ago + مُنذُ ثانيتين few - %lds ago + مُنذُ %ld ثوان many - %lds ago + مُنذُ %ld ثانية other - %lds ago + مُنذُ %ld ثانية diff --git a/Localization/StringsConvertor/input/ar_SA/app.json b/Localization/StringsConvertor/input/ar_SA/app.json index ba15760fb..71e0f538f 100644 --- a/Localization/StringsConvertor/input/ar_SA/app.json +++ b/Localization/StringsConvertor/input/ar_SA/app.json @@ -2,55 +2,55 @@ "common": { "alerts": { "common": { - "please_try_again": "الرجاء المحاولة مرة أخرى.", - "please_try_again_later": "الرجاء المحاولة مرة أخرى لاحقاً." + "please_try_again": "يُرجى المحاولة مرة أُخرى.", + "please_try_again_later": "يُرجى المحاولة مرة أُخرى لاحقاً." }, "sign_up_failure": { - "title": "فشل التسجيل" + "title": "إخفاق في التسجيل" }, "server_error": { "title": "خطأ في الخادم" }, "vote_failure": { - "title": "فشل التصويت", - "poll_ended": "The poll has ended" + "title": "إخفاق في التصويت", + "poll_ended": "انتهى استطلاع الرأي" }, "discard_post_content": { - "title": "تجاهل المسودة", - "message": "Confirm to discard composed post content." + "title": "التخلص من المسودة", + "message": "أكِّد للتخلص مِن مُحتوى مَنشور مؤلَّف." }, "publish_post_failure": { - "title": "أخفقت عملية النشر", - "message": "Failed to publish the post.\nPlease check your internet connection.", + "title": "إخفاق في عمليَّة النشر", + "message": "فَشَلَ نَشر المَنشور.\nيُرجى التحقق من اتصالك بالإنترنت.", "attachments_message": { - "video_attach_with_photo": "Cannot attach a video to a post that already contains images.", - "more_than_one_video": "Cannot attach more than one video." + "video_attach_with_photo": "لا يُمكن إرفاق مقطع مرئي إلى مَنشور يحتوي بالفعل على صُوَر.", + "more_than_one_video": "لا يُمكِنُ إرفاق أكثر مِن مَقطع مرئي واحِد." } }, "edit_profile_failure": { - "title": "Edit Profile Error", - "message": "Cannot edit profile. Please try again." + "title": "خطأ في تَحرير الملف الشخصي", + "message": "لا يمكن تعديل الملف الشخصي. يُرجى المحاولة مرة أُخرى." }, "sign_out": { "title": "تسجيل الخروج", - "message": "هل أنت متأكد من أنك تريد تسجيل الخروج؟", + "message": "هل أنت متأكد من رغبتك في تسجيل الخروج؟", "confirm": "تسجيل الخروج" }, "block_domain": { - "title": "Are you really, really sure you want to block the entire %s? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain and any of your followers from that domain will be removed.", - "block_entire_domain": "حظر النطاق" + "title": "هل أنتَ مُتأكِّدٌ حقًا مِن رغبتك في حظر %s بالكامل؟ في معظم الحالات، يكون مِنَ الكافي والمُفَضَّل استهداف عدد محدود للحظر أو الكتم. لن ترى محتوى من هذا النطاق وسوف يتم إزالة جميع متابعيك المتواجدين فيه.", + "block_entire_domain": "حظر النِطاق" }, "save_photo_failure": { - "title": "فشل حفظ الصورة", - "message": "Please enable the photo library access permission to save the photo." + "title": "إخفاق في حفظ الصورة", + "message": "يُرجى إتاحة إذن الوصول إلى مكتبة الصور لحفظ الصورة." }, "delete_post": { - "title": "هل أنت متأكد من أنك تريد حذف هذا المنشور؟", + "title": "هل أنت متأكد من رغبتك في حذف هذا المنشور؟", "delete": "احذف" }, "clean_cache": { - "title": "تنظيف ذاكرة التخزين المؤقت", - "message": "تم تنظيف ذاكرة التخزين المؤقت %s بنجاح." + "title": "مَحو ذاكرة التخزين المؤقت", + "message": "تمَّ مَحو ذاكرة التخزين المؤقت %s بنجاح." } }, "controls": { @@ -61,30 +61,31 @@ "open": "افتح", "add": "إضافة", "remove": "احذف", - "edit": "تعديل", + "edit": "تحرير", "save": "حفظ", "ok": "حسنًا", - "done": "تم", + "done": "تمّ", "confirm": "تأكيد", "continue": "واصل", + "compose": "تأليف", "cancel": "إلغاء", "discard": "تجاهل", - "try_again": "حاول مرة أخرى", - "take_photo": "التقط صورة", + "try_again": "المُحاولة مرة أُخرى", + "take_photo": "التقاط صورة", "save_photo": "حفظ الصورة", "copy_photo": "نسخ الصورة", - "sign_in": "لِج", - "sign_up": "انشئ حسابًا", + "sign_in": "تسجيل الدخول", + "sign_up": "إنشاء حِساب", "see_more": "عرض المزيد", - "preview": "معاينة", - "share": "شارك", - "share_user": "شارك %s", - "share_post": "شارك المنشور", - "open_in_safari": "افتحه في سفاري", + "preview": "مُعاينة", + "share": "المُشارك", + "share_user": "مُشاركة %s", + "share_post": "مشارك المنشور", + "open_in_safari": "الفتح في Safari", "find_people": "ابحث عن أشخاص لمتابعتهم", "manually_search": "البحث يدوياً بدلاً من ذلك", "skip": "تخطي", - "reply": "رد", + "reply": "الرَد", "report_user": "ابلغ عن %s", "block_domain": "حظر %s", "unblock_domain": "إلغاء حظر %s", @@ -100,7 +101,7 @@ "keyboard": { "common": { "switch_to_tab": "التبديل إلى %s", - "compose_new_post": "إنشاء منشور جديد", + "compose_new_post": "تأليف منشور جديد", "show_favorites": "إظهار المفضلة", "open_settings": "أفتح الإعدادات" }, @@ -111,9 +112,9 @@ "open_author_profile": "افتح الملف التعريفي للمؤلف", "open_reblogger_profile": "افتح الملف التعريفي لمشارِك المنشور", "reply_status": "رد على المنشور", - "toggle_reblog": "Toggle Reblog on Post", - "toggle_favorite": "Toggle Favorite on Post", - "toggle_content_warning": "Toggle Content Warning", + "toggle_reblog": "تبديل إعادة تدوين منشور", + "toggle_favorite": "تبديل المفضلة لِمنشور", + "toggle_content_warning": "تبديل تحذير المُحتَوى", "preview_image": "معاينة الصورة" }, "segmented_control": { @@ -122,44 +123,44 @@ } }, "status": { - "user_reblogged": "%s reblogged", - "user_replied_to": "Replied to %s", - "show_post": "Show Post", - "show_user_profile": "Show user profile", - "content_warning": "Content Warning", - "media_content_warning": "Tap anywhere to reveal", + "user_reblogged": "أعادَ %s تدوينها", + "user_replied_to": "رد على %s", + "show_post": "اظهر المنشور", + "show_user_profile": "اظهر الملف التعريفي للمستخدم", + "content_warning": "تحذير عن المحتوى", + "media_content_warning": "انقر على أي مكان للكشف", "poll": { - "vote": "Vote", - "closed": "Closed" + "vote": "صَوِّت", + "closed": "انتهى" }, "actions": { - "reply": "Reply", - "reblog": "Reblog", - "unreblog": "Undo reblog", - "favorite": "Favorite", - "unfavorite": "Unfavorite", - "menu": "Menu" + "reply": "رد", + "reblog": "إعادة النشر", + "unreblog": "تراجع عن إعادة النشر", + "favorite": "إضافة إلى المفضلة", + "unfavorite": "إزالة من المفضلة", + "menu": "القائمة" }, "tag": { - "url": "URL", - "mention": "Mention", - "link": "Link", - "hashtag": "Hashtag", + "url": "عنوان URL", + "mention": "أشر إلى", + "link": "الرابط", + "hashtag": "الوسم", "email": "البريد الإلكتروني", - "emoji": "Emoji" + "emoji": "إيموجي" } }, "friendship": { - "follow": "Follow", - "following": "Following", - "request": "Request", - "pending": "Pending", - "block": "Block", - "block_user": "Block %s", - "block_domain": "Block %s", - "unblock": "Unblock", - "unblock_user": "Unblock %s", - "blocked": "Blocked", + "follow": "اتبع", + "following": "مُتابَع", + "request": "إرسال طَلَب", + "pending": "قيد المُراجعة", + "block": "حظر", + "block_user": "حظر %s", + "block_domain": "حظر %s", + "unblock": "إلغاء الحَظر", + "unblock_user": "إلغاء حظر %s", + "blocked": "محظور", "mute": "أكتم", "mute_user": "أكتم %s", "unmute": "إلغاء الكتم", @@ -168,45 +169,45 @@ "edit_info": "تعديل المعلومات" }, "timeline": { - "filtered": "Filtered", + "filtered": "مُصفَّى", "timestamp": { "now": "الأن" }, "loader": { - "load_missing_posts": "Load missing posts", + "load_missing_posts": "تحميل المنشورات المَفقودة", "loading_missing_posts": "تحميل المزيد من المنشورات...", "show_more_replies": "إظهار المزيد من الردود" }, "header": { "no_status_found": "لا توجد هناك منشورات", - "blocking_warning": "You can’t view this user's profile\nuntil you unblock them.\nYour profile looks like this to them.", - "user_blocking_warning": "You can’t view %s’s profile\nuntil you unblock them.\nYour profile looks like this to them.", - "blocked_warning": "You can’t view this user’s profile\nuntil they unblock you.", - "user_blocked_warning": "You can’t view %s’s profile\nuntil they unblock you.", - "suspended_warning": "This user has been suspended.", - "user_suspended_warning": "%s’s account has been suspended." + "blocking_warning": "لا يُمكنك الاطلاع على الملف الشخصي لهذا المُستخدِم\nحتَّى تَرفعَ الحَظر عنه.\nملفًّكَ الشخصي يَظهَرُ بِمثل هذِهِ الحالة بالنسبةِ لَهُ أيضًا.", + "user_blocking_warning": "لا يُمكنك الاطلاع على ملف %s الشخصي\nحتَّى تَرفعَ الحَظر عنه.\nملفًّكَ الشخصي يَظهَرُ بِمثل هذِهِ الحالة بالنسبةِ لَهُ أيضًا.", + "blocked_warning": "لا يُمكِنُكَ عَرض الملف الشخصي لهذا المُستخدِم\nحتَّى يَرفَعَ الحَظر عَنك.", + "user_blocked_warning": "لا يُمكِنُكَ عَرض ملف %s الشخصي\nحتَّى يَرفَعَ الحَظر عَنك.", + "suspended_warning": "تمَّ إيقاف هذا المُستخدِم.", + "user_suspended_warning": "لقد أوقِفَ حِساب %s." } } } }, "scene": { "welcome": { - "slogan": "Social networking\nback in your hands." + "slogan": "شبكات التواصل الاجتماعي\nمرة أُخرى بين يديك." }, "server_picker": { - "title": "Pick a server,\nany server.", + "title": "اِختر خادِم،\nأي خادِم.", "button": { "category": { "all": "الكل", "all_accessiblity_description": "الفئة: الكل", - "academia": "academia", + "academia": "أكاديمي", "activism": "للنشطاء", "food": "الطعام", "furry": "فروي", "games": "ألعاب", "general": "عام", "journalism": "صحافة", - "lgbt": "lgbt", + "lgbt": "مجتمع الشواذ", "regional": "اقليمي", "art": "فن", "music": "موسيقى", @@ -225,7 +226,7 @@ }, "empty_state": { "finding_servers": "البحث عن خوادم متوفرة...", - "bad_network": "Something went wrong while loading the data. Check your internet connection.", + "bad_network": "حدث خطأٌ ما أثناء تحميل البيانات. تحقَّق من اتصالك بالإنترنت.", "no_results": "لا توجد نتائج" } }, @@ -263,29 +264,29 @@ "reason": "السبب" }, "reason": { - "blocked": "%s contains a disallowed email provider", - "unreachable": "%s does not seem to exist", - "taken": "%s is already in use", - "reserved": "%s is a reserved keyword", - "accepted": "%s must be accepted", - "blank": "%s is required", - "invalid": "%s is invalid", - "too_long": "%s is too long", - "too_short": "%s is too short", - "inclusion": "%s is not a supported value" + "blocked": "يحتوي %s على موفِّر خدمة بريد إلكتروني غير مسموح به", + "unreachable": "يبدوا أنَّ %s غير موجود", + "taken": "إنَّ %s مُستخدَمٌ بالفعل", + "reserved": "إنَّ %s عبارة عن كلمة مفتاحيَّة محجوزة", + "accepted": "يجب أن يُقبل %s", + "blank": "%s مطلوب", + "invalid": "%s غير صالح", + "too_long": "%s طويل جداً", + "too_short": "%s قصير جدا", + "inclusion": "إنَّ %s قيمة غير مدعومة" }, "special": { - "username_invalid": "Username must only contain alphanumeric characters and underscores", - "username_too_long": "Username is too long (can’t be longer than 30 characters)", - "email_invalid": "This is not a valid email address", - "password_too_short": "Password is too short (must be at least 8 characters)" + "username_invalid": "يُمكِن أن يحتوي اسم المستخدم على أحرف أبجدية، أرقام وشرطات سفلية فقط", + "username_too_long": "اسم المستخدم طويل جداً (يجب ألّا يكون أطول من 30 رمز)", + "email_invalid": "هذا عنوان بريد إلكتروني غير صالح", + "password_too_short": "كلمة المرور قصيرة جداً (يجب أن تكون 8 أحرف على الأقل)" } } }, "server_rules": { - "title": "Some ground rules.", - "subtitle": "These rules are set by the admins of %s.", - "prompt": "By continuing, you’re subject to the terms of service and privacy policy for %s.", + "title": "بعض القواعد الأساسية.", + "subtitle": "تم سنّ هذه القواعد من قبل مشرفي %s.", + "prompt": "إن اخترت المواصلة، فإنك تخضع لشروط الخدمة وسياسة الخصوصية لـ %s.", "terms_of_service": "شروط الخدمة", "privacy_policy": "سياسة الخصوصية", "button": { @@ -294,35 +295,35 @@ }, "confirm_email": { "title": "شيء واحد أخير.", - "subtitle": "We just sent an email to %s,\ntap the link to confirm your account.", + "subtitle": "لقد أرسلنا للتو رسالة بريد إلكتروني إلى %s،\nاضغط على الرابط لتأكيد حسابك.", "button": { - "open_email_app": "Open Email App", - "dont_receive_email": "I never got an email" + "open_email_app": "افتح تطبيق البريد الإلكتروني", + "dont_receive_email": "لم أستلم أبدًا بريدا إلكترونيا" }, "dont_receive_email": { - "title": "Check your email", - "description": "Check if your email address is correct as well as your junk folder if you haven’t.", - "resend_email": "Resend Email" + "title": "تحقق من بريدك الإلكتروني", + "description": "تحقق ممَّ إذا كان عنوان بريدك الإلكتروني صحيحًا وكذلك تأكد مِن مجلد البريد غير الهام إذا لم تكن قد فعلت ذلك.", + "resend_email": "إعادة إرسال البريد الإلكتروني" }, "open_email_app": { - "title": "Check your inbox.", - "description": "We just sent you an email. Check your junk folder if you haven’t.", + "title": "تحقَّق من بريدك الوارِد.", + "description": "لقد أرسلنا لك بريدًا إلكترونيًا للتو. تحقق من مجلد البريد غير الهام الخاص بك إذا لم تكن قد فعلت ذلك.", "mail": "البريد", - "open_email_client": "Open Email Client" + "open_email_client": "فتح عميل البريد الإلكتروني" } }, "home_timeline": { "title": "الخيط الرئيسي", "navigation_bar_state": { "offline": "غير متصل", - "new_posts": "See new posts", - "published": "Published!", - "Publishing": "Publishing post..." + "new_posts": "إظهار منشورات جديدة", + "published": "تم نشره!", + "Publishing": "جارٍ نشر المشاركة…" } }, "suggestion_account": { - "title": "Find People to Follow", - "follow_explain": "When you follow someone, you’ll see their posts in your home feed." + "title": "ابحث عن أشخاص لمتابعتهم", + "follow_explain": "عِندَ مُتابَعَتِكَ لأحدِهِم، سَوف تَرى مَنشوراته في تغذيَتِكَ الرئيسة." }, "compose": { "title": { @@ -334,54 +335,54 @@ "photo_library": "مكتبة الصور", "browse": "تصفح" }, - "content_input_placeholder": "ما الذي يجول ببالك", + "content_input_placeholder": "أخبِرنا بِما يَجُولُ فِي ذِهنَك", "compose_action": "انشر", "replying_to_user": "رد على %s", "attachment": { "photo": "صورة", "video": "فيديو", - "attachment_broken": "This %s is broken and can’t be\nuploaded to Mastodon.", - "description_photo": "Describe the photo for the visually-impaired...", - "description_video": "Describe the video for the visually-impaired..." + "attachment_broken": "هذا ال%s مُعطَّل ويتعذَّر رفعه إلى ماستودون.", + "description_photo": "صِف الصورة للمكفوفين...", + "description_video": "صِف المقطع المرئي للمكفوفين..." }, "poll": { - "duration_time": "Duration: %s", + "duration_time": "المدة: %s", "thirty_minutes": "30 دقيقة", "one_hour": "ساعة واحدة", "six_hours": "6 ساعات", "one_day": "يوم واحد", "three_days": "3 أيام", "seven_days": "7 أيام", - "option_number": "Option %ld" + "option_number": "الخيار %ld" }, "content_warning": { - "placeholder": "Write an accurate warning here..." + "placeholder": "اكتب تَحذيرًا دَقيقًا هُنا..." }, "visibility": { - "public": "Public", - "unlisted": "Unlisted", - "private": "Followers only", - "direct": "Only people I mention" + "public": "للعامة", + "unlisted": "غير مُدرَج", + "private": "لمتابعيك فقط", + "direct": "ففط للأشخاص المشار إليهم" }, "auto_complete": { - "space_to_add": "Space to add" + "space_to_add": "انقر مساحة لإضافتِها" }, "accessibility": { - "append_attachment": "Add Attachment", + "append_attachment": "إضافة مُرفَق", "append_poll": "اضافة استطلاع رأي", "remove_poll": "إزالة الاستطلاع", "custom_emoji_picker": "منتقي مخصص للإيموجي", "enable_content_warning": "تنشيط تحذير المحتوى", "disable_content_warning": "تعطيل تحذير الحتوى", - "post_visibility_menu": "Post Visibility Menu" + "post_visibility_menu": "قائمة ظهور المنشور" }, "keyboard": { - "discard_post": "Discard Post", - "publish_post": "Publish Post", - "toggle_poll": "Toggle Poll", - "toggle_content_warning": "Toggle Content Warning", - "append_attachment_entry": "Add Attachment - %s", - "select_visibility_entry": "Select Visibility - %s" + "discard_post": "تجاهُل المنشور", + "publish_post": "نَشر المَنشُور", + "toggle_poll": "تبديل الاستطلاع", + "toggle_content_warning": "تبديل تحذير المُحتوى", + "append_attachment_entry": "إضافة مُرفَق - %s", + "select_visibility_entry": "اختر مدى الظهور - %s" } }, "profile": { @@ -393,7 +394,7 @@ "fields": { "add_row": "إضافة صف", "placeholder": { - "label": "Label", + "label": "التسمية", "content": "المحتوى" } }, @@ -405,14 +406,20 @@ "relationship_action_alert": { "confirm_unmute_user": { "title": "إلغاء كتم الحساب", - "message": "Confirm to unmute %s" + "message": "أكِّد لرفع كتمْ %s" }, "confirm_unblock_usre": { "title": "إلغاء حظر الحساب", - "message": "Confirm to unblock %s" + "message": "أكِّد لرفع حظر %s" } } }, + "follower": { + "footer": "لا يُمكِن عَرض المُتابِعين مِنَ الخوادم الأُخرى." + }, + "following": { + "footer": "لا يُمكِن عَرض المُتابَعات مِنَ الخوادم الأُخرى." + }, "search": { "title": "بحث", "search_bar": { @@ -423,12 +430,12 @@ "button_text": "طالع الكل", "hash_tag": { "title": "ذات شعبية على ماستدون", - "description": "Hashtags that are getting quite a bit of attention", - "people_talking": "%s people are talking" + "description": "الوسوم التي تحظى بقدر كبير من الاهتمام", + "people_talking": "%s أشخاص يتحدَّثوا" }, "accounts": { - "title": "Accounts you might like", - "description": "You may like to follow these accounts", + "title": "حسابات قد تعجبك", + "description": "قد ترغب في متابعة هذه الحسابات", "follow": "تابع" } }, @@ -440,34 +447,34 @@ "posts": "المنشورات" }, "empty_state": { - "no_results": "No results" + "no_results": "ليس هناك أية نتيجة" }, - "recent_search": "Recent searches", - "clear": "Clear" + "recent_search": "عمليات البحث الأخيرة", + "clear": "مَحو" } }, "favorite": { - "title": "Your Favorites" + "title": "مفضلتك" }, "notification": { "title": { - "Everything": "Everything", - "Mentions": "Mentions" + "Everything": "الكل", + "Mentions": "الإشارات" }, - "user_followed_you": "%s followed you", - "user_favorited your post": "%s favorited your post", - "user_reblogged_your_post": "%s reblogged your post", - "user_mentioned_you": "%s mentioned you", - "user_requested_to_follow_you": "%s requested to follow you", - "user_your_poll_has_ended": "%s Your poll has ended", + "user_followed_you": "يتابعك %s", + "user_favorited your post": "أضاف %s منشورك إلى مفضلته", + "user_reblogged_your_post": "أعاد %s تدوين مشاركتك", + "user_mentioned_you": "أشار إليك %s", + "user_requested_to_follow_you": "طلب %s متابعتك", + "user_your_poll_has_ended": "%s اِنتهى استطلاعُكَ للرأي", "keyobard": { - "show_everything": "Show Everything", - "show_mentions": "Show Mentions" + "show_everything": "إظهار كل شيء", + "show_mentions": "إظهار الإشارات" } }, "thread": { - "back_title": "Post", - "title": "Post from %s" + "back_title": "منشور", + "title": "مَنشور مِن %s" }, "settings": { "title": "الإعدادات", @@ -475,44 +482,44 @@ "appearance": { "title": "المظهر", "automatic": "تلقائي", - "light": "Always Light", - "dark": "Always Dark" + "light": "مضيءٌ دائمًا", + "dark": "مظلمٌ دائِمًا" }, "notifications": { "title": "الإشعارات", - "favorites": "Favorites my post", - "follows": "Follows me", - "boosts": "Reblogs my post", - "mentions": "Mentions me", + "favorites": "الإعجاب بِمنشوراتي", + "follows": "يتابعني", + "boosts": "إعادة تدوين منشوراتي", + "mentions": "الإشارة لي", "trigger": { - "anyone": "anyone", - "follower": "a follower", - "follow": "anyone I follow", - "noone": "no one", - "title": "Notify me when" + "anyone": "أي شخص", + "follower": "مشترِك", + "follow": "أي شخص أُتابِعُه", + "noone": "لا أحد", + "title": "إشعاري عِندَ" } }, "preference": { "title": "التفضيلات", - "true_black_dark_mode": "True black dark mode", - "disable_avatar_animation": "Disable animated avatars", - "disable_emoji_animation": "Disable animated emojis", - "using_default_browser": "Use default browser to open links" + "true_black_dark_mode": "النمط الأسود الداكِن الحقيقي", + "disable_avatar_animation": "تعطيل الصور الرمزية المتحرِّكة", + "disable_emoji_animation": "تعطيل الرموز التعبيرية المتحرِّكَة", + "using_default_browser": "اِستخدام المتصفح الافتراضي لفتح الروابط" }, "boring_zone": { - "title": "The Boring Zone", + "title": "المنطقة المملة", "account_settings": "إعدادات الحساب", "terms": "شروط الخدمة", "privacy": "سياسة الخصوصية" }, "spicy_zone": { - "title": "The Spicy Zone", - "clear": "Clear Media Cache", + "title": "المنطقة الحارة", + "clear": "مسح ذاكرة التخزين المؤقت للوسائط", "signout": "تسجيل الخروج" } }, "footer": { - "mastodon_description": "ماستدون برنامج مفتوح المصدر. يمكنك المساهمة، أو الإبلاغ عن تقارير الأخطاء، على غيت هب %s (%s)" + "mastodon_description": "ماستدون برنامج مفتوح المصدر. يمكنك المساهمة، أو الإبلاغ عن تقارير الأخطاء على GitHub في %s (%s)" }, "keyboard": { "close_settings_window": "إغلاق نافذة الإعدادات" @@ -522,18 +529,28 @@ "title": "ابلغ عن %s", "step1": "الخطوة 1 من 2", "step2": "الخطوة 2 من 2", - "content1": "Are there any other posts you’d like to add to the report?", - "content2": "Is there anything the moderators should know about this report?", - "send": "Send Report", - "skip_to_send": "Send without comment", - "text_placeholder": "Type or paste additional comments" + "content1": "هل ترغب في إضافة أي مشاركات أُخرى إلى الشكوى؟", + "content2": "هل هناك أي شيء يجب أن يعرفه المُراقبين حول هذه الشكوى؟", + "send": "إرسال الشكوى", + "skip_to_send": "إرسال بدون تعليق", + "text_placeholder": "اكتب أو الصق تعليقات إضافيَّة" }, "preview": { "keyboard": { - "close_preview": "إغلاق المعاينة", + "close_preview": "إغلاق المُعايَنَة", "show_next": "إظهار التالي", "show_previous": "إظهار السابق" } + }, + "account_list": { + "tab_bar_hint": "المِلف المُحدَّد حاليًا: %s. انقر نقرًا مزدوجًا ثم اضغط مع الاستمرار لإظهار مُبدِّل الحِساب", + "dismiss_account_switcher": "تجاهُل مبدِّل الحساب", + "add_account": "إضافة حساب" + }, + "wizard": { + "new_in_mastodon": "جديد في ماستودون", + "multiple_account_switch_intro_description": "بدِّل بين حسابات متعددة عبر الاستمرار بالضغط على زر الملف الشخصي.", + "accessibility_hint": "انقر نقرًا مزدوجًا لتجاهل النافذة المنبثقة" } } } \ No newline at end of file diff --git a/Localization/StringsConvertor/input/ar_SA/ios-infoPlist.json b/Localization/StringsConvertor/input/ar_SA/ios-infoPlist.json index 932ac804a..22fb2868e 100644 --- a/Localization/StringsConvertor/input/ar_SA/ios-infoPlist.json +++ b/Localization/StringsConvertor/input/ar_SA/ios-infoPlist.json @@ -1,6 +1,6 @@ { - "NSCameraUsageDescription": "Used to take photo for post status", - "NSPhotoLibraryAddUsageDescription": "Used to save photo into the Photo Library", - "NewPostShortcutItemTitle": "New Post", + "NSCameraUsageDescription": "يُستخدم لالتقاط الصورة عِندَ نشر الحالات", + "NSPhotoLibraryAddUsageDescription": "يُستخدم لحِفظ الصورة في مكتبة الصور", + "NewPostShortcutItemTitle": "منشور جديد", "SearchShortcutItemTitle": "البحث" } diff --git a/Localization/StringsConvertor/input/ca_ES/Localizable.stringsdict b/Localization/StringsConvertor/input/ca_ES/Localizable.stringsdict index b76b0a921..140185bad 100644 --- a/Localization/StringsConvertor/input/ca_ES/Localizable.stringsdict +++ b/Localization/StringsConvertor/input/ca_ES/Localizable.stringsdict @@ -2,10 +2,26 @@ + a11y.plural.count.unread.notification + + NSStringLocalizedFormatKey + %#@notification_count_unread_notification@ + notification_count_unread_notification + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 notificació per llegir + other + %ld notificacions per llegir + + a11y.plural.count.input_limit_exceeds NSStringLocalizedFormatKey - El límit d’entrada supera a %#@character_count@ + El límit de la entrada supera a %#@character_count@ character_count NSStringFormatSpecTypeKey @@ -21,7 +37,7 @@ a11y.plural.count.input_limit_remains NSStringLocalizedFormatKey - El límit d’entrada continua sent %#@character_count@ + El límit de la entrada continua sent %#@character_count@ character_count NSStringFormatSpecTypeKey @@ -95,7 +111,7 @@ one 1 impuls other - %ld impuls + %ld impulsos plural.count.vote @@ -285,9 +301,9 @@ NSStringFormatValueTypeKey ld one - fa 1a + fa 1 any other - fa %ldy anys + fa %ld anys date.month.ago.abbr @@ -301,9 +317,9 @@ NSStringFormatValueTypeKey ld one - fa 1M + fa 1 mes other - fa %ldM mesos + fa %ld mesos date.day.ago.abbr @@ -317,9 +333,9 @@ NSStringFormatValueTypeKey ld one - fa 1d + fa 1 día other - fa %ldd dies + fa %ld dies date.hour.ago.abbr @@ -335,7 +351,7 @@ one fa 1h other - fa %ldh hores + fa %ld hores date.minute.ago.abbr @@ -349,9 +365,9 @@ NSStringFormatValueTypeKey ld one - fa 1m + fa 1 minut other - fa %ldm minuts + fa %ld minuts date.second.ago.abbr @@ -365,9 +381,9 @@ NSStringFormatValueTypeKey ld one - fa 1s + fa 1 segon other - fa %lds seg + fa %ld segons diff --git a/Localization/StringsConvertor/input/ca_ES/app.json b/Localization/StringsConvertor/input/ca_ES/app.json index 19ff88b46..2ecd587c6 100644 --- a/Localization/StringsConvertor/input/ca_ES/app.json +++ b/Localization/StringsConvertor/input/ca_ES/app.json @@ -67,6 +67,7 @@ "done": "Fet", "confirm": "Confirma", "continue": "Continua", + "compose": "Composa", "cancel": "Cancel·la", "discard": "Descarta", "try_again": "Torna a provar", @@ -413,6 +414,12 @@ } } }, + "follower": { + "footer": "Els seguidors d'altres servidors no son mostrats." + }, + "following": { + "footer": "Els seguits d'altres servidors no son mostrats." + }, "search": { "title": "Cerca", "search_bar": { @@ -534,6 +541,16 @@ "show_next": "Mostrar Següent", "show_previous": "Mostrar Anterior" } + }, + "account_list": { + "tab_bar_hint": "Perfil actual seleccionat: %s. Toca dues vegades i manté el dit per a mostrar el commutador de comptes", + "dismiss_account_switcher": "Descartar el commutador de comptes", + "add_account": "Afegir compte" + }, + "wizard": { + "new_in_mastodon": "Nou a Mastodon", + "multiple_account_switch_intro_description": "Commuta entre diversos comptes mantenint premut el botó del perfil.", + "accessibility_hint": "Toca dues vegades per descartar l'assistent" } } } \ No newline at end of file diff --git a/Localization/StringsConvertor/input/cy_GB/Localizable.stringsdict b/Localization/StringsConvertor/input/cy_GB/Localizable.stringsdict index 537064efb..e6b0d5f95 100644 --- a/Localization/StringsConvertor/input/cy_GB/Localizable.stringsdict +++ b/Localization/StringsConvertor/input/cy_GB/Localizable.stringsdict @@ -2,6 +2,30 @@ + a11y.plural.count.unread.notification + + NSStringLocalizedFormatKey + %#@notification_count_unread_notification@ + notification_count_unread_notification + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + zero + %ld unread notification + one + 1 unread notification + two + %ld unread notification + few + %ld unread notification + many + %ld unread notification + other + %ld unread notification + + a11y.plural.count.input_limit_exceeds NSStringLocalizedFormatKey diff --git a/Localization/StringsConvertor/input/cy_GB/app.json b/Localization/StringsConvertor/input/cy_GB/app.json index b60ade9c1..5c01ae7e0 100644 --- a/Localization/StringsConvertor/input/cy_GB/app.json +++ b/Localization/StringsConvertor/input/cy_GB/app.json @@ -67,6 +67,7 @@ "done": "Done", "confirm": "Confirm", "continue": "Continue", + "compose": "Compose", "cancel": "Cancel", "discard": "Discard", "try_again": "Try Again", @@ -413,6 +414,12 @@ } } }, + "follower": { + "footer": "Followers from other servers are not displayed." + }, + "following": { + "footer": "Follows from other servers are not displayed." + }, "search": { "title": "Search", "search_bar": { @@ -534,6 +541,16 @@ "show_next": "Show Next", "show_previous": "Show Previous" } + }, + "account_list": { + "tab_bar_hint": "Current selected profile: %s. Double tap then hold to show account switcher", + "dismiss_account_switcher": "Dismiss Account Switcher", + "add_account": "Add Account" + }, + "wizard": { + "new_in_mastodon": "New in Mastodon", + "multiple_account_switch_intro_description": "Switch between multiple accounts by holding the profile button.", + "accessibility_hint": "Double tap to dismiss this wizard" } } } \ No newline at end of file diff --git a/Localization/StringsConvertor/input/da_DK/Localizable.stringsdict b/Localization/StringsConvertor/input/da_DK/Localizable.stringsdict index c7c84d074..730e2902a 100644 --- a/Localization/StringsConvertor/input/da_DK/Localizable.stringsdict +++ b/Localization/StringsConvertor/input/da_DK/Localizable.stringsdict @@ -2,6 +2,22 @@ + a11y.plural.count.unread.notification + + NSStringLocalizedFormatKey + %#@notification_count_unread_notification@ + notification_count_unread_notification + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 unread notification + other + %ld unread notification + + a11y.plural.count.input_limit_exceeds NSStringLocalizedFormatKey diff --git a/Localization/StringsConvertor/input/da_DK/app.json b/Localization/StringsConvertor/input/da_DK/app.json index b60ade9c1..5c01ae7e0 100644 --- a/Localization/StringsConvertor/input/da_DK/app.json +++ b/Localization/StringsConvertor/input/da_DK/app.json @@ -67,6 +67,7 @@ "done": "Done", "confirm": "Confirm", "continue": "Continue", + "compose": "Compose", "cancel": "Cancel", "discard": "Discard", "try_again": "Try Again", @@ -413,6 +414,12 @@ } } }, + "follower": { + "footer": "Followers from other servers are not displayed." + }, + "following": { + "footer": "Follows from other servers are not displayed." + }, "search": { "title": "Search", "search_bar": { @@ -534,6 +541,16 @@ "show_next": "Show Next", "show_previous": "Show Previous" } + }, + "account_list": { + "tab_bar_hint": "Current selected profile: %s. Double tap then hold to show account switcher", + "dismiss_account_switcher": "Dismiss Account Switcher", + "add_account": "Add Account" + }, + "wizard": { + "new_in_mastodon": "New in Mastodon", + "multiple_account_switch_intro_description": "Switch between multiple accounts by holding the profile button.", + "accessibility_hint": "Double tap to dismiss this wizard" } } } \ No newline at end of file diff --git a/Localization/StringsConvertor/input/de_DE/Localizable.stringsdict b/Localization/StringsConvertor/input/de_DE/Localizable.stringsdict index e89bdb074..66b7f2a2d 100644 --- a/Localization/StringsConvertor/input/de_DE/Localizable.stringsdict +++ b/Localization/StringsConvertor/input/de_DE/Localizable.stringsdict @@ -2,6 +2,22 @@ + a11y.plural.count.unread.notification + + NSStringLocalizedFormatKey + %#@notification_count_unread_notification@ + notification_count_unread_notification + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 ungelesene Benachrichtigung + other + %ld ungelesene Benachrichtigungen + + a11y.plural.count.input_limit_exceeds NSStringLocalizedFormatKey diff --git a/Localization/StringsConvertor/input/de_DE/app.json b/Localization/StringsConvertor/input/de_DE/app.json index ecfe841eb..dc8cdf8c0 100644 --- a/Localization/StringsConvertor/input/de_DE/app.json +++ b/Localization/StringsConvertor/input/de_DE/app.json @@ -67,6 +67,7 @@ "done": "Fertig", "confirm": "Bestätigen", "continue": "Fortfahren", + "compose": "Compose", "cancel": "Abbrechen", "discard": "Verwerfen", "try_again": "Nochmals versuchen", @@ -413,6 +414,12 @@ } } }, + "follower": { + "footer": "Followers from other servers are not displayed." + }, + "following": { + "footer": "Follows from other servers are not displayed." + }, "search": { "title": "Suche", "search_bar": { @@ -534,6 +541,16 @@ "show_next": "Nächstes anzeigen", "show_previous": "Vorheriges anzeigen" } + }, + "account_list": { + "tab_bar_hint": "Aktuell ausgewähltes Profil: %s. Doppeltippen dann gedrückt halten, um den Kontoschalter anzuzeigen", + "dismiss_account_switcher": "Dismiss Account Switcher", + "add_account": "Konto hinzufügen" + }, + "wizard": { + "new_in_mastodon": "Neu in Mastodon", + "multiple_account_switch_intro_description": "Wechsel zwischen mehreren Konten durch drücken der Profil-Schaltfläche.", + "accessibility_hint": "Doppeltippen, um diesen Assistenten zu schließen" } } } \ No newline at end of file diff --git a/Localization/StringsConvertor/input/en_US/Localizable.stringsdict b/Localization/StringsConvertor/input/en_US/Localizable.stringsdict index c7c84d074..730e2902a 100644 --- a/Localization/StringsConvertor/input/en_US/Localizable.stringsdict +++ b/Localization/StringsConvertor/input/en_US/Localizable.stringsdict @@ -2,6 +2,22 @@ + a11y.plural.count.unread.notification + + NSStringLocalizedFormatKey + %#@notification_count_unread_notification@ + notification_count_unread_notification + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 unread notification + other + %ld unread notification + + a11y.plural.count.input_limit_exceeds NSStringLocalizedFormatKey diff --git a/Localization/StringsConvertor/input/en_US/app.json b/Localization/StringsConvertor/input/en_US/app.json index b60ade9c1..5c01ae7e0 100644 --- a/Localization/StringsConvertor/input/en_US/app.json +++ b/Localization/StringsConvertor/input/en_US/app.json @@ -67,6 +67,7 @@ "done": "Done", "confirm": "Confirm", "continue": "Continue", + "compose": "Compose", "cancel": "Cancel", "discard": "Discard", "try_again": "Try Again", @@ -413,6 +414,12 @@ } } }, + "follower": { + "footer": "Followers from other servers are not displayed." + }, + "following": { + "footer": "Follows from other servers are not displayed." + }, "search": { "title": "Search", "search_bar": { @@ -534,6 +541,16 @@ "show_next": "Show Next", "show_previous": "Show Previous" } + }, + "account_list": { + "tab_bar_hint": "Current selected profile: %s. Double tap then hold to show account switcher", + "dismiss_account_switcher": "Dismiss Account Switcher", + "add_account": "Add Account" + }, + "wizard": { + "new_in_mastodon": "New in Mastodon", + "multiple_account_switch_intro_description": "Switch between multiple accounts by holding the profile button.", + "accessibility_hint": "Double tap to dismiss this wizard" } } } \ No newline at end of file diff --git a/Localization/StringsConvertor/input/es_AR/Localizable.stringsdict b/Localization/StringsConvertor/input/es_AR/Localizable.stringsdict index f98962ccb..f4f0097eb 100644 --- a/Localization/StringsConvertor/input/es_AR/Localizable.stringsdict +++ b/Localization/StringsConvertor/input/es_AR/Localizable.stringsdict @@ -2,6 +2,22 @@ + a11y.plural.count.unread.notification + + NSStringLocalizedFormatKey + %#@notification_count_unread_notification@ + notification_count_unread_notification + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 notificación sin leer + other + %ld notificaciones sin leer + + a11y.plural.count.input_limit_exceeds NSStringLocalizedFormatKey diff --git a/Localization/StringsConvertor/input/es_AR/app.json b/Localization/StringsConvertor/input/es_AR/app.json index 301cec40e..ed909ecf1 100644 --- a/Localization/StringsConvertor/input/es_AR/app.json +++ b/Localization/StringsConvertor/input/es_AR/app.json @@ -67,6 +67,7 @@ "done": "Listo", "confirm": "Confirmar", "continue": "Continuar", + "compose": "Redactar", "cancel": "Cancelar", "discard": "Descartar", "try_again": "Intentá de nuevo", @@ -413,6 +414,12 @@ } } }, + "follower": { + "footer": "No se muestran los seguidores de otros servidores." + }, + "following": { + "footer": "No se muestran las cuentas de otros servidores que seguís." + }, "search": { "title": "Buscar", "search_bar": { @@ -534,6 +541,16 @@ "show_next": "Mostrar siguiente", "show_previous": "Mostrar anterior" } + }, + "account_list": { + "tab_bar_hint": "Perfil seleccionado actualmente: %s. Tocá dos veces y mantenelo presionado para cambiar de cuenta", + "dismiss_account_switcher": "Descartar cambio de cuenta", + "add_account": "Agregar cuenta" + }, + "wizard": { + "new_in_mastodon": "Novedad en Mastodon", + "multiple_account_switch_intro_description": "Cambiá entre varias cuentas manteniendo presionado el botón del perfil.", + "accessibility_hint": "Tocá dos veces para descartar este asistente" } } } \ No newline at end of file diff --git a/Localization/StringsConvertor/input/es_ES/Localizable.stringsdict b/Localization/StringsConvertor/input/es_ES/Localizable.stringsdict index bf493c1e8..d31d8825b 100644 --- a/Localization/StringsConvertor/input/es_ES/Localizable.stringsdict +++ b/Localization/StringsConvertor/input/es_ES/Localizable.stringsdict @@ -2,6 +2,22 @@ + a11y.plural.count.unread.notification + + NSStringLocalizedFormatKey + %#@notification_count_unread_notification@ + notification_count_unread_notification + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 unread notification + other + %ld unread notification + + a11y.plural.count.input_limit_exceeds NSStringLocalizedFormatKey diff --git a/Localization/StringsConvertor/input/es_ES/app.json b/Localization/StringsConvertor/input/es_ES/app.json index 5151137d7..72967c40f 100644 --- a/Localization/StringsConvertor/input/es_ES/app.json +++ b/Localization/StringsConvertor/input/es_ES/app.json @@ -67,6 +67,7 @@ "done": "Hecho", "confirm": "Confirmar", "continue": "Continuar", + "compose": "Redactar", "cancel": "Cancelar", "discard": "Descartar", "try_again": "Inténtalo de nuevo", @@ -413,6 +414,12 @@ } } }, + "follower": { + "footer": "No se muestran los seguidores de otros servidores." + }, + "following": { + "footer": "No se muestran los seguidos de otros servidores." + }, "search": { "title": "Buscar", "search_bar": { @@ -534,6 +541,16 @@ "show_next": "Mostrar Siguiente", "show_previous": "Mostrar Anterior" } + }, + "account_list": { + "tab_bar_hint": "Current selected profile: %s. Double tap then hold to show account switcher", + "dismiss_account_switcher": "Dismiss Account Switcher", + "add_account": "Add Account" + }, + "wizard": { + "new_in_mastodon": "New in Mastodon", + "multiple_account_switch_intro_description": "Switch between multiple accounts by holding the profile button.", + "accessibility_hint": "Double tap to dismiss this wizard" } } } \ No newline at end of file diff --git a/Localization/StringsConvertor/input/fr_FR/Localizable.stringsdict b/Localization/StringsConvertor/input/fr_FR/Localizable.stringsdict index d6fb911f3..4a912e4b3 100644 --- a/Localization/StringsConvertor/input/fr_FR/Localizable.stringsdict +++ b/Localization/StringsConvertor/input/fr_FR/Localizable.stringsdict @@ -2,10 +2,26 @@ + a11y.plural.count.unread.notification + + NSStringLocalizedFormatKey + %#@notification_count_unread_notification@ + notification_count_unread_notification + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 notification non lue + other + %ld notifications non lues + + a11y.plural.count.input_limit_exceeds NSStringLocalizedFormatKey - Input limit exceeds %#@character_count@ + La limite d’entrée dépasse %#@character_count@ character_count NSStringFormatSpecTypeKey @@ -13,9 +29,9 @@ NSStringFormatValueTypeKey ld one - 1 character + 1 caractère other - %ld characters + %ld caractères a11y.plural.count.input_limit_remains @@ -29,9 +45,9 @@ NSStringFormatValueTypeKey ld one - 1 character + 1 caractère other - %ld characters + %ld caractères plural.count.metric_formatted.post @@ -61,9 +77,9 @@ NSStringFormatValueTypeKey ld one - 1 post + 1 publication other - %ld posts + %ld publications plural.count.favorite @@ -157,9 +173,9 @@ NSStringFormatValueTypeKey ld one - 1 following + 1 abonnement other - %ld following + %ld abonnements plural.count.follower @@ -173,9 +189,9 @@ NSStringFormatValueTypeKey ld one - 1 follower + 1 abonné·e other - %ld followers + %ld abonné·e·s date.year.left @@ -189,9 +205,9 @@ NSStringFormatValueTypeKey ld one - 1 year left + Il reste 1 an other - %ld years left + %ld ans restants date.month.left @@ -205,9 +221,9 @@ NSStringFormatValueTypeKey ld one - 1 months left + 1 mois restant other - %ld months left + %ld mois restants date.day.left @@ -221,9 +237,9 @@ NSStringFormatValueTypeKey ld one - 1 day left + Il reste 1 jour other - %ld days left + il reste %ld jours date.hour.left @@ -237,9 +253,9 @@ NSStringFormatValueTypeKey ld one - 1 hour left + 1 heure restante other - %ld hours left + %ld heures restantes date.minute.left @@ -253,9 +269,9 @@ NSStringFormatValueTypeKey ld one - 1 minute left + 1 minute restante other - %ld minutes left + %ld minutes restantes date.second.left @@ -269,9 +285,9 @@ NSStringFormatValueTypeKey ld one - 1 second left + Il reste 1 seconde other - %ld seconds left + %ld secondes restantes date.year.ago.abbr @@ -285,9 +301,9 @@ NSStringFormatValueTypeKey ld one - 1y ago + il y a 1 année other - %ldy ago + il y a %ld ans date.month.ago.abbr @@ -301,9 +317,9 @@ NSStringFormatValueTypeKey ld one - 1M ago + il y a 1 mois other - %ldM ago + il y a %ld mois date.day.ago.abbr @@ -317,9 +333,9 @@ NSStringFormatValueTypeKey ld one - 1d ago + il y a 1j other - %ldd ago + il y a %ldj date.hour.ago.abbr @@ -333,9 +349,9 @@ NSStringFormatValueTypeKey ld one - 1h ago + il y a 1h other - %ldh ago + il y a %ldh date.minute.ago.abbr @@ -349,9 +365,9 @@ NSStringFormatValueTypeKey ld one - 1m ago + Il y a 1 m other - %ldm ago + il y a %ld m date.second.ago.abbr @@ -365,9 +381,9 @@ NSStringFormatValueTypeKey ld one - 1s ago + Il y a 1 s other - %lds ago + il y a %ld s diff --git a/Localization/StringsConvertor/input/fr_FR/app.json b/Localization/StringsConvertor/input/fr_FR/app.json index 1a73b068e..dd834928a 100644 --- a/Localization/StringsConvertor/input/fr_FR/app.json +++ b/Localization/StringsConvertor/input/fr_FR/app.json @@ -67,6 +67,7 @@ "done": "Terminé", "confirm": "Confirmer", "continue": "Continuer", + "compose": "Compose", "cancel": "Annuler", "discard": "Abandonner", "try_again": "Réessayer", @@ -105,10 +106,10 @@ "open_settings": "Ouvrir les paramètres" }, "timeline": { - "previous_status": "Article précédent", - "next_status": "Article suivant", + "previous_status": "Publication précédente", + "next_status": "Publication suivante", "open_status": "Ouvrir la publication", - "open_author_profile": "Ouvrir le profil de l'auteur", + "open_author_profile": "Ouvrir le profil de l’auteur·rice", "open_reblogger_profile": "Ouvrir le profil du rebloggeur", "reply_status": "Répondre à la publication", "toggle_reblog": "Basculer le reblogue lors de la publication", @@ -122,10 +123,10 @@ } }, "status": { - "user_reblogged": "%s à reblogué", + "user_reblogged": "%s a reblogué", "user_replied_to": "À répondu à %s", "show_post": "Montrer la publication", - "show_user_profile": "Montrer le profil de l’utilisateur", + "show_user_profile": "Montrer le profil de l’utilisateur·rice", "content_warning": "Avertissement de contenu", "media_content_warning": "Tapotez n’importe où pour révéler la publication", "poll": { @@ -180,9 +181,9 @@ "header": { "no_status_found": "Aucune publication trouvée", "blocking_warning": "Vous ne pouvez pas voir le profil de cet utilisateur\n tant que vous ne l’avez pas débloqué\nVotre profil ressemble à ça pour lui.", - "user_blocking_warning": "Vous ne pouvez pas voir le profil de %s tant que vous ne l’avez pas débloqué\nVotre profil ressemble à ça pour lui.", + "user_blocking_warning": "Vous ne pouvez pas voir le profil de %s\ntant que vous ne l’avez pas débloqué\nVotre profil ressemble à ça pour lui.", "blocked_warning": "Vous ne pouvez pas voir le profil de cet utilisateur\n tant qu'il ne vous aura pas débloqué.", - "user_blocked_warning": "Vous ne pouvez pas voir le profil de %s\n tant qu'il ne vous aura pas débloqué.", + "user_blocked_warning": "Vous ne pouvez pas voir le profil de %s\ntant qu’il ne vous aura pas débloqué.", "suspended_warning": "Cet utilisateur a été suspendu.", "user_suspended_warning": "Le compte de %s à été suspendu." } @@ -217,7 +218,7 @@ }, "label": { "language": "LANGUE", - "users": "UTILISATEURS", + "users": "UTILISATEUR·RICE·S", "category": "CATÉGORIE" }, "input": { @@ -255,7 +256,7 @@ }, "error": { "item": { - "username": "Nom d'utilisateur", + "username": "Nom d’utilisateur", "email": "Courriel", "password": "Mot de passe", "agreement": "Accord", @@ -370,7 +371,7 @@ "append_attachment": "Joindre un document", "append_poll": "Ajouter un Sondage", "remove_poll": "Retirer le sondage", - "custom_emoji_picker": "Sélecteur d’émojis personnalisé", + "custom_emoji_picker": "Sélecteur d’émojis personnalisés", "enable_content_warning": "Basculer l’avertissement de contenu", "disable_content_warning": "Désactiver l'avertissement de contenu", "post_visibility_menu": "Menu de Visibilité de la publication" @@ -405,7 +406,7 @@ "relationship_action_alert": { "confirm_unmute_user": { "title": "Ne plus mettre en sourdine ce compte", - "message": "Êtes-vous sûr de vouloir mettre en sourdine %s" + "message": "Êtes-vous sûr de vouloir désactiver la sourdine de %s" }, "confirm_unblock_usre": { "title": "Débloquer le compte", @@ -413,6 +414,12 @@ } } }, + "follower": { + "footer": "Les abonné·e·s issus des autres serveurs ne sont pas affiché·e·s." + }, + "following": { + "footer": "Follows from other servers are not displayed." + }, "search": { "title": "Rechercher", "search_bar": { @@ -454,12 +461,12 @@ "Everything": "Tout", "Mentions": "Mentions" }, - "user_followed_you": "%s followed you", - "user_favorited your post": "%s favorited your post", - "user_reblogged_your_post": "%s reblogged your post", - "user_mentioned_you": "%s mentioned you", - "user_requested_to_follow_you": "%s requested to follow you", - "user_your_poll_has_ended": "%s Your poll has ended", + "user_followed_you": "%s s’est abonné à vous", + "user_favorited your post": "%s a mis votre pouet en favori", + "user_reblogged_your_post": "%s a partagé votre publication", + "user_mentioned_you": "%s vous a mentionné", + "user_requested_to_follow_you": "%s a demandé à vous suivre", + "user_your_poll_has_ended": "%s votre sondage est terminé", "keyobard": { "show_everything": "Tout Afficher", "show_mentions": "Afficher les mentions" @@ -496,7 +503,7 @@ "title": "Préférences", "true_black_dark_mode": "Vrai mode sombre", "disable_avatar_animation": "Désactiver les avatars animés", - "disable_emoji_animation": "Désactiver les émoticônes animées", + "disable_emoji_animation": "Désactiver les émojis animées", "using_default_browser": "Utiliser le navigateur par défaut pour ouvrir les liens" }, "boring_zone": { @@ -534,6 +541,16 @@ "show_next": "Afficher le suivant", "show_previous": "Afficher le précédent" } + }, + "account_list": { + "tab_bar_hint": "Current selected profile: %s. Double tap then hold to show account switcher", + "dismiss_account_switcher": "Dismiss Account Switcher", + "add_account": "Ajouter un compte" + }, + "wizard": { + "new_in_mastodon": "Nouveau dans Mastodon", + "multiple_account_switch_intro_description": "Basculez entre plusieurs comptes en appuyant de maniere prolongée sur le bouton profil.", + "accessibility_hint": "Tapotez deux fois pour fermer cet assistant" } } } \ No newline at end of file diff --git a/Localization/StringsConvertor/input/gd_GB/Localizable.stringsdict b/Localization/StringsConvertor/input/gd_GB/Localizable.stringsdict index 63a998c6e..7a54f553e 100644 --- a/Localization/StringsConvertor/input/gd_GB/Localizable.stringsdict +++ b/Localization/StringsConvertor/input/gd_GB/Localizable.stringsdict @@ -2,6 +2,26 @@ + a11y.plural.count.unread.notification + + NSStringLocalizedFormatKey + %#@notification_count_unread_notification@ + notification_count_unread_notification + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + %ld bhrath nach deach a leughadh + two + %ld bhrath nach deach a leughadh + few + %ld brathan nach deach a leughadh + other + %ld brath nach deach a leughadh + + a11y.plural.count.input_limit_exceeds NSStringLocalizedFormatKey diff --git a/Localization/StringsConvertor/input/gd_GB/app.json b/Localization/StringsConvertor/input/gd_GB/app.json index 89ca12e3a..b5c66f8f6 100644 --- a/Localization/StringsConvertor/input/gd_GB/app.json +++ b/Localization/StringsConvertor/input/gd_GB/app.json @@ -67,6 +67,7 @@ "done": "Deiseil", "confirm": "Dearbh", "continue": "Lean air adhart", + "compose": "Sgrìobh", "cancel": "Sguir dheth", "discard": "Tilg air falbh", "try_again": "Feuch ris a-rithist", @@ -413,6 +414,12 @@ } } }, + "follower": { + "footer": "Cha dèid luchd-leantainn o fhrithealaichean eile a shealltainn." + }, + "following": { + "footer": "Cha dèid cò air a leanas tu air frithealaichean eile a shealltainn." + }, "search": { "title": "Lorg", "search_bar": { @@ -534,6 +541,16 @@ "show_next": "Air adhart", "show_previous": "Air ais" } + }, + "account_list": { + "tab_bar_hint": "A’ phròifil air a taghadh: %s. Thoir gnogag dhùbailte is cùm sìos a ghearradh leum gu cunntas eile", + "dismiss_account_switcher": "Leig seachad taghadh a’ chunntais", + "add_account": "Cuir cunntas ris" + }, + "wizard": { + "new_in_mastodon": "Na tha ùr ann am Mastodon", + "multiple_account_switch_intro_description": "Geàrr leum eadar iomadh cunntas le cumail sìos putan na pròifil.", + "accessibility_hint": "Thoir gnogag dhùbailte a’ leigeil seachad an draoidh seo" } } } \ No newline at end of file diff --git a/Localization/StringsConvertor/input/hi_IN/Localizable.stringsdict b/Localization/StringsConvertor/input/hi_IN/Localizable.stringsdict index c7c84d074..730e2902a 100644 --- a/Localization/StringsConvertor/input/hi_IN/Localizable.stringsdict +++ b/Localization/StringsConvertor/input/hi_IN/Localizable.stringsdict @@ -2,6 +2,22 @@ + a11y.plural.count.unread.notification + + NSStringLocalizedFormatKey + %#@notification_count_unread_notification@ + notification_count_unread_notification + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 unread notification + other + %ld unread notification + + a11y.plural.count.input_limit_exceeds NSStringLocalizedFormatKey diff --git a/Localization/StringsConvertor/input/hi_IN/app.json b/Localization/StringsConvertor/input/hi_IN/app.json index b60ade9c1..5c01ae7e0 100644 --- a/Localization/StringsConvertor/input/hi_IN/app.json +++ b/Localization/StringsConvertor/input/hi_IN/app.json @@ -67,6 +67,7 @@ "done": "Done", "confirm": "Confirm", "continue": "Continue", + "compose": "Compose", "cancel": "Cancel", "discard": "Discard", "try_again": "Try Again", @@ -413,6 +414,12 @@ } } }, + "follower": { + "footer": "Followers from other servers are not displayed." + }, + "following": { + "footer": "Follows from other servers are not displayed." + }, "search": { "title": "Search", "search_bar": { @@ -534,6 +541,16 @@ "show_next": "Show Next", "show_previous": "Show Previous" } + }, + "account_list": { + "tab_bar_hint": "Current selected profile: %s. Double tap then hold to show account switcher", + "dismiss_account_switcher": "Dismiss Account Switcher", + "add_account": "Add Account" + }, + "wizard": { + "new_in_mastodon": "New in Mastodon", + "multiple_account_switch_intro_description": "Switch between multiple accounts by holding the profile button.", + "accessibility_hint": "Double tap to dismiss this wizard" } } } \ No newline at end of file diff --git a/Localization/StringsConvertor/input/id_ID/Localizable.stringsdict b/Localization/StringsConvertor/input/id_ID/Localizable.stringsdict index 718723849..88c0fac97 100644 --- a/Localization/StringsConvertor/input/id_ID/Localizable.stringsdict +++ b/Localization/StringsConvertor/input/id_ID/Localizable.stringsdict @@ -2,6 +2,20 @@ + a11y.plural.count.unread.notification + + NSStringLocalizedFormatKey + %#@notification_count_unread_notification@ + notification_count_unread_notification + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + other + %ld unread notification + + a11y.plural.count.input_limit_exceeds NSStringLocalizedFormatKey @@ -69,7 +83,7 @@ NSStringFormatValueTypeKey ld other - %ld favorites + %ld favorit plural.count.reblog @@ -97,7 +111,7 @@ NSStringFormatValueTypeKey ld other - %ld votes + %ld suara plural.count.voter @@ -111,7 +125,7 @@ NSStringFormatValueTypeKey ld other - %ld voters + %ld pemilih plural.people_talking diff --git a/Localization/StringsConvertor/input/id_ID/app.json b/Localization/StringsConvertor/input/id_ID/app.json index cf4beced4..6f3171254 100644 --- a/Localization/StringsConvertor/input/id_ID/app.json +++ b/Localization/StringsConvertor/input/id_ID/app.json @@ -67,6 +67,7 @@ "done": "Selesai", "confirm": "Konfirmasi", "continue": "Lanjut", + "compose": "Compose", "cancel": "Batal", "discard": "Discard", "try_again": "Coba Lagi", @@ -267,7 +268,7 @@ "unreachable": "%s sepertinya tidak ada", "taken": "%s sudah digunakan", "reserved": "%s is a reserved keyword", - "accepted": "%s must be accepted", + "accepted": "%s harus diterima", "blank": "%s diperlukan", "invalid": "%s tidak valid", "too_long": "%s terlalu panjang", @@ -355,7 +356,7 @@ "option_number": "Option %ld" }, "content_warning": { - "placeholder": "Write an accurate warning here..." + "placeholder": "Tulis peringatan yang akurat di sini..." }, "visibility": { "public": "Publik", @@ -413,6 +414,12 @@ } } }, + "follower": { + "footer": "Followers from other servers are not displayed." + }, + "following": { + "footer": "Follows from other servers are not displayed." + }, "search": { "title": "Cari", "search_bar": { @@ -534,6 +541,16 @@ "show_next": "Show Next", "show_previous": "Show Previous" } + }, + "account_list": { + "tab_bar_hint": "Current selected profile: %s. Double tap then hold to show account switcher", + "dismiss_account_switcher": "Dismiss Account Switcher", + "add_account": "Add Account" + }, + "wizard": { + "new_in_mastodon": "New in Mastodon", + "multiple_account_switch_intro_description": "Switch between multiple accounts by holding the profile button.", + "accessibility_hint": "Double tap to dismiss this wizard" } } } \ No newline at end of file diff --git a/Localization/StringsConvertor/input/ja_JP/Localizable.stringsdict b/Localization/StringsConvertor/input/ja_JP/Localizable.stringsdict index ac37d9a39..c51a9a29d 100644 --- a/Localization/StringsConvertor/input/ja_JP/Localizable.stringsdict +++ b/Localization/StringsConvertor/input/ja_JP/Localizable.stringsdict @@ -2,6 +2,20 @@ + a11y.plural.count.unread.notification + + NSStringLocalizedFormatKey + %#@notification_count_unread_notification@ + notification_count_unread_notification + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + other + %ld 件の未読通知 + + a11y.plural.count.input_limit_exceeds NSStringLocalizedFormatKey @@ -13,7 +27,7 @@ NSStringFormatValueTypeKey ld other - %ld characters + %ld 文字 a11y.plural.count.input_limit_remains @@ -27,7 +41,7 @@ NSStringFormatValueTypeKey ld other - %ld characters + %ld 文字 plural.count.metric_formatted.post @@ -97,7 +111,7 @@ NSStringFormatValueTypeKey ld other - %ld votes + %ld票 plural.count.voter @@ -181,7 +195,7 @@ NSStringFormatValueTypeKey ld other - %ld months left + %ldか月前 date.day.left @@ -265,7 +279,7 @@ NSStringFormatValueTypeKey ld other - %ldM ago + %ld分前 date.day.ago.abbr diff --git a/Localization/StringsConvertor/input/ja_JP/app.json b/Localization/StringsConvertor/input/ja_JP/app.json index 3d05e62dc..417ca3e3a 100644 --- a/Localization/StringsConvertor/input/ja_JP/app.json +++ b/Localization/StringsConvertor/input/ja_JP/app.json @@ -67,6 +67,7 @@ "done": "完了", "confirm": "確認", "continue": "続ける", + "compose": "Compose", "cancel": "キャンセル", "discard": "破棄", "try_again": "再実行", @@ -191,7 +192,7 @@ }, "scene": { "welcome": { - "slogan": "Social networking\nback in your hands." + "slogan": "ソーシャルネットワーキングを、あなたの手の中に." }, "server_picker": { "title": "サーバーを選択", @@ -413,6 +414,12 @@ } } }, + "follower": { + "footer": "Followers from other servers are not displayed." + }, + "following": { + "footer": "Follows from other servers are not displayed." + }, "search": { "title": "検索", "search_bar": { @@ -534,6 +541,16 @@ "show_next": "次を見る", "show_previous": "前を見る" } + }, + "account_list": { + "tab_bar_hint": "Current selected profile: %s. Double tap then hold to show account switcher", + "dismiss_account_switcher": "Dismiss Account Switcher", + "add_account": "アカウントを追加" + }, + "wizard": { + "new_in_mastodon": "Mastodon の新機能", + "multiple_account_switch_intro_description": "プロフィールボタンを押して複数のアカウントを切り替えます。", + "accessibility_hint": "Double tap to dismiss this wizard" } } } \ No newline at end of file diff --git a/Localization/StringsConvertor/input/kmr_TR/Localizable.stringsdict b/Localization/StringsConvertor/input/kmr_TR/Localizable.stringsdict new file mode 100644 index 000000000..8ae1b812a --- /dev/null +++ b/Localization/StringsConvertor/input/kmr_TR/Localizable.stringsdict @@ -0,0 +1,390 @@ + + + + + a11y.plural.count.unread.notification + + NSStringLocalizedFormatKey + %#@notification_count_unread_notification@ + notification_count_unread_notification + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 agahdariya nexwendî + other + %ld agahdariyên nexwendî + + + a11y.plural.count.input_limit_exceeds + + NSStringLocalizedFormatKey + Sînorê têketinê derbas kir %#@character_count@ + character_count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 tîp + other + %ld tîp + + + a11y.plural.count.input_limit_remains + + NSStringLocalizedFormatKey + Sînorê têketinê %#@character_count@ maye + character_count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 tîp + other + %ld tîp + + + plural.count.metric_formatted.post + + NSStringLocalizedFormatKey + %@ %#@post_count@ + post_count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + şandî + other + şandî + + + plural.count.post + + NSStringLocalizedFormatKey + %#@post_count@ + post_count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 şandî + other + %ld şandî + + + plural.count.favorite + + NSStringLocalizedFormatKey + %#@favorite_count@ + favorite_count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 hezkirin + other + %ld hezkirin + + + plural.count.reblog + + NSStringLocalizedFormatKey + %#@reblog_count@ + reblog_count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 ji nû ve nivîsandin + other + %ld ji nû ve nivîsandin + + + plural.count.vote + + NSStringLocalizedFormatKey + %#@vote_count@ + vote_count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 deng + other + %ld deng + + + plural.count.voter + + NSStringLocalizedFormatKey + %#@voter_count@ + voter_count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 hilbijêr + other + %ld hilbijêr + + + plural.people_talking + + NSStringLocalizedFormatKey + %#@count_people_talking@ + count_people_talking + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 mirov diaxive + other + %ld mirov diaxive + + + plural.count.following + + NSStringLocalizedFormatKey + %#@count_following@ + count_following + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 dişopîne + other + %ld dişopîne + + + plural.count.follower + + NSStringLocalizedFormatKey + %#@count_follower@ + count_follower + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 şopîner + other + %ld şopîner + + + date.year.left + + NSStringLocalizedFormatKey + %#@count_year_left@ + count_year_left + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 sal berê + other + %ld sal berê + + + date.month.left + + NSStringLocalizedFormatKey + %#@count_month_left@ + count_month_left + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 meh berê + other + %ld meh berê + + + date.day.left + + NSStringLocalizedFormatKey + %#@count_day_left@ + count_day_left + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 roj berê + other + %ld roj berê + + + date.hour.left + + NSStringLocalizedFormatKey + %#@count_hour_left@ + count_hour_left + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 demjimêr berê + other + %ld demjimêr berê + + + date.minute.left + + NSStringLocalizedFormatKey + %#@count_minute_left@ + count_minute_left + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 xulek berê + other + %ld xulek berê + + + date.second.left + + NSStringLocalizedFormatKey + %#@count_second_left@ + count_second_left + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 çirke berê + other + %ld çirke berê + + + date.year.ago.abbr + + NSStringLocalizedFormatKey + %#@count_year_ago_abbr@ + count_year_ago_abbr + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 sal berê + other + %ld sal berê + + + date.month.ago.abbr + + NSStringLocalizedFormatKey + %#@count_month_ago_abbr@ + count_month_ago_abbr + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 xulek berê + other + %ld xulek berê + + + date.day.ago.abbr + + NSStringLocalizedFormatKey + %#@count_day_ago_abbr@ + count_day_ago_abbr + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 roj berê + other + %ld roj berê + + + date.hour.ago.abbr + + NSStringLocalizedFormatKey + %#@count_hour_ago_abbr@ + count_hour_ago_abbr + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 demjimêr berê + other + %ld demjimêr berê + + + date.minute.ago.abbr + + NSStringLocalizedFormatKey + %#@count_minute_ago_abbr@ + count_minute_ago_abbr + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 xulek berê + other + %ld xulek berê + + + date.second.ago.abbr + + NSStringLocalizedFormatKey + %#@count_second_ago_abbr@ + count_second_ago_abbr + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 çirke berê + other + %ld çirke berê + + + + diff --git a/Localization/StringsConvertor/input/kmr_TR/app.json b/Localization/StringsConvertor/input/kmr_TR/app.json new file mode 100644 index 000000000..c360eb430 --- /dev/null +++ b/Localization/StringsConvertor/input/kmr_TR/app.json @@ -0,0 +1,556 @@ +{ + "common": { + "alerts": { + "common": { + "please_try_again": "Ji kerema xwe dîsa biceribîne.", + "please_try_again_later": "Ji kerema xwe paşê dîsa biceribîne." + }, + "sign_up_failure": { + "title": "Tomarkirin têkçû" + }, + "server_error": { + "title": "Çewtiya rajekar" + }, + "vote_failure": { + "title": "Dengdayîn têkçû", + "poll_ended": "Rapirsîya qediya" + }, + "discard_post_content": { + "title": "Reşnivîsê paşguh bike", + "message": "Bipejrîne ku naveroka şandiyê ya hatiye nivîsandin paşguh bikî." + }, + "publish_post_failure": { + "title": "Weşandin têkçû", + "message": "Weşandina şandiyê têkçû.\nJkx girêdana înternetê xwe kontrol bike.", + "attachments_message": { + "video_attach_with_photo": "Nikare vîdyoyekê tevlî şandiyê ku berê wêne tê de heye bike.", + "more_than_one_video": "Nikare ji bêtirî yek vîdyoyekê tevlî şandiyê bike." + } + }, + "edit_profile_failure": { + "title": "Di serrastkirina profîlê çewtî", + "message": "Nikare profîlê serrast bike. Jkx dîsa biceribîne." + }, + "sign_out": { + "title": "Derkeve", + "message": "Ma tu dixwazî ku derkevî?", + "confirm": "Derkeve" + }, + "block_domain": { + "title": "Tu ji xwe bawerî, bi rastî tu dixwazî hemû %s asteng bikî? Di gelek rewşan de asteng kirin an jî bêdeng kirin têrê dike û tê tercîh kirin. Tu nikarî naveroka vê navperê di demnameyê an jî agahdariyên xwe de bibînî. Şopînerên te yê di vê navperê were jêbirin.", + "block_entire_domain": "Navperê asteng bike" + }, + "save_photo_failure": { + "title": "Tomarkirina wêneyê têkçû", + "message": "Ji kerema xwe mafê bide gihîştina wênegehê çalak bike da ku wêne werin tomarkirin." + }, + "delete_post": { + "title": "Ma tu dixwazî vê şandiyê jê bibî?", + "delete": "Jê bibe" + }, + "clean_cache": { + "title": "Pêşbîrê pak bike", + "message": "Pêşbîra %s biserketî hate pakkirin." + } + }, + "controls": { + "actions": { + "back": "Vegere", + "next": "Pêş", + "previous": "Paş", + "open": "Veke", + "add": "Tevlî bike", + "remove": "Rake", + "edit": "Serrast bike", + "save": "Tomar bike", + "ok": "BAŞ E", + "done": "Qediya", + "confirm": "Bipejirîne", + "continue": "Bidomîne", + "compose": "Binivîsîne", + "cancel": "Dev jê berde", + "discard": "Biavêje", + "try_again": "Dîsa biceribîne", + "take_photo": "Wêne bikişîne", + "save_photo": "Wêneyê tomar bike", + "copy_photo": "Wêneyê jê bigire", + "sign_in": "Têkeve", + "sign_up": "Tomar bibe", + "see_more": "Bêtir bibîne", + "preview": "Pêşdîtin", + "share": "Parve bike", + "share_user": "%s parve bike", + "share_post": "Şandiyê parve bike", + "open_in_safari": "Di Safariyê de veke", + "find_people": "Mirovan bo şopandinê bibîne", + "manually_search": "Ji devlê bi destan lêgerînê bike", + "skip": "Derbas bike", + "reply": "Bersivê bide", + "report_user": "%s ragihîne", + "block_domain": "%s asteng bike", + "unblock_domain": "%s asteng neke", + "settings": "Sazkarî", + "delete": "Jê bibe" + }, + "tabs": { + "home": "Serrûpel", + "search": "Bigere", + "notification": "Agahdarî", + "profile": "Profîl" + }, + "keyboard": { + "common": { + "switch_to_tab": "Biguherîne bo %s", + "compose_new_post": "Şandiyeke nû binivsîne", + "show_favorites": "Bijarteyan nîşan bide", + "open_settings": "Sazkariyan Veke" + }, + "timeline": { + "previous_status": "Şandeya paş", + "next_status": "Şandiya pêş", + "open_status": "Şandiyê veke", + "open_author_profile": "Profîla nivîskaran veke", + "open_reblogger_profile": "Profîla nivîskaran veke", + "reply_status": "Bersivê bide şandiyê", + "toggle_reblog": "Ji vû nivîsandin di şandiyê de biguherîne", + "toggle_favorite": "Li ser şandiyê bijarte biguherîne", + "toggle_content_warning": "Hişyariya naverokê biguherîne", + "preview_image": "Pêşdîtina wêneyê" + }, + "segmented_control": { + "previous_section": "Beşa paş", + "next_section": "Beşa pêş" + } + }, + "status": { + "user_reblogged": "%s ji nû ve hate nivîsandin", + "user_replied_to": "Bersiv da %s", + "show_post": "Şandiyê nîşan bide", + "show_user_profile": "Profîla bikarhêner nîşan bide", + "content_warning": "Hişyariya naverokê", + "media_content_warning": "Ji bo eşkerekirinê li derekî bitikîne", + "poll": { + "vote": "Deng bide", + "closed": "Girtî" + }, + "actions": { + "reply": "Bersivê bide", + "reblog": "Ji nû ve nivîsandin", + "unreblog": "Ji nû ve nivîsandinê vegere", + "favorite": "Bijarte", + "unfavorite": "Nebijarte", + "menu": "Kulîn" + }, + "tag": { + "url": "URL", + "mention": "Qalkirin", + "link": "Girêdan", + "hashtag": "Hashtag", + "email": "E-name", + "emoji": "Emojî" + } + }, + "friendship": { + "follow": "Bişopîne", + "following": "Dişopîne", + "request": "Daxwaz bike", + "pending": "Tê nirxandin", + "block": "Asteng bike", + "block_user": "%s asteng bike", + "block_domain": "%s asteng bike", + "unblock": "Astengiyê rake", + "unblock_user": "%s asteng neke", + "blocked": "Astengkirî", + "mute": "Bêdeng bike", + "mute_user": "%s bêdeng bike", + "unmute": "Bêdeng neke", + "unmute_user": "%s bêdeng neke", + "muted": "Bêdengkirî", + "edit_info": "Zanyariyan serrast bike" + }, + "timeline": { + "filtered": "Parzûnkirî", + "timestamp": { + "now": "Niha" + }, + "loader": { + "load_missing_posts": "Şandiyên wendayî bar bike", + "loading_missing_posts": "Şandiyên wendayî tên barkirin...", + "show_more_replies": "Bêtir bersivan nîşan bide" + }, + "header": { + "no_status_found": "Tu şandî nehate dîtin", + "blocking_warning": "Tu nikarî profîla vî/ê bikarhênerî bibînî\nHeya ku tu astengiyê li ser wî/ê ranekî.\nProfîla te ji wan ra wiha xuya dike.", + "user_blocking_warning": "Tu nikarî profîla %s bibînî\nHeya ku tu astengiyê li ser wî/ê ranekî.\nProfîla te ji wan ra wiha xuya dike.", + "blocked_warning": "Tu nikarî profîla vî/ê bikarhênerî bibînî\nheya ku ew astengiyê li ser te rakin.", + "user_blocked_warning": "Tu nikarî profîla %s bibînî\nHeta ku astengîya te rakin.", + "suspended_warning": "Ev bikarhêner hatiye rawestandin.", + "user_suspended_warning": "Ajimêra %s hatiye rawestandin." + } + } + } + }, + "scene": { + "welcome": { + "slogan": "Torên civakî\ndi destên te de." + }, + "server_picker": { + "title": "Rajekarekê hilbijêre,\nHer kîjan rajekar be.", + "button": { + "category": { + "all": "Hemû", + "all_accessiblity_description": "Beş: Hemû", + "academia": "akademî", + "activism": "çalakî", + "food": "xwarin", + "furry": "furry", + "games": "lîsk", + "general": "giştî", + "journalism": "rojnamevanî", + "lgbt": "lgbt", + "regional": "herêmî", + "art": "huner", + "music": "muzîk", + "tech": "teknolojî" + }, + "see_less": "Kêmtir bibîne", + "see_more": "Bêtir bibîne" + }, + "label": { + "language": "ZIMAN", + "users": "BIKARHÊNER", + "category": "BEŞ" + }, + "input": { + "placeholder": "Rajekarekî bibîne an jî beşdarî ya xwe bibe..." + }, + "empty_state": { + "finding_servers": "Peydakirina rajekarên berdest...", + "bad_network": "Di dema barkirina daneyan da çewtî derket. Girêdana xwe ya înternetê kontrol bike.", + "no_results": "Encam tune" + } + }, + "register": { + "title": "Ji me re hinekî qala xwe bike.", + "input": { + "avatar": { + "delete": "Jê bibe" + }, + "username": { + "placeholder": "navê bikarhêner", + "duplicate_prompt": "Navê vê bikarhêner tê girtin." + }, + "display_name": { + "placeholder": "navê nîşanê" + }, + "email": { + "placeholder": "e-name" + }, + "password": { + "placeholder": "pêborîn", + "hint": "Pêborîna te herî kêm divê ji 8 tîpan pêk bê" + }, + "invite": { + "registration_user_invite_request": "Tu çima dixwazî beşdar bibî?" + } + }, + "error": { + "item": { + "username": "Navê bikarhêner", + "email": "E-name", + "password": "Pêborîn", + "agreement": "Peyman", + "locale": "Zimanê navrûyê", + "reason": "Sedem" + }, + "reason": { + "blocked": "%s peydekerê e-peyamê yê qedexekirî dihewîne", + "unreachable": "%s xuya ye ku tune ye", + "taken": "%s jixwe tê bikaranîn", + "reserved": "%s peyveke parastî ye", + "accepted": "%s divê were pejirandin", + "blank": "%s pêwist e", + "invalid": "%s ne derbasdar e", + "too_long": "%s pir dirêj e", + "too_short": "%s pir kurt e", + "inclusion": "%s ne nirxek piştgirî ye" + }, + "special": { + "username_invalid": "Navê bikarhêner divê tenê ji tîpên alfajimarî û binxêz pêk be", + "username_too_long": "Navê bikarhêner pir dirêj e (ji 30 tîpan dirêjtir nabe)", + "email_invalid": "Ev navnîşaneke e-nameyê ne derbasdar e", + "password_too_short": "Pêborîn pir kurt e (divê herî kêm 8 tîp be)" + } + } + }, + "server_rules": { + "title": "Hinek rêzikên bingehîn.", + "subtitle": "Ev rêzik ji aliyê rêvebirên %s ve tên sazkirin.", + "prompt": "Bi domandinê, tu ji bo %s di bin mercên bikaranînê û polîtîkaya nepenîtiyê dipejirînî.", + "terms_of_service": "mercên bikaranînê", + "privacy_policy": "polîtikaya nihêniyê", + "button": { + "confirm": "Ez dipejirînim" + } + }, + "confirm_email": { + "title": "Tiştekî dawî.", + "subtitle": "Me tenê e-nameyek ji %s re şand,\ngirêdanê bitikne da ku ajimêra xwe bidî piştrastkirin.", + "button": { + "open_email_app": "Sepana e-nameyê veke", + "dont_receive_email": "Min hîç e-nameyeke nesitand" + }, + "dont_receive_email": { + "title": "E-nameyê xwe kontrol bike", + "description": "Kontrol bike ka navnîşana e-nameya te rast e û her wiha peldanka xwe ya spam.", + "resend_email": "E-namyê yê dîsa bişîne" + }, + "open_email_app": { + "title": "Nameyên xwe yên wergirtî kontrol bike.", + "description": "Me tenê ji te re e-nameyek şand. Heke nehatiye peldanka xwe ya spamê kontrol bike.", + "mail": "E-name", + "open_email_client": "Rajegirê e-nameyê veke" + } + }, + "home_timeline": { + "title": "Serrûpel", + "navigation_bar_state": { + "offline": "Derhêl", + "new_posts": "Şandiyên nû bibîne", + "published": "Hate weşandin!", + "Publishing": "Şandî tê weşandin..." + } + }, + "suggestion_account": { + "title": "Kesên bo ku bişopînî bibîne", + "follow_explain": "Gava tu kesekî dişopînî, tu yê şandiyê wan di serrûpelê de bibîne." + }, + "compose": { + "title": { + "new_post": "Şandiya nû", + "new_reply": "Bersiva nû" + }, + "media_selection": { + "camera": "Wêne bikişîne", + "photo_library": "Wênegeh", + "browse": "Bigere" + }, + "content_input_placeholder": "Tiştê ku di hişê te de ye binivîsin an jî pêve bike", + "compose_action": "Biweşîne", + "replying_to_user": "bersiv bide %s", + "attachment": { + "photo": "wêne", + "video": "vîdyo", + "attachment_broken": "Ev %s naxebite û nayê barkirin\n li ser Mastodon.", + "description_photo": "Wêneyê ji bo kêmbînên dîtbar bide nasîn...", + "description_video": "Vîdyoyê ji bo kêmbînên dîtbar bide nasîn..." + }, + "poll": { + "duration_time": "Dirêjî: %s", + "thirty_minutes": "30 xulek", + "one_hour": "1 Demjimêr", + "six_hours": "6 Demjimêr", + "one_day": "1 Roj", + "three_days": "3 Roj", + "seven_days": "7 Roj", + "option_number": "Vebijêrk %ld" + }, + "content_warning": { + "placeholder": "Li vir hişyariyek hûrgilî binivîsine..." + }, + "visibility": { + "public": "Gelemperî", + "unlisted": "Nerêzokkirî", + "private": "Tenê şopîneran", + "direct": "Tenê mirovên ku min qalkirî" + }, + "auto_complete": { + "space_to_add": "Bicîhkirinê tevlî bike" + }, + "accessibility": { + "append_attachment": "Pêvek tevlî bike", + "append_poll": "Rapirsî tevlî bike", + "remove_poll": "Rapirsî rake", + "custom_emoji_picker": "Hilbijêrê emojî yên kesanekirî", + "enable_content_warning": "Hişyariya naverokê çalak bike", + "disable_content_warning": "Hişyariya naverokê neçalak bike", + "post_visibility_menu": "Kulîna xuyabûna şandiyê" + }, + "keyboard": { + "discard_post": "Şandî paşguh bike", + "publish_post": "Şandiyê biweşîne", + "toggle_poll": "Rapirsiyê biguherîne", + "toggle_content_warning": "Hişyariya naverokê biguherîne", + "append_attachment_entry": "Pêvek tevlî bike - %s", + "select_visibility_entry": "Xuyabûnê hilbijêre - %s" + } + }, + "profile": { + "dashboard": { + "posts": "şandî", + "following": "dişopîne", + "followers": "şopîner" + }, + "fields": { + "add_row": "Rêzê tevlî bike", + "placeholder": { + "label": "Nîşan", + "content": "Naverok" + } + }, + "segmented_control": { + "posts": "Şandî", + "replies": "Bersiv", + "media": "Medya" + }, + "relationship_action_alert": { + "confirm_unmute_user": { + "title": "Ajimêrê bêdeng neke", + "message": "Ji bo vekirina bêdengkirinê bipejirîne %s" + }, + "confirm_unblock_usre": { + "title": "Astengiyê li ser ajimêr rake", + "message": "Ji bo rakirina astengkirinê bipejirîne %s" + } + } + }, + "follower": { + "footer": "Şopîner ji rajekerên din nayê dîtin." + }, + "following": { + "footer": "Şopandin ji rajekerên din nayê dîtin." + }, + "search": { + "title": "Bigere", + "search_bar": { + "placeholder": "Li hashtag û bikarhêneran bigere", + "cancel": "Dev jê berde" + }, + "recommend": { + "button_text": "Hemûyan bibîne", + "hash_tag": { + "title": "Rojev li ser Mastodon", + "description": "Hashtag ên ku pir balê dikişînin", + "people_talking": "%s kes diaxivin" + }, + "accounts": { + "title": "Ajimêrên ku belkî tu jê hez bikî", + "description": "Dibe ku tu bixwazî van ajimêran bişopînî", + "follow": "Bişopîne" + } + }, + "searching": { + "segment": { + "all": "Hemû", + "people": "Mirov", + "hashtags": "Hashtag", + "posts": "Şandî" + }, + "empty_state": { + "no_results": "Encam tune" + }, + "recent_search": "Lêgerînên dawî", + "clear": "Pak bike" + } + }, + "favorite": { + "title": "Bijarteyên te" + }, + "notification": { + "title": { + "Everything": "Her tişt", + "Mentions": "Qalkirin" + }, + "user_followed_you": "%s te şopand", + "user_favorited your post": "%s şandiya te hez kir", + "user_reblogged_your_post": "%s posta we ji nû ve tomar kir", + "user_mentioned_you": "%s qale te kir", + "user_requested_to_follow_you": "%s dixwazê te bişopîne", + "user_your_poll_has_ended": "Rapirsîya te qediya", + "keyobard": { + "show_everything": "Her tiştî nîşan bide", + "show_mentions": "Qalkirinan nîşan bike" + } + }, + "thread": { + "back_title": "Şandî", + "title": "Şandî ji %s" + }, + "settings": { + "title": "Sazkarî", + "section": { + "appearance": { + "title": "Xuyang", + "automatic": "Xweber", + "light": "Her dem ronî", + "dark": "Her dem tarî" + }, + "notifications": { + "title": "Agahdarî", + "favorites": "Şandiyên min hez kir", + "follows": "Min dişopîne", + "boosts": "Şandiya min ji nû ve nivîsand", + "mentions": "Qale min kir", + "trigger": { + "anyone": "her kes", + "follower": "şopînerek", + "follow": "her kesê ku dişopînim", + "noone": "ne yek", + "title": "Min agahdar bike gava" + } + }, + "preference": { + "title": "Hilbijarte", + "true_black_dark_mode": "Moda tarî ya reş a rastîn", + "disable_avatar_animation": "Avatarên anîmasyonî neçalak bike", + "disable_emoji_animation": "Emojiyên anîmasyonî neçalak bike", + "using_default_browser": "Ji bo vekirina girêdanan geroka berdest bi kar bîne" + }, + "boring_zone": { + "title": "Devera acizker", + "account_settings": "Sazkariyên ajimêr", + "terms": "Mercên bikaranînê", + "privacy": "Polîtikaya nihêniyê" + }, + "spicy_zone": { + "title": "Devera germ", + "clear": "Pêşbîra medyayê pak bike", + "signout": "Derkeve" + } + }, + "footer": { + "mastodon_description": "Mastodon nermalava çavkaniya vekirî ye. Tu dikarî pirsgirêkan li ser GitHub-ê ragihînî di %s (%s) de" + }, + "keyboard": { + "close_settings_window": "Sazkariyên çarçoveyê bigire" + } + }, + "report": { + "title": "%s ragihîne", + "step1": "Gav 1 ji 2", + "step2": "Gav 2 ji 2", + "content1": "Şandiyên din hene ku tu dixwazî tevlî ragihandinê bikî?", + "content2": "Derbarê vê ragihandinê de tiştek heye ku divê çavdêr bizanin?", + "send": "Ragihandinê bişîne", + "skip_to_send": "Bêyî şirove bişîne", + "text_placeholder": "Şiroveyên daxwazkirê binivîsine an jî pê ve bike" + }, + "preview": { + "keyboard": { + "close_preview": "Pêşdîtin bigire", + "show_next": "A pêş nîşan bide", + "show_previous": "A paş nîşan bide" + } + }, + "account_list": { + "tab_bar_hint": "Profîla hilbijartî ya niha: %s. Du caran bitikîne û paşê dest bide ser da ku guhêrbara ajimêr were nîşandan", + "dismiss_account_switcher": "Guherkera ajimêrê paş guh bike", + "add_account": "Ajimêr tevlî bike" + }, + "wizard": { + "new_in_mastodon": "Nû di Mastodon de", + "multiple_account_switch_intro_description": "Dest bide ser bişkoja profîlê da ku di navbera gelek ajimêrann de biguherînî.", + "accessibility_hint": "Du caran bitikîne da ku çarçoveyahilpekok ji holê rakî" + } + } +} \ No newline at end of file diff --git a/Localization/StringsConvertor/input/kmr_TR/ios-infoPlist.json b/Localization/StringsConvertor/input/kmr_TR/ios-infoPlist.json new file mode 100644 index 000000000..cdb286c00 --- /dev/null +++ b/Localization/StringsConvertor/input/kmr_TR/ios-infoPlist.json @@ -0,0 +1,6 @@ +{ + "NSCameraUsageDescription": "Bo kişandina wêneyê ji bo rewşa şandiyan tê bikaranîn", + "NSPhotoLibraryAddUsageDescription": "Ji bo tomarkirina wêneyê di pirtûkxaneya wêneyan de tê bikaranîn", + "NewPostShortcutItemTitle": "Şandiya nû", + "SearchShortcutItemTitle": "Bigere" +} diff --git a/Localization/StringsConvertor/input/ko_KR/Localizable.stringsdict b/Localization/StringsConvertor/input/ko_KR/Localizable.stringsdict index 4b19bab17..7c990671b 100644 --- a/Localization/StringsConvertor/input/ko_KR/Localizable.stringsdict +++ b/Localization/StringsConvertor/input/ko_KR/Localizable.stringsdict @@ -2,6 +2,20 @@ + a11y.plural.count.unread.notification + + NSStringLocalizedFormatKey + %#@notification_count_unread_notification@ + notification_count_unread_notification + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + other + %ld unread notification + + a11y.plural.count.input_limit_exceeds NSStringLocalizedFormatKey diff --git a/Localization/StringsConvertor/input/ko_KR/app.json b/Localization/StringsConvertor/input/ko_KR/app.json index 4cc299238..571b14659 100644 --- a/Localization/StringsConvertor/input/ko_KR/app.json +++ b/Localization/StringsConvertor/input/ko_KR/app.json @@ -67,6 +67,7 @@ "done": "완료", "confirm": "확인", "continue": "계속", + "compose": "Compose", "cancel": "취소", "discard": "버리기", "try_again": "다시 시도", @@ -413,6 +414,12 @@ } } }, + "follower": { + "footer": "Followers from other servers are not displayed." + }, + "following": { + "footer": "Follows from other servers are not displayed." + }, "search": { "title": "검색", "search_bar": { @@ -534,6 +541,16 @@ "show_next": "다음 보기", "show_previous": "이전 보기" } + }, + "account_list": { + "tab_bar_hint": "Current selected profile: %s. Double tap then hold to show account switcher", + "dismiss_account_switcher": "Dismiss Account Switcher", + "add_account": "Add Account" + }, + "wizard": { + "new_in_mastodon": "New in Mastodon", + "multiple_account_switch_intro_description": "Switch between multiple accounts by holding the profile button.", + "accessibility_hint": "Double tap to dismiss this wizard" } } } \ No newline at end of file diff --git a/Localization/StringsConvertor/input/nl_NL/Localizable.stringsdict b/Localization/StringsConvertor/input/nl_NL/Localizable.stringsdict index 1726606b4..8b6ab05ca 100644 --- a/Localization/StringsConvertor/input/nl_NL/Localizable.stringsdict +++ b/Localization/StringsConvertor/input/nl_NL/Localizable.stringsdict @@ -2,6 +2,22 @@ + a11y.plural.count.unread.notification + + NSStringLocalizedFormatKey + %#@notification_count_unread_notification@ + notification_count_unread_notification + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 unread notification + other + %ld unread notification + + a11y.plural.count.input_limit_exceeds NSStringLocalizedFormatKey diff --git a/Localization/StringsConvertor/input/nl_NL/app.json b/Localization/StringsConvertor/input/nl_NL/app.json index df20cab67..d8ee1e574 100644 --- a/Localization/StringsConvertor/input/nl_NL/app.json +++ b/Localization/StringsConvertor/input/nl_NL/app.json @@ -67,6 +67,7 @@ "done": "Klaar", "confirm": "Bevestigen", "continue": "Doorgaan", + "compose": "Compose", "cancel": "Annuleren", "discard": "Weggooien", "try_again": "Probeer Opnieuw", @@ -413,6 +414,12 @@ } } }, + "follower": { + "footer": "Followers from other servers are not displayed." + }, + "following": { + "footer": "Follows from other servers are not displayed." + }, "search": { "title": "Zoeken", "search_bar": { @@ -534,6 +541,16 @@ "show_next": "Volgende", "show_previous": "Vorige" } + }, + "account_list": { + "tab_bar_hint": "Current selected profile: %s. Double tap then hold to show account switcher", + "dismiss_account_switcher": "Dismiss Account Switcher", + "add_account": "Add Account" + }, + "wizard": { + "new_in_mastodon": "New in Mastodon", + "multiple_account_switch_intro_description": "Switch between multiple accounts by holding the profile button.", + "accessibility_hint": "Double tap to dismiss this wizard" } } } \ No newline at end of file diff --git a/Localization/StringsConvertor/input/pt_BR/Localizable.stringsdict b/Localization/StringsConvertor/input/pt_BR/Localizable.stringsdict index c7c84d074..730e2902a 100644 --- a/Localization/StringsConvertor/input/pt_BR/Localizable.stringsdict +++ b/Localization/StringsConvertor/input/pt_BR/Localizable.stringsdict @@ -2,6 +2,22 @@ + a11y.plural.count.unread.notification + + NSStringLocalizedFormatKey + %#@notification_count_unread_notification@ + notification_count_unread_notification + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 unread notification + other + %ld unread notification + + a11y.plural.count.input_limit_exceeds NSStringLocalizedFormatKey diff --git a/Localization/StringsConvertor/input/pt_BR/app.json b/Localization/StringsConvertor/input/pt_BR/app.json index b60ade9c1..5c01ae7e0 100644 --- a/Localization/StringsConvertor/input/pt_BR/app.json +++ b/Localization/StringsConvertor/input/pt_BR/app.json @@ -67,6 +67,7 @@ "done": "Done", "confirm": "Confirm", "continue": "Continue", + "compose": "Compose", "cancel": "Cancel", "discard": "Discard", "try_again": "Try Again", @@ -413,6 +414,12 @@ } } }, + "follower": { + "footer": "Followers from other servers are not displayed." + }, + "following": { + "footer": "Follows from other servers are not displayed." + }, "search": { "title": "Search", "search_bar": { @@ -534,6 +541,16 @@ "show_next": "Show Next", "show_previous": "Show Previous" } + }, + "account_list": { + "tab_bar_hint": "Current selected profile: %s. Double tap then hold to show account switcher", + "dismiss_account_switcher": "Dismiss Account Switcher", + "add_account": "Add Account" + }, + "wizard": { + "new_in_mastodon": "New in Mastodon", + "multiple_account_switch_intro_description": "Switch between multiple accounts by holding the profile button.", + "accessibility_hint": "Double tap to dismiss this wizard" } } } \ No newline at end of file diff --git a/Localization/StringsConvertor/input/pt_PT/Localizable.stringsdict b/Localization/StringsConvertor/input/pt_PT/Localizable.stringsdict index c7c84d074..730e2902a 100644 --- a/Localization/StringsConvertor/input/pt_PT/Localizable.stringsdict +++ b/Localization/StringsConvertor/input/pt_PT/Localizable.stringsdict @@ -2,6 +2,22 @@ + a11y.plural.count.unread.notification + + NSStringLocalizedFormatKey + %#@notification_count_unread_notification@ + notification_count_unread_notification + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 unread notification + other + %ld unread notification + + a11y.plural.count.input_limit_exceeds NSStringLocalizedFormatKey diff --git a/Localization/StringsConvertor/input/pt_PT/app.json b/Localization/StringsConvertor/input/pt_PT/app.json index b60ade9c1..5c01ae7e0 100644 --- a/Localization/StringsConvertor/input/pt_PT/app.json +++ b/Localization/StringsConvertor/input/pt_PT/app.json @@ -67,6 +67,7 @@ "done": "Done", "confirm": "Confirm", "continue": "Continue", + "compose": "Compose", "cancel": "Cancel", "discard": "Discard", "try_again": "Try Again", @@ -413,6 +414,12 @@ } } }, + "follower": { + "footer": "Followers from other servers are not displayed." + }, + "following": { + "footer": "Follows from other servers are not displayed." + }, "search": { "title": "Search", "search_bar": { @@ -534,6 +541,16 @@ "show_next": "Show Next", "show_previous": "Show Previous" } + }, + "account_list": { + "tab_bar_hint": "Current selected profile: %s. Double tap then hold to show account switcher", + "dismiss_account_switcher": "Dismiss Account Switcher", + "add_account": "Add Account" + }, + "wizard": { + "new_in_mastodon": "New in Mastodon", + "multiple_account_switch_intro_description": "Switch between multiple accounts by holding the profile button.", + "accessibility_hint": "Double tap to dismiss this wizard" } } } \ No newline at end of file diff --git a/Localization/StringsConvertor/input/ro_RO/Localizable.stringsdict b/Localization/StringsConvertor/input/ro_RO/Localizable.stringsdict index f623187e7..8cda4bbd7 100644 --- a/Localization/StringsConvertor/input/ro_RO/Localizable.stringsdict +++ b/Localization/StringsConvertor/input/ro_RO/Localizable.stringsdict @@ -2,6 +2,24 @@ + a11y.plural.count.unread.notification + + NSStringLocalizedFormatKey + %#@notification_count_unread_notification@ + notification_count_unread_notification + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 unread notification + few + %ld unread notification + other + %ld unread notification + + a11y.plural.count.input_limit_exceeds NSStringLocalizedFormatKey diff --git a/Localization/StringsConvertor/input/ro_RO/app.json b/Localization/StringsConvertor/input/ro_RO/app.json index f4f7a91ec..3927247ee 100644 --- a/Localization/StringsConvertor/input/ro_RO/app.json +++ b/Localization/StringsConvertor/input/ro_RO/app.json @@ -67,6 +67,7 @@ "done": "Done", "confirm": "Confirm", "continue": "Continue", + "compose": "Compose", "cancel": "Cancel", "discard": "Discard", "try_again": "Try Again", @@ -413,6 +414,12 @@ } } }, + "follower": { + "footer": "Followers from other servers are not displayed." + }, + "following": { + "footer": "Follows from other servers are not displayed." + }, "search": { "title": "Search", "search_bar": { @@ -534,6 +541,16 @@ "show_next": "Show Next", "show_previous": "Show Previous" } + }, + "account_list": { + "tab_bar_hint": "Current selected profile: %s. Double tap then hold to show account switcher", + "dismiss_account_switcher": "Dismiss Account Switcher", + "add_account": "Add Account" + }, + "wizard": { + "new_in_mastodon": "New in Mastodon", + "multiple_account_switch_intro_description": "Switch between multiple accounts by holding the profile button.", + "accessibility_hint": "Double tap to dismiss this wizard" } } } \ No newline at end of file diff --git a/Localization/StringsConvertor/input/ru_RU/Localizable.stringsdict b/Localization/StringsConvertor/input/ru_RU/Localizable.stringsdict index 1a9a44a0f..96afce4ed 100644 --- a/Localization/StringsConvertor/input/ru_RU/Localizable.stringsdict +++ b/Localization/StringsConvertor/input/ru_RU/Localizable.stringsdict @@ -2,10 +2,30 @@ + a11y.plural.count.unread.notification + + NSStringLocalizedFormatKey + %#@notification_count_unread_notification@ + notification_count_unread_notification + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 unread notification + few + %ld unread notification + many + %ld unread notification + other + %ld unread notification + + a11y.plural.count.input_limit_exceeds NSStringLocalizedFormatKey - Input limit exceeds %#@character_count@ + Лимит превышен на %#@character_count@ character_count NSStringFormatSpecTypeKey @@ -13,19 +33,19 @@ NSStringFormatValueTypeKey ld one - 1 character + %ld символ few - %ld characters + %ld символа many - %ld characters + %ld символов other - %ld characters + %ld символа a11y.plural.count.input_limit_remains NSStringLocalizedFormatKey - Input limit remains %#@character_count@ + %#@character_count@ character_count NSStringFormatSpecTypeKey @@ -33,13 +53,13 @@ NSStringFormatValueTypeKey ld one - 1 character + %ld символ остался few - %ld characters + %ld символа осталось many - %ld characters + %ld символов осталось other - %ld characters + %ld символа осталось plural.count.metric_formatted.post @@ -53,13 +73,13 @@ NSStringFormatValueTypeKey ld one - post + пост few - posts + поста many - posts + постов other - posts + поста plural.count.post @@ -133,13 +153,13 @@ NSStringFormatValueTypeKey ld one - 1 vote + %ld голос few - %ld votes + %ld голоса many - %ld votes + %ld голосов other - %ld votes + %ld голоса plural.count.voter diff --git a/Localization/StringsConvertor/input/ru_RU/app.json b/Localization/StringsConvertor/input/ru_RU/app.json index f357a60b4..c1ad3ee49 100644 --- a/Localization/StringsConvertor/input/ru_RU/app.json +++ b/Localization/StringsConvertor/input/ru_RU/app.json @@ -67,6 +67,7 @@ "done": "Готово", "confirm": "Подтвердить", "continue": "Продолжить", + "compose": "Compose", "cancel": "Отмена", "discard": "Отмена", "try_again": "Попробовать снова", @@ -413,6 +414,12 @@ } } }, + "follower": { + "footer": "Followers from other servers are not displayed." + }, + "following": { + "footer": "Follows from other servers are not displayed." + }, "search": { "title": "Поиск", "search_bar": { @@ -534,6 +541,16 @@ "show_next": "Следующее изображение", "show_previous": "Предыдущее изображение" } + }, + "account_list": { + "tab_bar_hint": "Current selected profile: %s. Double tap then hold to show account switcher", + "dismiss_account_switcher": "Dismiss Account Switcher", + "add_account": "Add Account" + }, + "wizard": { + "new_in_mastodon": "New in Mastodon", + "multiple_account_switch_intro_description": "Switch between multiple accounts by holding the profile button.", + "accessibility_hint": "Double tap to dismiss this wizard" } } } \ No newline at end of file diff --git a/Localization/StringsConvertor/input/ru_RU/ios-infoPlist.json b/Localization/StringsConvertor/input/ru_RU/ios-infoPlist.json index c6db73de0..3dabe4322 100644 --- a/Localization/StringsConvertor/input/ru_RU/ios-infoPlist.json +++ b/Localization/StringsConvertor/input/ru_RU/ios-infoPlist.json @@ -2,5 +2,5 @@ "NSCameraUsageDescription": "Used to take photo for post status", "NSPhotoLibraryAddUsageDescription": "Used to save photo into the Photo Library", "NewPostShortcutItemTitle": "New Post", - "SearchShortcutItemTitle": "Search" + "SearchShortcutItemTitle": "Поиск" } diff --git a/Localization/StringsConvertor/input/sv_FI/Localizable.stringsdict b/Localization/StringsConvertor/input/sv_FI/Localizable.stringsdict index 30533a5eb..65316e3d0 100644 --- a/Localization/StringsConvertor/input/sv_FI/Localizable.stringsdict +++ b/Localization/StringsConvertor/input/sv_FI/Localizable.stringsdict @@ -2,6 +2,22 @@ + a11y.plural.count.unread.notification + + NSStringLocalizedFormatKey + %#@notification_count_unread_notification@ + notification_count_unread_notification + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 unread notification + other + %ld unread notification + + a11y.plural.count.input_limit_exceeds NSStringLocalizedFormatKey diff --git a/Localization/StringsConvertor/input/sv_FI/app.json b/Localization/StringsConvertor/input/sv_FI/app.json index b7eb3b167..7acf48755 100644 --- a/Localization/StringsConvertor/input/sv_FI/app.json +++ b/Localization/StringsConvertor/input/sv_FI/app.json @@ -67,6 +67,7 @@ "done": "Done", "confirm": "Confirm", "continue": "Fortsätt", + "compose": "Compose", "cancel": "Avbryt", "discard": "Discard", "try_again": "Försök igen", @@ -413,6 +414,12 @@ } } }, + "follower": { + "footer": "Followers from other servers are not displayed." + }, + "following": { + "footer": "Follows from other servers are not displayed." + }, "search": { "title": "Search", "search_bar": { @@ -454,12 +461,12 @@ "Everything": "Everything", "Mentions": "Mentions" }, - "user_followed_you": "%s followed you", + "user_followed_you": "%s följde dig", "user_favorited your post": "%s favorited your post", "user_reblogged_your_post": "%s reblogged your post", - "user_mentioned_you": "%s mentioned you", - "user_requested_to_follow_you": "%s requested to follow you", - "user_your_poll_has_ended": "%s Your poll has ended", + "user_mentioned_you": "%s nämnde dig", + "user_requested_to_follow_you": "%s har begärt att följa dig", + "user_your_poll_has_ended": "%s Omröstningen har avslutats", "keyobard": { "show_everything": "Show Everything", "show_mentions": "Show Mentions" @@ -534,6 +541,16 @@ "show_next": "Show Next", "show_previous": "Show Previous" } + }, + "account_list": { + "tab_bar_hint": "Current selected profile: %s. Double tap then hold to show account switcher", + "dismiss_account_switcher": "Dismiss Account Switcher", + "add_account": "Lägg till konto" + }, + "wizard": { + "new_in_mastodon": "New in Mastodon", + "multiple_account_switch_intro_description": "Switch between multiple accounts by holding the profile button.", + "accessibility_hint": "Double tap to dismiss this wizard" } } } \ No newline at end of file diff --git a/Localization/StringsConvertor/input/sv_SE/Localizable.stringsdict b/Localization/StringsConvertor/input/sv_SE/Localizable.stringsdict index 30533a5eb..65316e3d0 100644 --- a/Localization/StringsConvertor/input/sv_SE/Localizable.stringsdict +++ b/Localization/StringsConvertor/input/sv_SE/Localizable.stringsdict @@ -2,6 +2,22 @@ + a11y.plural.count.unread.notification + + NSStringLocalizedFormatKey + %#@notification_count_unread_notification@ + notification_count_unread_notification + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 unread notification + other + %ld unread notification + + a11y.plural.count.input_limit_exceeds NSStringLocalizedFormatKey diff --git a/Localization/StringsConvertor/input/sv_SE/app.json b/Localization/StringsConvertor/input/sv_SE/app.json index 978d6719c..7acf48755 100644 --- a/Localization/StringsConvertor/input/sv_SE/app.json +++ b/Localization/StringsConvertor/input/sv_SE/app.json @@ -67,6 +67,7 @@ "done": "Done", "confirm": "Confirm", "continue": "Fortsätt", + "compose": "Compose", "cancel": "Avbryt", "discard": "Discard", "try_again": "Försök igen", @@ -413,6 +414,12 @@ } } }, + "follower": { + "footer": "Followers from other servers are not displayed." + }, + "following": { + "footer": "Follows from other servers are not displayed." + }, "search": { "title": "Search", "search_bar": { @@ -534,6 +541,16 @@ "show_next": "Show Next", "show_previous": "Show Previous" } + }, + "account_list": { + "tab_bar_hint": "Current selected profile: %s. Double tap then hold to show account switcher", + "dismiss_account_switcher": "Dismiss Account Switcher", + "add_account": "Lägg till konto" + }, + "wizard": { + "new_in_mastodon": "New in Mastodon", + "multiple_account_switch_intro_description": "Switch between multiple accounts by holding the profile button.", + "accessibility_hint": "Double tap to dismiss this wizard" } } } \ No newline at end of file diff --git a/Localization/StringsConvertor/input/th_TH/Localizable.stringsdict b/Localization/StringsConvertor/input/th_TH/Localizable.stringsdict index dc114db41..8971821f6 100644 --- a/Localization/StringsConvertor/input/th_TH/Localizable.stringsdict +++ b/Localization/StringsConvertor/input/th_TH/Localizable.stringsdict @@ -2,6 +2,20 @@ + a11y.plural.count.unread.notification + + NSStringLocalizedFormatKey + %#@notification_count_unread_notification@ + notification_count_unread_notification + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + other + %ld การแจ้งเตือนที่ยังไม่ได้อ่าน + + a11y.plural.count.input_limit_exceeds NSStringLocalizedFormatKey diff --git a/Localization/StringsConvertor/input/th_TH/app.json b/Localization/StringsConvertor/input/th_TH/app.json index 318d05391..7852b5d01 100644 --- a/Localization/StringsConvertor/input/th_TH/app.json +++ b/Localization/StringsConvertor/input/th_TH/app.json @@ -67,6 +67,7 @@ "done": "เสร็จสิ้น", "confirm": "ยืนยัน", "continue": "ดำเนินการต่อ", + "compose": "เขียน", "cancel": "ยกเลิก", "discard": "ละทิ้ง", "try_again": "ลองอีกครั้ง", @@ -179,10 +180,10 @@ }, "header": { "no_status_found": "ไม่พบโพสต์", - "blocking_warning": "You can’t view this user's profile\nuntil you unblock them.\nYour profile looks like this to them.", - "user_blocking_warning": "You can’t view %s’s profile\nuntil you unblock them.\nYour profile looks like this to them.", - "blocked_warning": "You can’t view this user’s profile\nuntil they unblock you.", - "user_blocked_warning": "You can’t view %s’s profile\nuntil they unblock you.", + "blocking_warning": "คุณไม่สามารถดูโปรไฟล์ของผู้ใช้นี้\nจนกว่าคุณจะเลิกปิดกั้นผู้ใช้นี้\nผู้ใช้นี้เห็นโปรไฟล์ของคุณเหมือนกับที่คุณเห็น", + "user_blocking_warning": "คุณไม่สามารถดูโปรไฟล์ของ %s\nจนกว่าคุณจะเลิกปิดกั้นผู้ใช้นี้\nผู้ใช้นี้เห็นโปรไฟล์ของคุณเหมือนกับที่คุณเห็น", + "blocked_warning": "คุณไม่สามารถดูโปรไฟล์ของผู้ใช้นี้\nจนกว่าผู้ใช้นี้จะเลิกปิดกั้นคุณ", + "user_blocked_warning": "คุณไม่สามารถดูโปรไฟล์ของ %s\nจนกว่าผู้ใช้นี้จะเลิกปิดกั้นคุณ", "suspended_warning": "ผู้ใช้นี้ถูกระงับการใช้งาน", "user_suspended_warning": "บัญชีของ %s ถูกระงับการใช้งาน" } @@ -210,7 +211,7 @@ "regional": "ภูมิภาค", "art": "ศิลปะ", "music": "ดนตรี", - "tech": "tech" + "tech": "เทคโนโลยี" }, "see_less": "ดูน้อยลง", "see_more": "ดูเพิ่มเติม" @@ -285,7 +286,7 @@ "server_rules": { "title": "กฎพื้นฐานบางประการ", "subtitle": "กฎเหล่านี้ถูกตั้งโดยผู้ดูแลของ %s", - "prompt": "By continuing, you’re subject to the terms of service and privacy policy for %s.", + "prompt": "เมื่อคุณดำเนินการต่อ คุณอยู่ภายใต้เงื่อนไขการให้บริการและนโยบายความเป็นส่วนตัวสำหรับ %s", "terms_of_service": "เงื่อนไขการให้บริการ", "privacy_policy": "นโยบายความเป็นส่วนตัว", "button": { @@ -413,6 +414,12 @@ } } }, + "follower": { + "footer": "ไม่ได้แสดงผู้ติดตามจากเซิร์ฟเวอร์อื่น ๆ" + }, + "following": { + "footer": "ไม่ได้แสดงการติดตามจากเซิร์ฟเวอร์อื่น ๆ" + }, "search": { "title": "ค้นหา", "search_bar": { @@ -534,6 +541,16 @@ "show_next": "แสดงถัดไป", "show_previous": "แสดงก่อนหน้า" } + }, + "account_list": { + "tab_bar_hint": "โปรไฟล์ที่เลือกในปัจจุบัน: %s แตะสองครั้งแล้วกดค้างไว้เพื่อแสดงตัวสลับบัญชี", + "dismiss_account_switcher": "ปิดตัวสลับบัญชี", + "add_account": "เพิ่มบัญชี" + }, + "wizard": { + "new_in_mastodon": "มาใหม่ใน Mastodon", + "multiple_account_switch_intro_description": "สลับระหว่างหลายบัญชีโดยกดปุ่มโปรไฟล์ค้างไว้", + "accessibility_hint": "แตะสองครั้งเพื่อปิดตัวช่วยสร้างนี้" } } } \ No newline at end of file diff --git a/Localization/StringsConvertor/input/zh_CN/Localizable.stringsdict b/Localization/StringsConvertor/input/zh_CN/Localizable.stringsdict index c28637620..12b8b5f6e 100644 --- a/Localization/StringsConvertor/input/zh_CN/Localizable.stringsdict +++ b/Localization/StringsConvertor/input/zh_CN/Localizable.stringsdict @@ -2,6 +2,20 @@ + a11y.plural.count.unread.notification + + NSStringLocalizedFormatKey + %#@notification_count_unread_notification@ + notification_count_unread_notification + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + other + %ld 条未读通知 + + a11y.plural.count.input_limit_exceeds NSStringLocalizedFormatKey diff --git a/Localization/StringsConvertor/input/zh_CN/app.json b/Localization/StringsConvertor/input/zh_CN/app.json index 25a8f13a7..905afdd86 100644 --- a/Localization/StringsConvertor/input/zh_CN/app.json +++ b/Localization/StringsConvertor/input/zh_CN/app.json @@ -67,6 +67,7 @@ "done": "完成", "confirm": "确认", "continue": "继续", + "compose": "撰写", "cancel": "取消", "discard": "放弃", "try_again": "再试一次", @@ -413,6 +414,12 @@ } } }, + "follower": { + "footer": "不会显示来自其它服务器的关注者" + }, + "following": { + "footer": "不会显示来自其它服务器的关注" + }, "search": { "title": "搜索", "search_bar": { @@ -534,6 +541,16 @@ "show_next": "显示下一个", "show_previous": "显示前一个" } + }, + "account_list": { + "tab_bar_hint": "当前账户:%s。 双击并按住来打开账户切换页面", + "dismiss_account_switcher": "关闭账户切换页面", + "add_account": "添加账户" + }, + "wizard": { + "new_in_mastodon": "新功能", + "multiple_account_switch_intro_description": "按住个人资料标签按钮,即可在多个账户之间进行切换。", + "accessibility_hint": "双击关闭此向导" } } } \ No newline at end of file diff --git a/Localization/StringsConvertor/input/zh_TW/Localizable.stringsdict b/Localization/StringsConvertor/input/zh_TW/Localizable.stringsdict index bebde18a5..dafab129d 100644 --- a/Localization/StringsConvertor/input/zh_TW/Localizable.stringsdict +++ b/Localization/StringsConvertor/input/zh_TW/Localizable.stringsdict @@ -2,6 +2,20 @@ + a11y.plural.count.unread.notification + + NSStringLocalizedFormatKey + %#@notification_count_unread_notification@ + notification_count_unread_notification + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + other + %ld unread notification + + a11y.plural.count.input_limit_exceeds NSStringLocalizedFormatKey diff --git a/Localization/StringsConvertor/input/zh_TW/app.json b/Localization/StringsConvertor/input/zh_TW/app.json index b60ade9c1..5c01ae7e0 100644 --- a/Localization/StringsConvertor/input/zh_TW/app.json +++ b/Localization/StringsConvertor/input/zh_TW/app.json @@ -67,6 +67,7 @@ "done": "Done", "confirm": "Confirm", "continue": "Continue", + "compose": "Compose", "cancel": "Cancel", "discard": "Discard", "try_again": "Try Again", @@ -413,6 +414,12 @@ } } }, + "follower": { + "footer": "Followers from other servers are not displayed." + }, + "following": { + "footer": "Follows from other servers are not displayed." + }, "search": { "title": "Search", "search_bar": { @@ -534,6 +541,16 @@ "show_next": "Show Next", "show_previous": "Show Previous" } + }, + "account_list": { + "tab_bar_hint": "Current selected profile: %s. Double tap then hold to show account switcher", + "dismiss_account_switcher": "Dismiss Account Switcher", + "add_account": "Add Account" + }, + "wizard": { + "new_in_mastodon": "New in Mastodon", + "multiple_account_switch_intro_description": "Switch between multiple accounts by holding the profile button.", + "accessibility_hint": "Double tap to dismiss this wizard" } } } \ No newline at end of file diff --git a/Localization/app.json b/Localization/app.json index b60ade9c1..6d3b2fcc2 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -67,6 +67,7 @@ "done": "Done", "confirm": "Confirm", "continue": "Continue", + "compose": "Compose", "cancel": "Cancel", "discard": "Discard", "try_again": "Try Again", @@ -81,6 +82,7 @@ "share_user": "Share %s", "share_post": "Share Post", "open_in_safari": "Open in Safari", + "open_in_browser": "Open in Browser", "find_people": "Find people to follow", "manually_search": "Manually search instead", "skip": "Skip", @@ -413,6 +415,12 @@ } } }, + "follower": { + "footer": "Followers from other servers are not displayed." + }, + "following": { + "footer": "Follows from other servers are not displayed." + }, "search": { "title": "Search", "search_bar": { @@ -534,6 +542,16 @@ "show_next": "Show Next", "show_previous": "Show Previous" } + }, + "account_list": { + "tab_bar_hint": "Current selected profile: %s. Double tap then hold to show account switcher", + "dismiss_account_switcher": "Dismiss Account Switcher", + "add_account": "Add Account" + }, + "wizard": { + "new_in_mastodon": "New in Mastodon", + "multiple_account_switch_intro_description": "Switch between multiple accounts by holding the profile button.", + "accessibility_hint": "Double tap to dismiss this wizard" } } } \ No newline at end of file diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index c935acb21..6b7644e3e 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -187,6 +187,8 @@ DB029E95266A20430062874E /* MastodonAuthenticationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB029E94266A20430062874E /* MastodonAuthenticationController.swift */; }; DB02CDAB26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB02CDAA26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift */; }; DB02CDBF2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB02CDBE2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift */; }; + DB03A793272A7E5700EE37C5 /* SidebarListHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB03A792272A7E5700EE37C5 /* SidebarListHeaderView.swift */; }; + DB03A795272A981400EE37C5 /* ContentSplitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB03A794272A981400EE37C5 /* ContentSplitViewController.swift */; }; DB03F7F32689AEA3007B274C /* ComposeRepliedToStatusContentTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB03F7F22689AEA3007B274C /* ComposeRepliedToStatusContentTableViewCell.swift */; }; DB03F7F52689B782007B274C /* ComposeTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB03F7F42689B782007B274C /* ComposeTableView.swift */; }; DB040ED126538E3D00BEE9D8 /* Trie.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB040ED026538E3C00BEE9D8 /* Trie.swift */; }; @@ -199,9 +201,12 @@ DB0C947226A7D2D70088FB11 /* AvatarButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0C947126A7D2D70088FB11 /* AvatarButton.swift */; }; DB0C947726A7FE840088FB11 /* NotificationAvatarButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0C947626A7FE840088FB11 /* NotificationAvatarButton.swift */; }; DB0E91EA26A9675100BD2ACC /* MetaLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0E91E926A9675100BD2ACC /* MetaLabel.swift */; }; + DB0EF72B26FDB1D200347686 /* SidebarListCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0EF72A26FDB1D200347686 /* SidebarListCollectionViewCell.swift */; }; + DB0EF72E26FDB24F00347686 /* SidebarListContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0EF72D26FDB24F00347686 /* SidebarListContentView.swift */; }; DB0F8150264D1E2500F2A12B /* PickServerLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0F814F264D1E2500F2A12B /* PickServerLoaderTableViewCell.swift */; }; DB118A8225E4B6E600FAB162 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DB118A8125E4B6E600FAB162 /* Preview Assets.xcassets */; }; DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1D186B25EF5BA7003F1F23 /* PollTableView.swift */; }; + DB1D61CF26F1B33600DA8662 /* WelcomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1D61CE26F1B33600DA8662 /* WelcomeViewModel.swift */; }; DB1D842C26551A1C000346B3 /* StatusProvider+StatusTableViewKeyCommandNavigateable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1D842B26551A1C000346B3 /* StatusProvider+StatusTableViewKeyCommandNavigateable.swift */; }; DB1D842E26552C4D000346B3 /* StatusTableViewControllerNavigateable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1D842D26552C4D000346B3 /* StatusTableViewControllerNavigateable.swift */; }; DB1D843026566512000346B3 /* KeyboardPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1D842F26566512000346B3 /* KeyboardPreference.swift */; }; @@ -262,6 +267,10 @@ DB482A45261335BA008AE74C /* UserTimelineViewController+Provider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB482A44261335BA008AE74C /* UserTimelineViewController+Provider.swift */; }; DB482A4B261340A7008AE74C /* APIService+UserTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB482A4A261340A7008AE74C /* APIService+UserTimeline.swift */; }; DB4924E226312AB200E9DB22 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4924E126312AB200E9DB22 /* NotificationService.swift */; }; + DB4932B126F1FB5300EF46D4 /* WizardCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4932B026F1FB5300EF46D4 /* WizardCardView.swift */; }; + DB4932B326F2054200EF46D4 /* CircleAvatarButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4932B226F2054200EF46D4 /* CircleAvatarButton.swift */; }; + DB4932B726F30F0700EF46D4 /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20223826146553000C64BF /* Array.swift */; }; + DB4932B926F31AD300EF46D4 /* BadgeButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4932B826F31AD300EF46D4 /* BadgeButton.swift */; }; DB49A61425FF2C5600B98345 /* EmojiService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A61325FF2C5600B98345 /* EmojiService.swift */; }; DB49A61F25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A61E25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift */; }; DB49A62525FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A62425FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift */; }; @@ -287,6 +296,11 @@ DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */; }; DB59F10E25EF724F001F1DAB /* APIService+Poll.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */; }; DB59F11825EFA35B001F1DAB /* StripProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F11725EFA35B001F1DAB /* StripProgressView.swift */; }; + DB5B7295273112B100081888 /* FollowingListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5B7294273112B100081888 /* FollowingListViewController.swift */; }; + DB5B7298273112C800081888 /* FollowingListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5B7297273112C800081888 /* FollowingListViewModel.swift */; }; + DB5B729A2731137900081888 /* FollowingListViewController+Provider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5B72992731137900081888 /* FollowingListViewController+Provider.swift */; }; + DB5B729C273113C200081888 /* FollowingListViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5B729B273113C200081888 /* FollowingListViewModel+Diffable.swift */; }; + DB5B729E273113F300081888 /* FollowingListViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5B729D273113F300081888 /* FollowingListViewModel+State.swift */; }; DB6180DD263918E30018D199 /* MediaPreviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6180DC263918E30018D199 /* MediaPreviewViewController.swift */; }; DB6180E02639194B0018D199 /* MediaPreviewPagingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6180DF2639194B0018D199 /* MediaPreviewPagingViewController.swift */; }; DB6180E326391A4C0018D199 /* ViewControllerAnimatedTransitioning.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6180E226391A4C0018D199 /* ViewControllerAnimatedTransitioning.swift */; }; @@ -301,9 +315,13 @@ DB6180F826391D660018D199 /* MediaPreviewingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6180F726391D660018D199 /* MediaPreviewingViewController.swift */; }; DB6180FA26391F2E0018D199 /* MediaPreviewViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6180F926391F2E0018D199 /* MediaPreviewViewModel.swift */; }; DB63BE7F268DD1070011D3F9 /* NotificationViewController+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB63BE7E268DD1070011D3F9 /* NotificationViewController+StatusProvider.swift */; }; + DB647C5926F1EA2700F7F82C /* WizardPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB647C5826F1EA2700F7F82C /* WizardPreference.swift */; }; DB66728C25F9F8DC00D60309 /* ComposeViewModel+DataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB66728B25F9F8DC00D60309 /* ComposeViewModel+DataSource.swift */; }; DB66729625F9F91600D60309 /* ComposeStatusSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB66729525F9F91600D60309 /* ComposeStatusSection.swift */; }; DB66729C25F9F91F00D60309 /* ComposeStatusItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB66729B25F9F91F00D60309 /* ComposeStatusItem.swift */; }; + DB67D08427312970006A36CF /* APIService+Following.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB67D08327312970006A36CF /* APIService+Following.swift */; }; + DB67D08627312E67006A36CF /* WizardViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB67D08527312E67006A36CF /* WizardViewController.swift */; }; + DB67D089273256D7006A36CF /* StoreReviewPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB67D088273256D7006A36CF /* StoreReviewPreference.swift */; }; DB68045B2636DC6A00430867 /* MastodonNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68045A2636DC6A00430867 /* MastodonNotification.swift */; }; DB6804662636DC9000430867 /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D939AB425EDD8A90076FA61 /* String.swift */; }; DB68046C2636DC9E00430867 /* MastodonNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68045A2636DC6A00430867 /* MastodonNotification.swift */; }; @@ -322,6 +340,17 @@ DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68A06225E905E000CFDF14 /* UIApplication.swift */; }; DB6B35182601FA3400DC1E11 /* MastodonAttachmentService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B35172601FA3400DC1E11 /* MastodonAttachmentService.swift */; }; DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift */; }; + DB6B74EF272FB55000C70B6E /* FollowerListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B74EE272FB55000C70B6E /* FollowerListViewController.swift */; }; + DB6B74F2272FB67600C70B6E /* FollowerListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B74F1272FB67600C70B6E /* FollowerListViewModel.swift */; }; + DB6B74F4272FBAE700C70B6E /* FollowerListViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B74F3272FBAE700C70B6E /* FollowerListViewModel+Diffable.swift */; }; + DB6B74F6272FBCDB00C70B6E /* FollowerListViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B74F5272FBCDB00C70B6E /* FollowerListViewModel+State.swift */; }; + DB6B74F8272FBFB100C70B6E /* FollowerListViewController+Provider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B74F7272FBFB100C70B6E /* FollowerListViewController+Provider.swift */; }; + DB6B74FA272FC2B500C70B6E /* APIService+Follower.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B74F9272FC2B500C70B6E /* APIService+Follower.swift */; }; + DB6B74FC272FF55800C70B6E /* UserSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B74FB272FF55800C70B6E /* UserSection.swift */; }; + DB6B74FE272FF59000C70B6E /* UserItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B74FD272FF59000C70B6E /* UserItem.swift */; }; + DB6B7500272FF73800C70B6E /* UserTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B74FF272FF73800C70B6E /* UserTableViewCell.swift */; }; + DB6B75022730060700C70B6E /* UserProviderFacade+UITableViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B75012730060700C70B6E /* UserProviderFacade+UITableViewDelegate.swift */; }; + DB6B750427300B4000C70B6E /* TimelineFooterTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B750327300B4000C70B6E /* TimelineFooterTableViewCell.swift */; }; DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */; }; DB6D1B3D2636857500ACB481 /* AppearancePreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B3C2636857500ACB481 /* AppearancePreference.swift */; }; DB6D1B44263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B43263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift */; }; @@ -338,6 +367,8 @@ DB6D9F9726367249008423CD /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F9626367249008423CD /* SettingsViewController.swift */; }; DB6F5E35264E78E7009108F4 /* AutoCompleteViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6F5E34264E78E7009108F4 /* AutoCompleteViewController.swift */; }; DB6F5E38264E994A009108F4 /* AutoCompleteTopChevronView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6F5E37264E994A009108F4 /* AutoCompleteTopChevronView.swift */; }; + DB71C7CB271D5A0300BE3819 /* LineChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71C7CA271D5A0300BE3819 /* LineChartView.swift */; }; + DB71C7CD271D7F4300BE3819 /* CurveAlgorithm.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71C7CC271D7F4300BE3819 /* CurveAlgorithm.swift */; }; DB71FD2C25F86A5100512AE1 /* AvatarStackContainerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */; }; DB71FD3625F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */; }; DB71FD3C25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD3B25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift */; }; @@ -346,12 +377,23 @@ DB71FD5225F8CCAA00512AE1 /* APIService+Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD5125F8CCAA00512AE1 /* APIService+Status.swift */; }; DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */; }; DB72602725E36A6F00235243 /* MastodonServerRulesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */; }; + DB7274F4273BB9B200577D95 /* ListBatchFetchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB7274F3273BB9B200577D95 /* ListBatchFetchViewModel.swift */; }; DB73B490261F030A002E9E9F /* SafariActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB73B48F261F030A002E9E9F /* SafariActivity.swift */; }; + DB73BF3B2711885500781945 /* UserDefaults+Notification.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB73BF3A2711885500781945 /* UserDefaults+Notification.swift */; }; + DB73BF4127118B6D00781945 /* Instance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB73BF4027118B6D00781945 /* Instance.swift */; }; + DB73BF43271192BB00781945 /* InstanceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB73BF42271192BB00781945 /* InstanceService.swift */; }; + DB73BF45271195AC00781945 /* APIService+CoreData+Instance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB73BF44271195AC00781945 /* APIService+CoreData+Instance.swift */; }; + DB73BF47271199CA00781945 /* Instance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB73BF46271199CA00781945 /* Instance.swift */; }; + DB73BF4927140BA300781945 /* UICollectionViewDiffableDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB73BF4827140BA300781945 /* UICollectionViewDiffableDataSource.swift */; }; + DB73BF4B27140C0800781945 /* UITableViewDiffableDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB73BF4A27140C0800781945 /* UITableViewDiffableDataSource.swift */; }; DB75BF1E263C1C1B00EDBF1F /* CustomScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB75BF1D263C1C1B00EDBF1F /* CustomScheduler.swift */; }; DB789A0B25F9F2950071ACA0 /* ComposeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */; }; DB789A1225F9F2CC0071ACA0 /* ComposeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */; }; DB7F48452620241000796008 /* ProfileHeaderViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB7F48442620241000796008 /* ProfileHeaderViewModel.swift */; }; DB8190C62601FF0400020C08 /* AttachmentContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */; }; + DB852D1926FAEB6B00FC9D81 /* SidebarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB852D1826FAEB6B00FC9D81 /* SidebarViewController.swift */; }; + DB852D1C26FB021500FC9D81 /* RootSplitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB852D1B26FB021500FC9D81 /* RootSplitViewController.swift */; }; + DB852D1F26FB037800FC9D81 /* SidebarViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB852D1E26FB037800FC9D81 /* SidebarViewModel.swift */; }; DB87D4452609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */; }; DB87D44B2609C11900D12C0D /* PollOptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB87D44A2609C11900D12C0D /* PollOptionView.swift */; }; DB87D4512609CF1E00D12C0D /* ComposeStatusPollOptionAppendEntryCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB87D4502609CF1E00D12C0D /* ComposeStatusPollOptionAppendEntryCollectionViewCell.swift */; }; @@ -415,6 +457,9 @@ DB9D6C3825E508BE0051B173 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C3725E508BE0051B173 /* Attachment.swift */; }; DB9D7C21269824B80054B3DF /* APIService+Filter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D7C20269824B80054B3DF /* APIService+Filter.swift */; }; DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */; }; + DB9F58EC26EF435000E7BBE9 /* AccountViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9F58EB26EF435000E7BBE9 /* AccountViewController.swift */; }; + DB9F58EF26EF491E00E7BBE9 /* AccountListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9F58EE26EF491E00E7BBE9 /* AccountListViewModel.swift */; }; + DB9F58F126EF512300E7BBE9 /* AccountListTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9F58F026EF512300E7BBE9 /* AccountListTableViewCell.swift */; }; DBA088DF26958164003EB4B2 /* UserFetchedResultsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA088DE26958164003EB4B2 /* UserFetchedResultsController.swift */; }; DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */; }; DBA1DB80268F84F80052DB59 /* NotificationType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA1DB7F268F84F80052DB59 /* NotificationType.swift */; }; @@ -422,6 +467,9 @@ DBA465952696E387002B41DB /* AppPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA465942696E387002B41DB /* AppPreference.swift */; }; DBA4B0F626C269880077136E /* Intents.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = DBA4B0F926C269880077136E /* Intents.stringsdict */; }; DBA4B0F726C269880077136E /* Intents.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = DBA4B0F926C269880077136E /* Intents.stringsdict */; }; + DBA5A52F26F07ED800CACBAA /* PanModal in Frameworks */ = {isa = PBXBuildFile; productRef = DBA5A52E26F07ED800CACBAA /* PanModal */; }; + DBA5A53126F08EF000CACBAA /* DragIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA5A53026F08EF000CACBAA /* DragIndicatorView.swift */; }; + DBA5A53526F0A36A00CACBAA /* AddAccountTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA5A53426F0A36A00CACBAA /* AddAccountTableViewCell.swift */; }; DBA5E7A3263AD0A3004598BB /* PhotoLibraryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA5E7A2263AD0A3004598BB /* PhotoLibraryService.swift */; }; DBA5E7A5263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA5E7A4263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift */; }; DBA5E7A9263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA5E7A8263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift */; }; @@ -436,7 +484,6 @@ DBAC6483267D0B21007FE9FD /* DifferenceKit in Frameworks */ = {isa = PBXBuildFile; productRef = DBAC6482267D0B21007FE9FD /* DifferenceKit */; }; DBAC6485267D0F9E007FE9FD /* StatusNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAC6484267D0F9E007FE9FD /* StatusNode.swift */; }; DBAC6488267D388B007FE9FD /* ASTableNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAC6487267D388B007FE9FD /* ASTableNode.swift */; }; - DBAC648A267DC355007FE9FD /* NSDiffableDataSourceSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAC6489267DC355007FE9FD /* NSDiffableDataSourceSnapshot.swift */; }; DBAC648F267DC84D007FE9FD /* TableNodeDiffableDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAC648E267DC84D007FE9FD /* TableNodeDiffableDataSource.swift */; }; DBAC6497267DECCB007FE9FD /* TimelineMiddleLoaderNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAC6496267DECCB007FE9FD /* TimelineMiddleLoaderNode.swift */; }; DBAC6499267DF2C4007FE9FD /* TimelineBottomLoaderNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAC6498267DF2C4007FE9FD /* TimelineBottomLoaderNode.swift */; }; @@ -554,9 +601,15 @@ DBE3CE13261D7D4200430CC6 /* StatusTableViewControllerAspect.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CE12261D7D4200430CC6 /* StatusTableViewControllerAspect.swift */; }; DBE54AC62636C89F004E7C0B /* NotificationPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE54AC52636C89F004E7C0B /* NotificationPreference.swift */; }; DBE54ACC2636C8FD004E7C0B /* NotificationPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE54AC52636C89F004E7C0B /* NotificationPreference.swift */; }; + DBF156DF2701B17600EC00B7 /* SidebarAddAccountCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF156DE2701B17600EC00B7 /* SidebarAddAccountCollectionViewCell.swift */; }; + DBF156E22702DA6900EC00B7 /* UIStatusBarManager+HandleTapAction.m in Sources */ = {isa = PBXBuildFile; fileRef = DBF156E12702DA6900EC00B7 /* UIStatusBarManager+HandleTapAction.m */; }; + DBF156E42702DB3F00EC00B7 /* HandleTapAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF156E32702DB3F00EC00B7 /* HandleTapAction.swift */; }; + DBF1572F27046F1A00EC00B7 /* SecondaryPlaceholderViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF1572E27046F1A00EC00B7 /* SecondaryPlaceholderViewController.swift */; }; DBF1D24E269DAF5D00C1C08A /* SearchDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF1D24D269DAF5D00C1C08A /* SearchDetailViewController.swift */; }; DBF1D251269DB01200C1C08A /* SearchHistoryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF1D250269DB01200C1C08A /* SearchHistoryViewController.swift */; }; DBF1D257269DBAC600C1C08A /* SearchDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF1D256269DBAC600C1C08A /* SearchDetailViewModel.swift */; }; + DBF3B73F2733EAED00E21627 /* local-codes.json in Resources */ = {isa = PBXBuildFile; fileRef = DBF3B73E2733EAED00E21627 /* local-codes.json */; }; + DBF3B7412733EB9400E21627 /* MastodonLocalCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF3B7402733EB9400E21627 /* MastodonLocalCode.swift */; }; DBF7A0FC26830C33004176A2 /* FPSIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = DBF7A0FB26830C33004176A2 /* FPSIndicator */; }; DBF8AE16263293E400C9C23C /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF8AE15263293E400C9C23C /* NotificationService.swift */; }; DBF8AE1A263293E400C9C23C /* NotificationService.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = DBF8AE13263293E400C9C23C /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; @@ -928,6 +981,8 @@ DB029E94266A20430062874E /* MastodonAuthenticationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonAuthenticationController.swift; sourceTree = ""; }; DB02CDAA26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadReplyLoaderTableViewCell.swift; sourceTree = ""; }; DB02CDBE2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptiveUserInterfaceStyleBarButtonItem.swift; sourceTree = ""; }; + DB03A792272A7E5700EE37C5 /* SidebarListHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarListHeaderView.swift; sourceTree = ""; }; + DB03A794272A981400EE37C5 /* ContentSplitViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentSplitViewController.swift; sourceTree = ""; }; DB03F7F22689AEA3007B274C /* ComposeRepliedToStatusContentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeRepliedToStatusContentTableViewCell.swift; sourceTree = ""; }; DB03F7F42689B782007B274C /* ComposeTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeTableView.swift; sourceTree = ""; }; DB040ED026538E3C00BEE9D8 /* Trie.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Trie.swift; sourceTree = ""; }; @@ -938,11 +993,14 @@ DB0C947126A7D2D70088FB11 /* AvatarButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarButton.swift; sourceTree = ""; }; DB0C947626A7FE840088FB11 /* NotificationAvatarButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationAvatarButton.swift; sourceTree = ""; }; DB0E91E926A9675100BD2ACC /* MetaLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetaLabel.swift; sourceTree = ""; }; + DB0EF72A26FDB1D200347686 /* SidebarListCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarListCollectionViewCell.swift; sourceTree = ""; }; + DB0EF72D26FDB24F00347686 /* SidebarListContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarListContentView.swift; sourceTree = ""; }; DB0F814D264CFFD300F2A12B /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = ""; }; DB0F814E264CFFD300F2A12B /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/InfoPlist.strings; sourceTree = ""; }; DB0F814F264D1E2500F2A12B /* PickServerLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerLoaderTableViewCell.swift; sourceTree = ""; }; DB118A8125E4B6E600FAB162 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; DB1D186B25EF5BA7003F1F23 /* PollTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollTableView.swift; sourceTree = ""; }; + DB1D61CE26F1B33600DA8662 /* WelcomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeViewModel.swift; sourceTree = ""; }; DB1D842B26551A1C000346B3 /* StatusProvider+StatusTableViewKeyCommandNavigateable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+StatusTableViewKeyCommandNavigateable.swift"; sourceTree = ""; }; DB1D842D26552C4D000346B3 /* StatusTableViewControllerNavigateable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableViewControllerNavigateable.swift; sourceTree = ""; }; DB1D842F26566512000346B3 /* KeyboardPreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardPreference.swift; sourceTree = ""; }; @@ -1010,6 +1068,9 @@ DB482A44261335BA008AE74C /* UserTimelineViewController+Provider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserTimelineViewController+Provider.swift"; sourceTree = ""; }; DB482A4A261340A7008AE74C /* APIService+UserTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+UserTimeline.swift"; sourceTree = ""; }; DB4924E126312AB200E9DB22 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; + DB4932B026F1FB5300EF46D4 /* WizardCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WizardCardView.swift; sourceTree = ""; }; + DB4932B226F2054200EF46D4 /* CircleAvatarButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircleAvatarButton.swift; sourceTree = ""; }; + DB4932B826F31AD300EF46D4 /* BadgeButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeButton.swift; sourceTree = ""; }; DB49A61325FF2C5600B98345 /* EmojiService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiService.swift; sourceTree = ""; }; DB49A61E25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EmojiService+CustomEmojiViewModel.swift"; sourceTree = ""; }; DB49A62425FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EmojiService+CustomEmojiViewModel+LoadState.swift"; sourceTree = ""; }; @@ -1059,6 +1120,11 @@ DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewCellHeightCacheableContainer.swift; sourceTree = ""; }; DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Poll.swift"; sourceTree = ""; }; DB59F11725EFA35B001F1DAB /* StripProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StripProgressView.swift; sourceTree = ""; }; + DB5B7294273112B100081888 /* FollowingListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowingListViewController.swift; sourceTree = ""; }; + DB5B7297273112C800081888 /* FollowingListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowingListViewModel.swift; sourceTree = ""; }; + DB5B72992731137900081888 /* FollowingListViewController+Provider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FollowingListViewController+Provider.swift"; sourceTree = ""; }; + DB5B729B273113C200081888 /* FollowingListViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FollowingListViewModel+Diffable.swift"; sourceTree = ""; }; + DB5B729D273113F300081888 /* FollowingListViewModel+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FollowingListViewModel+State.swift"; sourceTree = ""; }; DB6180DC263918E30018D199 /* MediaPreviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewViewController.swift; sourceTree = ""; }; DB6180DF2639194B0018D199 /* MediaPreviewPagingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewPagingViewController.swift; sourceTree = ""; }; DB6180E226391A4C0018D199 /* ViewControllerAnimatedTransitioning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewControllerAnimatedTransitioning.swift; sourceTree = ""; }; @@ -1073,9 +1139,13 @@ DB6180F726391D660018D199 /* MediaPreviewingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewingViewController.swift; sourceTree = ""; }; DB6180F926391F2E0018D199 /* MediaPreviewViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewViewModel.swift; sourceTree = ""; }; DB63BE7E268DD1070011D3F9 /* NotificationViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationViewController+StatusProvider.swift"; sourceTree = ""; }; + DB647C5826F1EA2700F7F82C /* WizardPreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WizardPreference.swift; sourceTree = ""; }; DB66728B25F9F8DC00D60309 /* ComposeViewModel+DataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ComposeViewModel+DataSource.swift"; sourceTree = ""; }; DB66729525F9F91600D60309 /* ComposeStatusSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusSection.swift; sourceTree = ""; }; DB66729B25F9F91F00D60309 /* ComposeStatusItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusItem.swift; sourceTree = ""; }; + DB67D08327312970006A36CF /* APIService+Following.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Following.swift"; sourceTree = ""; }; + DB67D08527312E67006A36CF /* WizardViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WizardViewController.swift; sourceTree = ""; }; + DB67D088273256D7006A36CF /* StoreReviewPreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreReviewPreference.swift; sourceTree = ""; }; DB68045A2636DC6A00430867 /* MastodonNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonNotification.swift; sourceTree = ""; }; DB68047F2637CD4C00430867 /* AppShared.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = AppShared.framework; sourceTree = BUILT_PRODUCTS_DIR; }; DB6804812637CD4C00430867 /* AppShared.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AppShared.h; sourceTree = ""; }; @@ -1090,6 +1160,17 @@ DB68A06225E905E000CFDF14 /* UIApplication.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIApplication.swift; sourceTree = ""; }; DB6B35172601FA3400DC1E11 /* MastodonAttachmentService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonAttachmentService.swift; sourceTree = ""; }; DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusAttachmentCollectionViewCell.swift; sourceTree = ""; }; + DB6B74EE272FB55000C70B6E /* FollowerListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowerListViewController.swift; sourceTree = ""; }; + DB6B74F1272FB67600C70B6E /* FollowerListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowerListViewModel.swift; sourceTree = ""; }; + DB6B74F3272FBAE700C70B6E /* FollowerListViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FollowerListViewModel+Diffable.swift"; sourceTree = ""; }; + DB6B74F5272FBCDB00C70B6E /* FollowerListViewModel+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FollowerListViewModel+State.swift"; sourceTree = ""; }; + DB6B74F7272FBFB100C70B6E /* FollowerListViewController+Provider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FollowerListViewController+Provider.swift"; sourceTree = ""; }; + DB6B74F9272FC2B500C70B6E /* APIService+Follower.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Follower.swift"; sourceTree = ""; }; + DB6B74FB272FF55800C70B6E /* UserSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSection.swift; sourceTree = ""; }; + DB6B74FD272FF59000C70B6E /* UserItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserItem.swift; sourceTree = ""; }; + DB6B74FF272FF73800C70B6E /* UserTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserTableViewCell.swift; sourceTree = ""; }; + DB6B75012730060700C70B6E /* UserProviderFacade+UITableViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserProviderFacade+UITableViewDelegate.swift"; sourceTree = ""; }; + DB6B750327300B4000C70B6E /* TimelineFooterTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineFooterTableViewCell.swift; sourceTree = ""; }; DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error.swift"; sourceTree = ""; }; DB6D1B3C2636857500ACB481 /* AppearancePreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearancePreference.swift; sourceTree = ""; }; DB6D1B43263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+API+Subscriptions+Policy.swift"; sourceTree = ""; }; @@ -1105,6 +1186,8 @@ DB6D9F9626367249008423CD /* SettingsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = ""; }; DB6F5E34264E78E7009108F4 /* AutoCompleteViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCompleteViewController.swift; sourceTree = ""; }; DB6F5E37264E994A009108F4 /* AutoCompleteTopChevronView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCompleteTopChevronView.swift; sourceTree = ""; }; + DB71C7CA271D5A0300BE3819 /* LineChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LineChartView.swift; sourceTree = ""; }; + DB71C7CC271D7F4300BE3819 /* CurveAlgorithm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurveAlgorithm.swift; sourceTree = ""; }; DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarStackContainerButton.swift; sourceTree = ""; }; DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+PersistMemo.swift"; sourceTree = ""; }; DB71FD3B25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+PersistCache.swift"; sourceTree = ""; }; @@ -1113,12 +1196,23 @@ DB71FD5125F8CCAA00512AE1 /* APIService+Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Status.swift"; sourceTree = ""; }; DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewController.swift; sourceTree = ""; }; DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewModel.swift; sourceTree = ""; }; + DB7274F3273BB9B200577D95 /* ListBatchFetchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListBatchFetchViewModel.swift; sourceTree = ""; }; DB73B48F261F030A002E9E9F /* SafariActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariActivity.swift; sourceTree = ""; }; + DB73BF3A2711885500781945 /* UserDefaults+Notification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserDefaults+Notification.swift"; sourceTree = ""; }; + DB73BF4027118B6D00781945 /* Instance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Instance.swift; sourceTree = ""; }; + DB73BF42271192BB00781945 /* InstanceService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceService.swift; sourceTree = ""; }; + DB73BF44271195AC00781945 /* APIService+CoreData+Instance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Instance.swift"; sourceTree = ""; }; + DB73BF46271199CA00781945 /* Instance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Instance.swift; sourceTree = ""; }; + DB73BF4827140BA300781945 /* UICollectionViewDiffableDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UICollectionViewDiffableDataSource.swift; sourceTree = ""; }; + DB73BF4A27140C0800781945 /* UITableViewDiffableDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITableViewDiffableDataSource.swift; sourceTree = ""; }; DB75BF1D263C1C1B00EDBF1F /* CustomScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomScheduler.swift; sourceTree = ""; }; DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewController.swift; sourceTree = ""; }; DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewModel.swift; sourceTree = ""; }; DB7F48442620241000796008 /* ProfileHeaderViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderViewModel.swift; sourceTree = ""; }; DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentContainerView.swift; sourceTree = ""; }; + DB852D1826FAEB6B00FC9D81 /* SidebarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarViewController.swift; sourceTree = ""; }; + DB852D1B26FB021500FC9D81 /* RootSplitViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootSplitViewController.swift; sourceTree = ""; }; + DB852D1E26FB037800FC9D81 /* SidebarViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarViewModel.swift; sourceTree = ""; }; DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollOptionCollectionViewCell.swift; sourceTree = ""; }; DB87D44A2609C11900D12C0D /* PollOptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionView.swift; sourceTree = ""; }; DB87D4502609CF1E00D12C0D /* ComposeStatusPollOptionAppendEntryCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollOptionAppendEntryCollectionViewCell.swift; sourceTree = ""; }; @@ -1184,6 +1278,9 @@ DB9D6C3725E508BE0051B173 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = ""; }; DB9D7C20269824B80054B3DF /* APIService+Filter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Filter.swift"; sourceTree = ""; }; DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIInterpolatingMotionEffect.swift; sourceTree = ""; }; + DB9F58EB26EF435000E7BBE9 /* AccountViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountViewController.swift; sourceTree = ""; }; + DB9F58EE26EF491E00E7BBE9 /* AccountListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountListViewModel.swift; sourceTree = ""; }; + DB9F58F026EF512300E7BBE9 /* AccountListTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountListTableViewCell.swift; sourceTree = ""; }; DBA088DE26958164003EB4B2 /* UserFetchedResultsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserFetchedResultsController.swift; sourceTree = ""; }; DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeToolbarView.swift; sourceTree = ""; }; DBA1DB7F268F84F80052DB59 /* NotificationType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationType.swift; sourceTree = ""; }; @@ -1219,6 +1316,8 @@ DBA4B0EF26C153B20077136E /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/InfoPlist.strings; sourceTree = ""; }; DBA4B0F526C2621D0077136E /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Intents.strings; sourceTree = ""; }; DBA4B0F826C269880077136E /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = en; path = en.lproj/Intents.stringsdict; sourceTree = ""; }; + DBA5A53026F08EF000CACBAA /* DragIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DragIndicatorView.swift; sourceTree = ""; }; + DBA5A53426F0A36A00CACBAA /* AddAccountTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddAccountTableViewCell.swift; sourceTree = ""; }; DBA5E7A2263AD0A3004598BB /* PhotoLibraryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoLibraryService.swift; sourceTree = ""; }; DBA5E7A4263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenuImagePreviewViewModel.swift; sourceTree = ""; }; DBA5E7A8263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenuImagePreviewViewController.swift; sourceTree = ""; }; @@ -1232,7 +1331,6 @@ DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeIllustrationView.swift; sourceTree = ""; }; DBAC6484267D0F9E007FE9FD /* StatusNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusNode.swift; sourceTree = ""; }; DBAC6487267D388B007FE9FD /* ASTableNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ASTableNode.swift; sourceTree = ""; }; - DBAC6489267DC355007FE9FD /* NSDiffableDataSourceSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSDiffableDataSourceSnapshot.swift; sourceTree = ""; }; DBAC648E267DC84D007FE9FD /* TableNodeDiffableDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableNodeDiffableDataSource.swift; sourceTree = ""; }; DBAC6496267DECCB007FE9FD /* TimelineMiddleLoaderNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMiddleLoaderNode.swift; sourceTree = ""; }; DBAC6498267DF2C4007FE9FD /* TimelineBottomLoaderNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineBottomLoaderNode.swift; sourceTree = ""; }; @@ -1307,6 +1405,11 @@ DBD376AB2692ECDB007FEC24 /* ThemePreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemePreference.swift; sourceTree = ""; }; DBD376B1269302A4007FEC24 /* UITableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITableViewCell.swift; sourceTree = ""; }; DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Onboarding.swift"; sourceTree = ""; }; + DBDC1CF9272C0FD600055C3D /* ku-TR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "ku-TR"; path = "ku-TR.lproj/Intents.strings"; sourceTree = ""; }; + DBDC1CFA272C0FD600055C3D /* ku-TR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "ku-TR"; path = "ku-TR.lproj/Localizable.stringsdict"; sourceTree = ""; }; + DBDC1CFB272C0FD600055C3D /* ku-TR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "ku-TR"; path = "ku-TR.lproj/Localizable.strings"; sourceTree = ""; }; + DBDC1CFC272C0FD600055C3D /* ku-TR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "ku-TR"; path = "ku-TR.lproj/InfoPlist.strings"; sourceTree = ""; }; + DBDC1CFD272C0FD600055C3D /* ku-TR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "ku-TR"; path = "ku-TR.lproj/Intents.stringsdict"; sourceTree = ""; }; DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewController.swift; sourceTree = ""; }; DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewModel.swift; sourceTree = ""; }; DBE3CDBA261C427900430CC6 /* TimelineHeaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineHeaderTableViewCell.swift; sourceTree = ""; }; @@ -1318,9 +1421,17 @@ DBE3CE0C261D767100430CC6 /* FavoriteViewController+Provider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FavoriteViewController+Provider.swift"; sourceTree = ""; }; DBE3CE12261D7D4200430CC6 /* StatusTableViewControllerAspect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableViewControllerAspect.swift; sourceTree = ""; }; DBE54AC52636C89F004E7C0B /* NotificationPreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPreference.swift; sourceTree = ""; }; + DBF156DD27006F5D00EC00B7 /* CoreData 2.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "CoreData 2.xcdatamodel"; sourceTree = ""; }; + DBF156DE2701B17600EC00B7 /* SidebarAddAccountCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarAddAccountCollectionViewCell.swift; sourceTree = ""; }; + DBF156E02702DA6800EC00B7 /* Mastodon-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Mastodon-Bridging-Header.h"; sourceTree = ""; }; + DBF156E12702DA6900EC00B7 /* UIStatusBarManager+HandleTapAction.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "UIStatusBarManager+HandleTapAction.m"; sourceTree = ""; }; + DBF156E32702DB3F00EC00B7 /* HandleTapAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandleTapAction.swift; sourceTree = ""; }; + DBF1572E27046F1A00EC00B7 /* SecondaryPlaceholderViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecondaryPlaceholderViewController.swift; sourceTree = ""; }; DBF1D24D269DAF5D00C1C08A /* SearchDetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchDetailViewController.swift; sourceTree = ""; }; DBF1D250269DB01200C1C08A /* SearchHistoryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistoryViewController.swift; sourceTree = ""; }; DBF1D256269DBAC600C1C08A /* SearchDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchDetailViewModel.swift; sourceTree = ""; }; + DBF3B73E2733EAED00E21627 /* local-codes.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "local-codes.json"; sourceTree = ""; }; + DBF3B7402733EB9400E21627 /* MastodonLocalCode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonLocalCode.swift; sourceTree = ""; }; DBF53F5F25C14E88008AAC7B /* Mastodon.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; name = Mastodon.xctestplan; path = Mastodon/Mastodon.xctestplan; sourceTree = ""; }; DBF53F6025C14E9D008AAC7B /* MastodonSDK.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = MastodonSDK.xctestplan; sourceTree = ""; }; DBF8AE13263293E400C9C23C /* NotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1381,6 +1492,7 @@ 87FFDA5D898A5C42ADCB35E7 /* Pods_Mastodon.framework in Frameworks */, DBF7A0FC26830C33004176A2 /* FPSIndicator in Frameworks */, DB01E23526A98F0900C3965B /* MetaTextKit in Frameworks */, + DBA5A52F26F07ED800CACBAA /* PanModal in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1487,6 +1599,7 @@ children = ( DBABE3F125ECAC4E00879EE5 /* View */, 0FAA0FDE25E0B57E0017CCDE /* WelcomeViewController.swift */, + DB1D61CE26F1B33600DA8662 /* WelcomeViewModel.swift */, ); path = Welcome; sourceTree = ""; @@ -1664,6 +1777,7 @@ isa = PBXGroup; children = ( DB0C947126A7D2D70088FB11 /* AvatarButton.swift */, + DB4932B226F2054200EF46D4 /* CircleAvatarButton.swift */, DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */, 2D42FF8425C8224F004A627A /* HitTestExpandedButton.swift */, 0FAA101125E105390017CCDE /* PrimaryActionButton.swift */, @@ -1691,12 +1805,16 @@ 2D5A3D0125CF8640002347D6 /* Vender */ = { isa = PBXGroup; children = ( + DB71C7CC271D7F4300BE3819 /* CurveAlgorithm.swift */, 2D5A3D0225CF8742002347D6 /* ControlContainableScrollViews.swift */, DB51D170262832380062B7A1 /* BlurHashDecode.swift */, DB51D171262832380062B7A1 /* BlurHashEncode.swift */, DB6180EC26391C6C0018D199 /* TransitioningMath.swift */, DB75BF1D263C1C1B00EDBF1F /* CustomScheduler.swift */, DBAC649A267DF8C8007FE9FD /* ActivityIndicatorNode.swift */, + DBF156E32702DB3F00EC00B7 /* HandleTapAction.swift */, + DBF156E12702DA6900EC00B7 /* UIStatusBarManager+HandleTapAction.m */, + DBF156E02702DA6800EC00B7 /* Mastodon-Bridging-Header.h */, ); path = Vender; sourceTree = ""; @@ -1721,6 +1839,7 @@ DB297B1A2679FAE200704C90 /* PlaceholderImageCacheService.swift */, DBAEDE5B267A058D00D25FF5 /* BlurhashImageCacheService.swift */, DB564BD2269F3B35001E39A7 /* StatusFilterService.swift */, + DB73BF42271192BB00781945 /* InstanceService.swift */, ); path = Service; sourceTree = ""; @@ -1789,6 +1908,7 @@ 2D4AD8A126316CD200613EFC /* SelectedAccountSection.swift */, DB6D9F7C26358ED4008423CD /* SettingsSection.swift */, DBA94433265CBB5300C537E1 /* ProfileFieldSection.swift */, + DB6B74FB272FF55800C70B6E /* UserSection.swift */, ); path = Section; sourceTree = ""; @@ -1832,8 +1952,10 @@ 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */, 2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */, DBE3CDBA261C427900430CC6 /* TimelineHeaderTableViewCell.swift */, + DB6B750327300B4000C70B6E /* TimelineFooterTableViewCell.swift */, DB02CDAA26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift */, DB92CF7125E7BB98002C1017 /* PollOptionTableViewCell.swift */, + DB6B74FF272FF73800C70B6E /* UserTableViewCell.swift */, ); path = TableviewCell; sourceTree = ""; @@ -1842,6 +1964,7 @@ isa = PBXGroup; children = ( 2D7631B225C159F700929FB9 /* Item.swift */, + DB6B74FD272FF59000C70B6E /* UserItem.swift */, 2D198642261BF09500F0B013 /* SearchResultItem.swift */, DB4F097C26A03A5B00D62E92 /* SearchHistoryItem.swift */, 2D4AD8A726316D3500613EFC /* SelectedAccountItem.swift */, @@ -1891,6 +2014,7 @@ isa = PBXGroup; children = ( 2DCB73FC2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift */, + DB71C7CA271D5A0300BE3819 /* LineChartView.swift */, ); path = View; sourceTree = ""; @@ -2029,6 +2153,7 @@ DBAFB7342645463500371D5F /* Emojis.swift */, DBA94439265CC0FC00C537E1 /* Fields.swift */, DBA1DB7F268F84F80052DB59 /* NotificationType.swift */, + DB73BF46271199CA00781945 /* Instance.swift */, ); path = CoreDataStack; sourceTree = ""; @@ -2049,6 +2174,17 @@ path = Button; sourceTree = ""; }; + DB0EF72C26FDB1D600347686 /* View */ = { + isa = PBXGroup; + children = ( + DBF156DE2701B17600EC00B7 /* SidebarAddAccountCollectionViewCell.swift */, + DB0EF72A26FDB1D200347686 /* SidebarListCollectionViewCell.swift */, + DB0EF72D26FDB24F00347686 /* SidebarListContentView.swift */, + DB03A792272A7E5700EE37C5 /* SidebarListHeaderView.swift */, + ); + path = View; + sourceTree = ""; + }; DB1D187125EF5BBD003F1F23 /* TableView */ = { isa = PBXGroup; children = ( @@ -2082,6 +2218,7 @@ isa = PBXGroup; children = ( 164F0EBB267D4FE400249499 /* BoopSound.caf */, + DBF3B73E2733EAED00E21627 /* local-codes.json */, DB427DDE25BAA00100D1B89D /* Assets.xcassets */, DB118A8125E4B6E600FAB162 /* Preview Assets.xcassets */, DB564BCE269F2F83001E39A7 /* Localizable.stringsdict */, @@ -2199,6 +2336,8 @@ 2D34D9D026148D9E0081BFC0 /* APIService+Recommend.swift */, 2D34D9DA261494120081BFC0 /* APIService+Search.swift */, 0F202212261351F5000C64BF /* APIService+HashtagTimeline.swift */, + DB6B74F9272FC2B500C70B6E /* APIService+Follower.swift */, + DB67D08327312970006A36CF /* APIService+Following.swift */, DBCC3B9426157E6E0045B23D /* APIService+Relationship.swift */, 5B24BBE1262DB19100A9381B /* APIService+Report.swift */, DBAE3F932616E28B004B8251 /* APIService+Follow.swift */, @@ -2220,6 +2359,7 @@ 2D79E700261EA5550011E398 /* APIService+CoreData+Tag.swift */, DB6D9F56263577D2008423CD /* APIService+CoreData+Setting.swift */, 5B90C48A26259C120002E742 /* APIService+CoreData+Subscriptions.swift */, + DB73BF44271195AC00781945 /* APIService+CoreData+Instance.swift */, ); path = CoreData; sourceTree = ""; @@ -2310,11 +2450,13 @@ isa = PBXGroup; children = ( DBA465942696E387002B41DB /* AppPreference.swift */, + DB647C5826F1EA2700F7F82C /* WizardPreference.swift */, DB6D1B3C2636857500ACB481 /* AppearancePreference.swift */, DBE54AC52636C89F004E7C0B /* NotificationPreference.swift */, DB1D842F26566512000346B3 /* KeyboardPreference.swift */, DBCBCC0C2680B908000F5B51 /* HomeTimelinePreference.swift */, DBD376AB2692ECDB007FEC24 /* ThemePreference.swift */, + DB67D088273256D7006A36CF /* StoreReviewPreference.swift */, ); path = Preference; sourceTree = ""; @@ -2334,6 +2476,18 @@ path = View; sourceTree = ""; }; + DB5B7296273112B400081888 /* Following */ = { + isa = PBXGroup; + children = ( + DB5B7294273112B100081888 /* FollowingListViewController.swift */, + DB5B72992731137900081888 /* FollowingListViewController+Provider.swift */, + DB5B7297273112C800081888 /* FollowingListViewModel.swift */, + DB5B729B273113C200081888 /* FollowingListViewModel+Diffable.swift */, + DB5B729D273113F300081888 /* FollowingListViewModel+State.swift */, + ); + path = Following; + sourceTree = ""; + }; DB6180DE263919350018D199 /* MediaPreview */ = { isa = PBXGroup; children = ( @@ -2385,6 +2539,14 @@ path = Image; sourceTree = ""; }; + DB67D08727312E6A006A36CF /* Wizard */ = { + isa = PBXGroup; + children = ( + DB67D08527312E67006A36CF /* WizardViewController.swift */, + ); + path = Wizard; + sourceTree = ""; + }; DB6804802637CD4C00430867 /* AppShared */ = { isa = PBXGroup; children = ( @@ -2393,6 +2555,7 @@ DB6804912637CD8700430867 /* AppName.swift */, DB6804FC2637CFEC00430867 /* AppSecret.swift */, DB6804D02637CE4700430867 /* UserDefaults.swift */, + DB73BF3A2711885500781945 /* UserDefaults+Notification.swift */, ); path = AppShared; sourceTree = ""; @@ -2415,6 +2578,18 @@ path = NavigationController; sourceTree = ""; }; + DB6B74F0272FB55400C70B6E /* Follower */ = { + isa = PBXGroup; + children = ( + DB6B74EE272FB55000C70B6E /* FollowerListViewController.swift */, + DB6B74F7272FBFB100C70B6E /* FollowerListViewController+Provider.swift */, + DB6B74F1272FB67600C70B6E /* FollowerListViewModel.swift */, + DB6B74F3272FBAE700C70B6E /* FollowerListViewModel+Diffable.swift */, + DB6B74F5272FBCDB00C70B6E /* FollowerListViewModel+State.swift */, + ); + path = Follower; + sourceTree = ""; + }; DB6C8C0525F0921200AAA452 /* MastodonSDK */ = { isa = PBXGroup; children = ( @@ -2489,6 +2664,28 @@ path = CollectionViewCell; sourceTree = ""; }; + DB852D1A26FAED0100FC9D81 /* Sidebar */ = { + isa = PBXGroup; + children = ( + DB0EF72C26FDB1D600347686 /* View */, + DB852D1826FAEB6B00FC9D81 /* SidebarViewController.swift */, + DB852D1E26FB037800FC9D81 /* SidebarViewModel.swift */, + DBF1572E27046F1A00EC00B7 /* SecondaryPlaceholderViewController.swift */, + ); + path = Sidebar; + sourceTree = ""; + }; + DB852D1D26FB021900FC9D81 /* Root */ = { + isa = PBXGroup; + children = ( + DB852D1B26FB021500FC9D81 /* RootSplitViewController.swift */, + DB03A794272A981400EE37C5 /* ContentSplitViewController.swift */, + DB852D1A26FAED0100FC9D81 /* Sidebar */, + DB8AF54E25C13703002E6C99 /* MainTab */, + ); + path = Root; + sourceTree = ""; + }; DB87D45C2609DE6600D12C0D /* TextField */ = { isa = PBXGroup; children = ( @@ -2554,6 +2751,7 @@ 5B90C46D26259B2C0002E742 /* Setting.swift */, 5B90C46C26259B2C0002E742 /* Subscription.swift */, 5B90C47E26259BA90002E742 /* SubscriptionAlerts.swift */, + DB73BF4027118B6D00781945 /* Instance.swift */, ); path = Entity; sourceTree = ""; @@ -2599,8 +2797,10 @@ children = ( 2D7631A425C1532200929FB9 /* Share */, DB6180E426391A500018D199 /* Transition */, - DB8AF54E25C13703002E6C99 /* MainTab */, + DB852D1D26FB021900FC9D81 /* Root */, DB01409B25C40BB600F9F3CF /* Onboarding */, + DB67D08727312E6A006A36CF /* Wizard */, + DB9F58ED26EF435800E7BBE9 /* Account */, 2D38F1D325CD463600561493 /* HomeTimeline */, 2D76316325C14BAC00929FB9 /* PublicTimeline */, 5B24BBD6262DB14800A9381B /* Report */, @@ -2632,7 +2832,6 @@ DB0E91E926A9675100BD2ACC /* MetaLabel.swift */, DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */, DB47229625F9EFAD00DA7F53 /* NSManagedObjectContext.swift */, - DBAC6489267DC355007FE9FD /* NSDiffableDataSourceSnapshot.swift */, DB0140CE25C42AEE00F9F3CF /* OSLog.swift */, 2D939AB425EDD8A90076FA61 /* String.swift */, DB68A06225E905E000CFDF14 /* UIApplication.swift */, @@ -2652,6 +2851,8 @@ DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */, DBCC3B2F261440A50045B23D /* UITabBarController.swift */, DBCC3B35261440BA0045B23D /* UINavigationController.swift */, + DB73BF4827140BA300781945 /* UICollectionViewDiffableDataSource.swift */, + DB73BF4A27140C0800781945 /* UITableViewDiffableDataSource.swift */, ); path = Extension; sourceTree = ""; @@ -2743,6 +2944,8 @@ DBB525462611ED57002F1F29 /* Header */, DBB5253B2611ECF5002F1F29 /* Timeline */, DBE3CDF1261C6B3100430CC6 /* Favorite */, + DB6B74F0272FB55400C70B6E /* Follower */, + DB5B7296273112B400081888 /* Following */, DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */, DBAE3F812615DDA3004B8251 /* ProfileViewController+UserProvider.swift */, DBB5255D2611F07A002F1F29 /* ProfileViewModel.swift */, @@ -2771,10 +2974,40 @@ DB9D6C2225E502C60051B173 /* MosaicImageViewModel.swift */, 2DA6055025F74407006356F9 /* AudioContainerViewModel.swift */, 5DF1054625F8870E00D6C0D4 /* VideoPlayerViewModel.swift */, + DB7274F3273BB9B200577D95 /* ListBatchFetchViewModel.swift */, ); path = ViewModel; sourceTree = ""; }; + DB9F58ED26EF435800E7BBE9 /* Account */ = { + isa = PBXGroup; + children = ( + DBA5A53226F08EF300CACBAA /* View */, + DBA5A53326F0932E00CACBAA /* Cell */, + DB9F58EB26EF435000E7BBE9 /* AccountViewController.swift */, + DB9F58EE26EF491E00E7BBE9 /* AccountListViewModel.swift */, + ); + path = Account; + sourceTree = ""; + }; + DBA5A53226F08EF300CACBAA /* View */ = { + isa = PBXGroup; + children = ( + DBA5A53026F08EF000CACBAA /* DragIndicatorView.swift */, + DB4932B826F31AD300EF46D4 /* BadgeButton.swift */, + ); + path = View; + sourceTree = ""; + }; + DBA5A53326F0932E00CACBAA /* Cell */ = { + isa = PBXGroup; + children = ( + DB9F58F026EF512300E7BBE9 /* AccountListTableViewCell.swift */, + DBA5A53426F0A36A00CACBAA /* AddAccountTableViewCell.swift */, + ); + path = Cell; + sourceTree = ""; + }; DBA5E7A6263BD298004598BB /* ContextMenu */ = { isa = PBXGroup; children = ( @@ -2806,6 +3039,7 @@ isa = PBXGroup; children = ( DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */, + DB4932B026F1FB5300EF46D4 /* WizardCardView.swift */, ); path = View; sourceTree = ""; @@ -2833,6 +3067,7 @@ children = ( DBAE3F672615DD60004B8251 /* UserProvider.swift */, DBAE3F872615DDF4004B8251 /* UserProviderFacade.swift */, + DB6B75012730060700C70B6E /* UserProviderFacade+UITableViewDelegate.swift */, ); path = UserProvider; sourceTree = ""; @@ -2919,6 +3154,7 @@ DBBC24D626A54BCB00398BB9 /* MastodonRegex.swift */, DBBC24D826A54BCB00398BB9 /* MastodonMetricFormatter.swift */, DBFEF07626A691FB006D7ED1 /* MastodonAuthenticationBox.swift */, + DBF3B7402733EB9400E21627 /* MastodonLocalCode.swift */, ); path = Helper; sourceTree = ""; @@ -3144,6 +3380,7 @@ DB01E23226A98F0900C3965B /* MastodonMeta */, DB01E23426A98F0900C3965B /* MetaTextKit */, DB552D4E26BBD10C00E481F6 /* OrderedCollections */, + DBA5A52E26F07ED800CACBAA /* PanModal */, ); productName = Mastodon; productReference = DB427DD225BAA00100D1B89D /* Mastodon.app */; @@ -3333,7 +3570,7 @@ TargetAttributes = { DB427DD125BAA00100D1B89D = { CreatedOnToolsVersion = 12.4; - LastSwiftMigration = 1220; + LastSwiftMigration = 1300; }; DB427DE725BAA00100D1B89D = { CreatedOnToolsVersion = 12.4; @@ -3385,6 +3622,7 @@ ru, "gd-GB", th, + "ku-TR", ); mainGroup = DB427DC925BAA00100D1B89D; packageReferences = ( @@ -3403,6 +3641,7 @@ DB0E2D2C26833FF600865C3C /* XCRemoteSwiftPackageReference "Nuke-FLAnimatedImage-Plugin" */, DB01E23126A98F0900C3965B /* XCRemoteSwiftPackageReference "MetaTextKit" */, DB552D4D26BBD10C00E481F6 /* XCRemoteSwiftPackageReference "swift-collections" */, + DBA5A52D26F07ED800CACBAA /* XCRemoteSwiftPackageReference "PanModal" */, ); productRefGroup = DB427DD325BAA00100D1B89D /* Products */; projectDirPath = ""; @@ -3433,6 +3672,7 @@ DB427DDF25BAA00100D1B89D /* Assets.xcassets in Resources */, DB427DDD25BAA00100D1B89D /* Main.storyboard in Resources */, DBA4B0F626C269880077136E /* Intents.stringsdict in Resources */, + DBF3B73F2733EAED00E21627 /* local-codes.json in Resources */, DB118A8225E4B6E600FAB162 /* Preview Assets.xcassets in Resources */, DB2B3ABC25E37E15007045F9 /* InfoPlist.strings in Resources */, DB68A05D25E9055900CFDF14 /* Settings.bundle in Resources */, @@ -3725,6 +3965,7 @@ DBAE3F8E2616E0B1004B8251 /* APIService+Block.swift in Sources */, 5DF1057F25F88A4100D6C0D4 /* TouchBlockingView.swift in Sources */, DB1D843426579931000346B3 /* TableViewControllerNavigateable.swift in Sources */, + DB5B729A2731137900081888 /* FollowingListViewController+Provider.swift in Sources */, 0FAA0FDF25E0B57E0017CCDE /* WelcomeViewController.swift in Sources */, 2D206B8C25F6015000143C56 /* AudioPlaybackService.swift in Sources */, 2D59819B25E4A581000FB903 /* MastodonConfirmEmailViewController.swift in Sources */, @@ -3733,8 +3974,10 @@ DBA94440265D137600C537E1 /* Mastodon+Entity+Field.swift in Sources */, DB49A61425FF2C5600B98345 /* EmojiService.swift in Sources */, DBBF1DC7265251D400E5B703 /* AutoCompleteViewModel+State.swift in Sources */, + DB03A793272A7E5700EE37C5 /* SidebarListHeaderView.swift in Sources */, DB4FFC2B269EC39600D62E92 /* SearchToSearchDetailViewControllerAnimatedTransitioning.swift in Sources */, DBCC3B9526157E6E0045B23D /* APIService+Relationship.swift in Sources */, + DB5B7298273112C800081888 /* FollowingListViewModel.swift in Sources */, 2D7631B325C159F700929FB9 /* Item.swift in Sources */, 5DF1054125F886D400D6C0D4 /* VideoPlaybackService.swift in Sources */, DB6B35182601FA3400DC1E11 /* MastodonAttachmentService.swift in Sources */, @@ -3756,6 +3999,7 @@ 2D5A3D3825CF8D9F002347D6 /* ScrollViewContainer.swift in Sources */, DB6180EF26391CA50018D199 /* MediaPreviewImageViewController.swift in Sources */, DB1E347825F519300079D7DF /* PickServerItem.swift in Sources */, + DBA5A53126F08EF000CACBAA /* DragIndicatorView.swift in Sources */, DB1FD45A25F27898004CFCFC /* CategoryPickerItem.swift in Sources */, DB6180F626391D580018D199 /* MediaPreviewableViewController.swift in Sources */, 2D571B2F26004EC000540450 /* NavigationBarProgressView.swift in Sources */, @@ -3794,6 +4038,7 @@ DB71FD4625F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift in Sources */, DB297B1B2679FAE200704C90 /* PlaceholderImageCacheService.swift in Sources */, 2D8FCA082637EABB00137F46 /* APIService+FollowRequest.swift in Sources */, + DB03A795272A981400EE37C5 /* ContentSplitViewController.swift in Sources */, 2D152A8C25C295CC009AA50C /* StatusView.swift in Sources */, DBBC24DE26A54BCB00398BB9 /* MastodonMetricFormatter.swift in Sources */, DBB3BA2A26A81C020004F2D4 /* FLAnimatedImageView.swift in Sources */, @@ -3803,12 +4048,14 @@ DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */, DBB5250E2611EBAF002F1F29 /* ProfileSegmentedViewController.swift in Sources */, 2D42FF8F25C8228A004A627A /* UIButton.swift in Sources */, + DBF156DF2701B17600EC00B7 /* SidebarAddAccountCollectionViewCell.swift in Sources */, DB789A0B25F9F2950071ACA0 /* ComposeViewController.swift in Sources */, DB938F0926240F3C00E5B6C1 /* RemoteThreadViewModel.swift in Sources */, DBBC24AE26A53DC100398BB9 /* ReplicaStatusView.swift in Sources */, DB75BF1E263C1C1B00EDBF1F /* CustomScheduler.swift in Sources */, 0FAA102725E1126A0017CCDE /* MastodonPickServerViewController.swift in Sources */, DB59F0FE25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift in Sources */, + DB6B74FE272FF59000C70B6E /* UserItem.swift in Sources */, DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */, DBE3CE07261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift in Sources */, 2D61335825C188A000CAE157 /* APIService+Persist+Status.swift in Sources */, @@ -3825,7 +4072,6 @@ DBBC24DC26A54BCB00398BB9 /* MastodonRegex.swift in Sources */, 2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Status.swift in Sources */, DB4481C625EE2ADA00BEFB67 /* PollSection.swift in Sources */, - DBAC648A267DC355007FE9FD /* NSDiffableDataSourceSnapshot.swift in Sources */, DBCBED1726132DB500B49291 /* UserTimelineViewModel+Diffable.swift in Sources */, DB71FD4C25F8C80E00512AE1 /* StatusPrefetchingService.swift in Sources */, 2DE0FACE2615F7AD00CDF649 /* RecommendAccountSection.swift in Sources */, @@ -3857,6 +4103,7 @@ DB36679F268ABAF20027D07F /* ComposeStatusAttachmentSection.swift in Sources */, 2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */, DB87D44B2609C11900D12C0D /* PollOptionView.swift in Sources */, + DBF1572F27046F1A00EC00B7 /* SecondaryPlaceholderViewController.swift in Sources */, DB03F7F32689AEA3007B274C /* ComposeRepliedToStatusContentTableViewCell.swift in Sources */, 2D4AD8A826316D3500613EFC /* SelectedAccountItem.swift in Sources */, DBE3CDFB261C6CA500430CC6 /* FavoriteViewModel.swift in Sources */, @@ -3867,6 +4114,8 @@ DBE3CE01261D623D00430CC6 /* FavoriteViewModel+State.swift in Sources */, 2D82BA0525E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift in Sources */, 2D38F1EB25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift in Sources */, + DB67D089273256D7006A36CF /* StoreReviewPreference.swift in Sources */, + DB5B7295273112B100081888 /* FollowingListViewController.swift in Sources */, 0F202201261326E6000C64BF /* HashtagTimelineViewModel.swift in Sources */, DB6D9F9726367249008423CD /* SettingsViewController.swift in Sources */, DB4F097F26A03DA600D62E92 /* SearchHistoryFetchedResultController.swift in Sources */, @@ -3874,15 +4123,19 @@ DBAE3FAF26172FC0004B8251 /* RemoteProfileViewModel.swift in Sources */, DBE3CE0D261D767100430CC6 /* FavoriteViewController+Provider.swift in Sources */, 2D084B9326259545003AA3AF /* NotificationViewModel+LoadLatestState.swift in Sources */, + DB73BF47271199CA00781945 /* Instance.swift in Sources */, DB0F8150264D1E2500F2A12B /* PickServerLoaderTableViewCell.swift in Sources */, DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */, + DB5B729C273113C200081888 /* FollowingListViewModel+Diffable.swift in Sources */, DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */, DBB9759C262462E1004620BD /* ThreadMetaView.swift in Sources */, + DB5B729E273113F300081888 /* FollowingListViewModel+State.swift in Sources */, 2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */, 5DDDF1A92617489F00311060 /* Mastodon+Entity+History.swift in Sources */, DB59F11825EFA35B001F1DAB /* StripProgressView.swift in Sources */, DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */, DBF9814C265E339500E4BA07 /* ProfileFieldAddEntryCollectionViewCell.swift in Sources */, + DB73BF4927140BA300781945 /* UICollectionViewDiffableDataSource.swift in Sources */, DBA5E7AB263BD3F5004598BB /* TimelineTableViewCellContextMenuConfiguration.swift in Sources */, DB73B490261F030A002E9E9F /* SafariActivity.swift in Sources */, DB6D1B44263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift in Sources */, @@ -3896,6 +4149,8 @@ 5B90C45E262599800002E742 /* SettingsViewModel.swift in Sources */, 2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */, 5DF1054725F8870E00D6C0D4 /* VideoPlayerViewModel.swift in Sources */, + DB73BF43271192BB00781945 /* InstanceService.swift in Sources */, + DB67D08427312970006A36CF /* APIService+Following.swift in Sources */, DBA9443A265CC0FC00C537E1 /* Fields.swift in Sources */, 2DE0FAC12615F04D00CDF649 /* RecommendHashTagSection.swift in Sources */, DBA5E7A5263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift in Sources */, @@ -3916,22 +4171,28 @@ DBAEDE5C267A058D00D25FF5 /* BlurhashImageCacheService.swift in Sources */, 2D38F1DF25CD46A400561493 /* HomeTimelineViewController+Provider.swift in Sources */, DB1D843026566512000346B3 /* KeyboardPreference.swift in Sources */, + DB852D1926FAEB6B00FC9D81 /* SidebarViewController.swift in Sources */, 2D206B9225F60EA700143C56 /* UIControl.swift in Sources */, 2D9DB96B263A91D1007C1D71 /* APIService+DomainBlock.swift in Sources */, DBBF1DC92652538500E5B703 /* AutoCompleteSection.swift in Sources */, 2D45E5BF25C9549700A6D639 /* PublicTimelineViewModel+State.swift in Sources */, DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */, DB7F48452620241000796008 /* ProfileHeaderViewModel.swift in Sources */, + DB647C5926F1EA2700F7F82C /* WizardPreference.swift in Sources */, 2D3F9E0425DFA133004262D9 /* UITapGestureRecognizer.swift in Sources */, 5DDDF1992617447F00311060 /* Mastodon+Entity+Tag.swift in Sources */, 5B90C45F262599800002E742 /* SettingsToggleTableViewCell.swift in Sources */, 2D694A7425F9EB4E0038ADDC /* ContentWarningOverlayView.swift in Sources */, + DB4932B126F1FB5300EF46D4 /* WizardCardView.swift in Sources */, DBAE3F682615DD60004B8251 /* UserProvider.swift in Sources */, DBAC6488267D388B007FE9FD /* ASTableNode.swift in Sources */, DB6D9F76263587C7008423CD /* SettingFetchedResultController.swift in Sources */, DB9A486C26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift in Sources */, 5D0393902612D259007FE196 /* WebViewController.swift in Sources */, DB4481CC25EE2AFE00BEFB67 /* PollItem.swift in Sources */, + DB6B74FA272FC2B500C70B6E /* APIService+Follower.swift in Sources */, + DB6B74F4272FBAE700C70B6E /* FollowerListViewModel+Diffable.swift in Sources */, + DB6B74F2272FB67600C70B6E /* FollowerListViewModel.swift in Sources */, DB44767B260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift in Sources */, 0F20222D261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift in Sources */, DB0009A626AEE5DC009B9D2D /* Intents.intentdefinition in Sources */, @@ -3944,7 +4205,9 @@ DB35FC1F2612F1D9006193C9 /* ProfileRelationshipActionButton.swift in Sources */, DBC7A672260C897100E57475 /* StatusContentWarningEditorView.swift in Sources */, DB3667A6268AE2620027D07F /* ComposeStatusPollSection.swift in Sources */, + DB6B750427300B4000C70B6E /* TimelineFooterTableViewCell.swift in Sources */, DB59F10E25EF724F001F1DAB /* APIService+Poll.swift in Sources */, + DB852D1F26FB037800FC9D81 /* SidebarViewModel.swift in Sources */, DB47229725F9EFAD00DA7F53 /* NSManagedObjectContext.swift in Sources */, 2D34D9D126148D9E0081BFC0 /* APIService+Recommend.swift in Sources */, DBB525562611EDCA002F1F29 /* UserTimelineViewModel.swift in Sources */, @@ -3956,10 +4219,13 @@ DB3667A1268ABB2E0027D07F /* ComposeStatusAttachmentItem.swift in Sources */, DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */, DBA94434265CBB5300C537E1 /* ProfileFieldSection.swift in Sources */, + DBF156E42702DB3F00EC00B7 /* HandleTapAction.swift in Sources */, DB023295267F0AB800031745 /* ASMetaEditableTextNode.swift in Sources */, 2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */, + DB6B74F6272FBCDB00C70B6E /* FollowerListViewModel+State.swift in Sources */, DB4F096C269EFA2000D62E92 /* SearchResultViewController+StatusProvider.swift in Sources */, DB87D4452609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift in Sources */, + DB9F58EF26EF491E00E7BBE9 /* AccountListViewModel.swift in Sources */, DB6D9F7D26358ED4008423CD /* SettingsSection.swift in Sources */, DB0E91EA26A9675100BD2ACC /* MetaLabel.swift in Sources */, DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */, @@ -3970,9 +4236,12 @@ 2D24E11D2626D8B100A59D4F /* NotificationStatusTableViewCell.swift in Sources */, DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */, DB1E346825F518E20079D7DF /* CategoryPickerSection.swift in Sources */, + DB7274F4273BB9B200577D95 /* ListBatchFetchViewModel.swift in Sources */, 2D61254D262547C200299647 /* APIService+Notification.swift in Sources */, DB040ED126538E3D00BEE9D8 /* Trie.swift in Sources */, + DB73BF4B27140C0800781945 /* UITableViewDiffableDataSource.swift in Sources */, DBB525642612C988002F1F29 /* MeProfileViewModel.swift in Sources */, + DB6B74EF272FB55000C70B6E /* FollowerListViewController.swift in Sources */, 5BB04FE9262EFC300043BFF6 /* ReportedStatusTableviewCell.swift in Sources */, DBAE3F822615DDA3004B8251 /* ProfileViewController+UserProvider.swift in Sources */, DBBC24C426A544B900398BB9 /* Theme.swift in Sources */, @@ -3981,6 +4250,7 @@ DBC7A67C260DFADE00E57475 /* StatusPublishService.swift in Sources */, DBCBCC092680B01B000F5B51 /* AsyncHomeTimelineViewModel+LoadMiddleState.swift in Sources */, 2DCB73FD2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift in Sources */, + DB852D1C26FB021500FC9D81 /* RootSplitViewController.swift in Sources */, DB02CDBF2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift in Sources */, DB1FD44425F26CCC004CFCFC /* PickServerSection.swift in Sources */, 0FB3D30F25E525CD00AAD544 /* PickServerCategoryView.swift in Sources */, @@ -4001,6 +4271,7 @@ 2DAC9E46262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift in Sources */, DB4FFC2C269EC39600D62E92 /* SearchTransitionController.swift in Sources */, DBA5E7A9263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift in Sources */, + DBF156E22702DA6900EC00B7 /* UIStatusBarManager+HandleTapAction.m in Sources */, DB45FADD25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift in Sources */, 2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */, 2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */, @@ -4012,17 +4283,21 @@ DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */, DB6180F826391D660018D199 /* MediaPreviewingViewController.swift in Sources */, DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */, + DB6B75022730060700C70B6E /* UserProviderFacade+UITableViewDelegate.swift in Sources */, DB44384F25E8C1FA008912A2 /* CALayer.swift in Sources */, 2D34D9CB261489930081BFC0 /* SearchViewController+Recommend.swift in Sources */, + DB71C7CB271D5A0300BE3819 /* LineChartView.swift in Sources */, DB938F1526241FDF00E5B6C1 /* APIService+Thread.swift in Sources */, DB482A45261335BA008AE74C /* UserTimelineViewController+Provider.swift in Sources */, 2D206B8625F5FB0900143C56 /* Double.swift in Sources */, + DB9F58F126EF512300E7BBE9 /* AccountListTableViewCell.swift in Sources */, 2D76319F25C1521200929FB9 /* StatusSection.swift in Sources */, DB35FC252612FD7A006193C9 /* ProfileFieldView.swift in Sources */, DB938F0326240EA300E5B6C1 /* CachedThreadViewModel.swift in Sources */, DB6D9F6326357848008423CD /* SettingService.swift in Sources */, 2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */, 2D24E12D2626FD2E00A59D4F /* NotificationViewModel+LoadOldestState.swift in Sources */, + DBA5A53526F0A36A00CACBAA /* AddAccountTableViewCell.swift in Sources */, 2DB72C8C262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift in Sources */, 2D35237A26256D920031AF25 /* NotificationSection.swift in Sources */, DB084B5725CBC56C00F898ED /* Status.swift in Sources */, @@ -4040,6 +4315,7 @@ DB9D6C0E25E4F9780051B173 /* MosaicImageViewContainer.swift in Sources */, DBCBCC0D2680B908000F5B51 /* HomeTimelinePreference.swift in Sources */, DB71FD3625F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift in Sources */, + DB0EF72E26FDB24F00347686 /* SidebarListContentView.swift in Sources */, DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */, DB98338725C945ED00AD9700 /* Strings.swift in Sources */, 2D7867192625B77500211898 /* NotificationItem.swift in Sources */, @@ -4055,10 +4331,12 @@ 2D32EADA25CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift in Sources */, 5B90C48526259BF10002E742 /* APIService+Subscriptions.swift in Sources */, 0F20223926146553000C64BF /* Array.swift in Sources */, + DB0EF72B26FDB1D200347686 /* SidebarListCollectionViewCell.swift in Sources */, 5B90C460262599800002E742 /* SettingsAppearanceTableViewCell.swift in Sources */, DB0C946B26A700AB0088FB11 /* MastodonUser+Property.swift in Sources */, DB8AF54425C13647002E6C99 /* SceneCoordinator.swift in Sources */, 5DF1058525F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift in Sources */, + DB73BF45271195AC00781945 /* APIService+CoreData+Instance.swift in Sources */, DB1D84382657B275000346B3 /* SegmentedControlNavigateable.swift in Sources */, DB447697260B439000B66B82 /* CustomEmojiPickerHeaderCollectionReusableView.swift in Sources */, DB45FAF925CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift in Sources */, @@ -4071,11 +4349,14 @@ DBE54AC62636C89F004E7C0B /* NotificationPreference.swift in Sources */, 2D5A3D2825CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift in Sources */, DB98339C25C96DE600AD9700 /* APIService+Account.swift in Sources */, + DB6B74FC272FF55800C70B6E /* UserSection.swift in Sources */, 2DF75BA725D10E1000694EC8 /* APIService+Favorite.swift in Sources */, DB9D6C3825E508BE0051B173 /* Attachment.swift in Sources */, 5DFC35DF262068D20045711D /* SearchViewController+Follow.swift in Sources */, DB8AF52E25C13561002E6C99 /* ViewStateStore.swift in Sources */, + DB1D61CF26F1B33600DA8662 /* WelcomeViewModel.swift in Sources */, 2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */, + DB71C7CD271D7F4300BE3819 /* CurveAlgorithm.swift in Sources */, DBD376B2269302A4007FEC24 /* UITableViewCell.swift in Sources */, DB4F0966269ED52200D62E92 /* SearchResultViewModel.swift in Sources */, DBAC6499267DF2C4007FE9FD /* TimelineBottomLoaderNode.swift in Sources */, @@ -4085,11 +4366,13 @@ 0F202227261411BB000C64BF /* HashtagTimelineViewController+Provider.swift in Sources */, 2D7631A825C1535600929FB9 /* StatusTableViewCell.swift in Sources */, 2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */, + DB6B7500272FF73800C70B6E /* UserTableViewCell.swift in Sources */, DB1D842E26552C4D000346B3 /* StatusTableViewControllerNavigateable.swift in Sources */, DB938F1F2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift in Sources */, 2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */, DB482A4B261340A7008AE74C /* APIService+UserTimeline.swift in Sources */, DB427DD825BAA00100D1B89D /* SceneDelegate.swift in Sources */, + DB4932B926F31AD300EF46D4 /* BadgeButton.swift in Sources */, 0F2021FB2613262F000C64BF /* HashtagTimelineViewController.swift in Sources */, DBCC3B30261440A50045B23D /* UITabBarController.swift in Sources */, DB8190C62601FF0400020C08 /* AttachmentContainerView.swift in Sources */, @@ -4098,6 +4381,7 @@ DB71FD2C25F86A5100512AE1 /* AvatarStackContainerButton.swift in Sources */, DB87D4512609CF1E00D12C0D /* ComposeStatusPollOptionAppendEntryCollectionViewCell.swift in Sources */, DBBC24C026A5443100398BB9 /* MastodonTheme.swift in Sources */, + DB67D08627312E67006A36CF /* WizardViewController.swift in Sources */, DBBC24B526A540AE00398BB9 /* AvatarConfigurableView.swift in Sources */, DB9A489026035963008B817C /* APIService+Media.swift in Sources */, DBFEF07726A691FB006D7ED1 /* MastodonAuthenticationBox.swift in Sources */, @@ -4105,6 +4389,7 @@ 2D198649261C0B8500F0B013 /* SearchResultSection.swift in Sources */, DB4F097B26A039FF00D62E92 /* SearchHistorySection.swift in Sources */, DBB525302611EBF3002F1F29 /* ProfilePagingViewModel.swift in Sources */, + DB9F58EC26EF435000E7BBE9 /* AccountViewController.swift in Sources */, DBCBCC0B2680B03F000F5B51 /* AsyncHomeTimelineViewModel+LoadOldestState.swift in Sources */, 2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */, DB49A62525FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift in Sources */, @@ -4112,7 +4397,9 @@ DB6D9F6F2635807F008423CD /* Setting.swift in Sources */, DB6F5E38264E994A009108F4 /* AutoCompleteTopChevronView.swift in Sources */, DBB525412611ED54002F1F29 /* ProfileHeaderViewController.swift in Sources */, + DB6B74F8272FBFB100C70B6E /* FollowerListViewController+Provider.swift in Sources */, DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */, + DB4932B326F2054200EF46D4 /* CircleAvatarButton.swift in Sources */, 0FB3D30825E524C600AAD544 /* PickServerCategoriesCell.swift in Sources */, 2D4AD8A226316CD200613EFC /* SelectedAccountSection.swift in Sources */, DB789A1225F9F2CC0071ACA0 /* ComposeViewModel.swift in Sources */, @@ -4124,6 +4411,7 @@ 2D38F1FE25CD481700561493 /* StatusProvider.swift in Sources */, DB1EE7B2267F9525000CC337 /* StatusProvider+StatusNodeDelegate.swift in Sources */, 5B24BBE2262DB19100A9381B /* APIService+Report.swift in Sources */, + DBF3B7412733EB9400E21627 /* MastodonLocalCode.swift in Sources */, DB4F096A269EDAD200D62E92 /* SearchResultViewModel+State.swift in Sources */, 5BB04FF5262F0E6D0043BFF6 /* ReportSection.swift in Sources */, DBA94436265CBB7400C537E1 /* ProfileFieldItem.swift in Sources */, @@ -4168,6 +4456,8 @@ buildActionMask = 2147483647; files = ( DB6804D12637CE4700430867 /* UserDefaults.swift in Sources */, + DB73BF3B2711885500781945 /* UserDefaults+Notification.swift in Sources */, + DB4932B726F30F0700EF46D4 /* Array.swift in Sources */, DB6804922637CD8700430867 /* AppName.swift in Sources */, DB6804FD2637CFEC00430867 /* AppSecret.swift in Sources */, ); @@ -4187,6 +4477,7 @@ 2D927F0E25C7E9C9004F19B8 /* History.swift in Sources */, DBCC3B9B261584A00045B23D /* PrivateNote.swift in Sources */, DB89BA3725C1145C008580ED /* CoreData.xcdatamodeld in Sources */, + DB73BF4127118B6D00781945 /* Instance.swift in Sources */, DB8AF52525C131D1002E6C99 /* MastodonUser.swift in Sources */, DB89BA1B25C1107F008580ED /* Collection.swift in Sources */, DB4481AD25EE155900BEFB67 /* Poll.swift in Sources */, @@ -4383,6 +4674,7 @@ DB4B777F26CA4EFA00B087B3 /* ru */, DB4B778426CA500E00B087B3 /* gd-GB */, DB4B779226CA50BA00B087B3 /* th */, + DBDC1CF9272C0FD600055C3D /* ku-TR */, ); name = Intents.intentdefinition; sourceTree = ""; @@ -4403,6 +4695,7 @@ DB4B778226CA4EFA00B087B3 /* ru */, DB4B778726CA500E00B087B3 /* gd-GB */, DB4B779526CA50BA00B087B3 /* th */, + DBDC1CFC272C0FD600055C3D /* ku-TR */, ); name = InfoPlist.strings; sourceTree = ""; @@ -4423,6 +4716,7 @@ DB4B778126CA4EFA00B087B3 /* ru */, DB4B778626CA500E00B087B3 /* gd-GB */, DB4B779426CA50BA00B087B3 /* th */, + DBDC1CFB272C0FD600055C3D /* ku-TR */, ); name = Localizable.strings; sourceTree = ""; @@ -4459,6 +4753,7 @@ DB4B778026CA4EFA00B087B3 /* ru */, DB4B778526CA500E00B087B3 /* gd-GB */, DB4B779326CA50BA00B087B3 /* th */, + DBDC1CFA272C0FD600055C3D /* ku-TR */, ); name = Localizable.stringsdict; sourceTree = ""; @@ -4479,6 +4774,7 @@ DB4B779026CA504900B087B3 /* fr */, DB4B779126CA504A00B087B3 /* ja */, DB4B779626CA50BA00B087B3 /* th */, + DBDC1CFD272C0FD600055C3D /* ku-TR */, ); name = Intents.stringsdict; sourceTree = ""; @@ -4623,7 +4919,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 60; + CURRENT_PROJECT_VERSION = 88; DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets"; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = Mastodon/Info.plist; @@ -4635,6 +4931,7 @@ PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OBJC_BRIDGING_HEADER = "Mastodon/Vender/Mastodon-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; @@ -4651,7 +4948,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 60; + CURRENT_PROJECT_VERSION = 88; DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets"; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = Mastodon/Info.plist; @@ -4663,6 +4960,7 @@ PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OBJC_BRIDGING_HEADER = "Mastodon/Vender/Mastodon-Bridging-Header.h"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; VERSIONING_SYSTEM = "apple-generic"; @@ -4758,11 +5056,11 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 60; + CURRENT_PROJECT_VERSION = 88; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5Z4GVSS33P; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 60; + DYLIB_CURRENT_VERSION = 88; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = AppShared/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -4789,11 +5087,11 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 60; + CURRENT_PROJECT_VERSION = 88; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5Z4GVSS33P; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 60; + DYLIB_CURRENT_VERSION = 88; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = AppShared/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -4818,11 +5116,11 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 60; + CURRENT_PROJECT_VERSION = 88; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5Z4GVSS33P; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 60; + DYLIB_CURRENT_VERSION = 88; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = CoreDataStack/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -4848,11 +5146,11 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 60; + CURRENT_PROJECT_VERSION = 88; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5Z4GVSS33P; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 60; + DYLIB_CURRENT_VERSION = 88; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = CoreDataStack/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -4915,7 +5213,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = MastodonIntent/MastodonIntent.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 60; + CURRENT_PROJECT_VERSION = 88; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = MastodonIntent/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -4940,7 +5238,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = MastodonIntent/MastodonIntent.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 60; + CURRENT_PROJECT_VERSION = 88; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = MastodonIntent/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -4965,7 +5263,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = MastodonIntent/MastodonIntent.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 60; + CURRENT_PROJECT_VERSION = 88; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = MastodonIntent/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -4990,7 +5288,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = MastodonIntent/MastodonIntent.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 60; + CURRENT_PROJECT_VERSION = 88; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = MastodonIntent/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -5015,7 +5313,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = ShareActionExtension/ShareActionExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 60; + CURRENT_PROJECT_VERSION = 88; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = ShareActionExtension/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -5040,7 +5338,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = ShareActionExtension/ShareActionExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 60; + CURRENT_PROJECT_VERSION = 88; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = ShareActionExtension/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -5065,7 +5363,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = ShareActionExtension/ShareActionExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 60; + CURRENT_PROJECT_VERSION = 88; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = ShareActionExtension/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -5090,7 +5388,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = ShareActionExtension/ShareActionExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 60; + CURRENT_PROJECT_VERSION = 88; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = ShareActionExtension/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -5181,7 +5479,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 60; + CURRENT_PROJECT_VERSION = 88; DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets"; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = Mastodon/Info.plist; @@ -5193,6 +5491,7 @@ PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OBJC_BRIDGING_HEADER = "Mastodon/Vender/Mastodon-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; @@ -5247,11 +5546,11 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 60; + CURRENT_PROJECT_VERSION = 88; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5Z4GVSS33P; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 60; + DYLIB_CURRENT_VERSION = 88; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = CoreDataStack/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -5296,7 +5595,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 60; + CURRENT_PROJECT_VERSION = 88; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = NotificationService/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -5321,11 +5620,11 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 60; + CURRENT_PROJECT_VERSION = 88; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5Z4GVSS33P; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 60; + DYLIB_CURRENT_VERSION = 88; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = AppShared/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -5417,7 +5716,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 60; + CURRENT_PROJECT_VERSION = 88; DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets"; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = Mastodon/Info.plist; @@ -5429,6 +5728,7 @@ PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OBJC_BRIDGING_HEADER = "Mastodon/Vender/Mastodon-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; @@ -5483,11 +5783,11 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 60; + CURRENT_PROJECT_VERSION = 88; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5Z4GVSS33P; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 60; + DYLIB_CURRENT_VERSION = 88; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = CoreDataStack/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -5532,7 +5832,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 60; + CURRENT_PROJECT_VERSION = 88; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = NotificationService/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -5557,11 +5857,11 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 60; + CURRENT_PROJECT_VERSION = 88; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5Z4GVSS33P; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 60; + DYLIB_CURRENT_VERSION = 88; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = AppShared/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -5587,7 +5887,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 60; + CURRENT_PROJECT_VERSION = 88; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = NotificationService/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -5611,7 +5911,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 60; + CURRENT_PROJECT_VERSION = 88; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = NotificationService/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -5782,7 +6082,7 @@ repositoryURL = "https://github.com/TwidereProject/MetaTextKit.git"; requirement = { kind = exactVersion; - version = 2.1.1; + version = 2.1.2; }; }; DB0E2D2C26833FF600865C3C /* XCRemoteSwiftPackageReference "Nuke-FLAnimatedImage-Plugin" */ = { @@ -5825,6 +6125,14 @@ minimumVersion = 1.4.1; }; }; + DBA5A52D26F07ED800CACBAA /* XCRemoteSwiftPackageReference "PanModal" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/slackhq/PanModal.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.2.7; + }; + }; DBAC6481267D0B21007FE9FD /* XCRemoteSwiftPackageReference "DifferenceKit" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/ra1028/DifferenceKit.git"; @@ -5937,6 +6245,11 @@ package = DB9A487C2603456B008B817C /* XCRemoteSwiftPackageReference "UITextView-Placeholder" */; productName = "UITextView+Placeholder"; }; + DBA5A52E26F07ED800CACBAA /* PanModal */ = { + isa = XCSwiftPackageProductDependency; + package = DBA5A52D26F07ED800CACBAA /* XCRemoteSwiftPackageReference "PanModal" */; + productName = PanModal; + }; DBAC6482267D0B21007FE9FD /* DifferenceKit */ = { isa = XCSwiftPackageProductDependency; package = DBAC6481267D0B21007FE9FD /* XCRemoteSwiftPackageReference "DifferenceKit" */; @@ -5998,9 +6311,10 @@ DB89BA3525C1145C008580ED /* CoreData.xcdatamodeld */ = { isa = XCVersionGroup; children = ( + DBF156DD27006F5D00EC00B7 /* CoreData 2.xcdatamodel */, DB89BA3625C1145C008580ED /* CoreData.xcdatamodel */, ); - currentVersion = DB89BA3625C1145C008580ED /* CoreData.xcdatamodel */; + currentVersion = DBF156DD27006F5D00EC00B7 /* CoreData 2.xcdatamodel */; path = CoreData.xcdatamodeld; sourceTree = ""; versionGroupType = wrapper.xcdatamodel; diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index 9d41c6857..5c99e944b 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,87 +7,92 @@ AppShared.xcscheme_^#shared#^_ orderHint - 60 + 44 CoreDataStack.xcscheme_^#shared#^_ orderHint - 62 + 45 Mastodon - ASDK.xcscheme_^#shared#^_ orderHint - 11 + 4 Mastodon - RTL.xcscheme_^#shared#^_ orderHint - 12 + 17 Mastodon - Release.xcscheme_^#shared#^_ orderHint - 10 + 3 Mastodon - ar.xcscheme_^#shared#^_ orderHint - 7 + 11 Mastodon - ca.xcscheme_^#shared#^_ orderHint - 32 + 23 Mastodon - de.xcscheme_^#shared#^_ orderHint - 8 + 13 - Mastodon - en.xcscheme_^#shared#^_ + Mastodon - double length.xcscheme_^#shared#^_ orderHint 1 - Mastodon - es-419.xcscheme_^#shared#^_ - - orderHint - 5 - - Mastodon - es.xcscheme_^#shared#^_ - - orderHint - 4 - - Mastodon - fr.xcscheme_^#shared#^_ - - orderHint - 6 - - Mastodon - jp.xcscheme_^#shared#^_ - - orderHint - 27 - - Mastodon - nl.xcscheme_^#shared#^_ - - orderHint - 9 - - Mastodon - ru.xcscheme_^#shared#^_ + Mastodon - en.xcscheme_^#shared#^_ orderHint 2 + Mastodon - es-419.xcscheme_^#shared#^_ + + orderHint + 9 + + Mastodon - es.xcscheme_^#shared#^_ + + orderHint + 8 + + Mastodon - fr.xcscheme_^#shared#^_ + + orderHint + 10 + + Mastodon - jp.xcscheme_^#shared#^_ + + orderHint + 19 + + Mastodon - nl.xcscheme_^#shared#^_ + + orderHint + 15 + + Mastodon - ru.xcscheme_^#shared#^_ + + orderHint + 5 + Mastodon - th.xcscheme_^#shared#^_ orderHint - 3 + 6 Mastodon - zh_Hans.xcscheme_^#shared#^_ orderHint - 30 + 21 Mastodon.xcscheme_^#shared#^_ @@ -97,7 +102,7 @@ MastodonIntent.xcscheme_^#shared#^_ orderHint - 56 + 43 MastodonIntents.xcscheme_^#shared#^_ @@ -112,12 +117,12 @@ NotificationService.xcscheme_^#shared#^_ orderHint - 13 + 7 ShareActionExtension.xcscheme_^#shared#^_ orderHint - 58 + 42 SuppressBuildableAutocreation diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0dc724ded..11dde7269 100644 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "repositoryURL": "https://github.com/Alamofire/Alamofire.git", "state": { "branch": null, - "revision": "f96b619bcb2383b43d898402283924b80e2c4bae", - "version": "5.4.3" + "revision": "d120af1e8638c7da36c8481fd61a66c0c08dc4fc", + "version": "5.4.4" } }, { @@ -69,8 +69,8 @@ "repositoryURL": "https://github.com/MainasuK/FPSIndicator.git", "state": { "branch": null, - "revision": "b2a002d689c400485f2ba41f9e71e15f7b99764a", - "version": "1.0.1" + "revision": "e4a5067ccd5293b024c767f09e51056afd4a4796", + "version": "1.1.0" } }, { @@ -96,8 +96,8 @@ "repositoryURL": "https://github.com/TwidereProject/MetaTextKit.git", "state": { "branch": null, - "revision": "a5f412b72fc08cd1348e2388fc7ec326365e1823", - "version": "2.1.1" + "revision": "7af4182f64329440a4656f2cba307cb5848e496a", + "version": "2.1.2" } }, { @@ -105,8 +105,8 @@ "repositoryURL": "https://github.com/kean/Nuke.git", "state": { "branch": null, - "revision": "3bd3a1765bdf62d561d4c2e10e1c4fc7a010f44e", - "version": "10.3.2" + "revision": "0db18dd34998cca18e9a28bcee136f84518007a0", + "version": "10.4.1" } }, { @@ -127,13 +127,22 @@ "version": "3.6.2" } }, + { + "package": "PanModal", + "repositoryURL": "https://github.com/slackhq/PanModal.git", + "state": { + "branch": null, + "revision": "b012aecb6b67a8e46369227f893c12544846613f", + "version": "1.2.7" + } + }, { "package": "SDWebImage", "repositoryURL": "https://github.com/SDWebImage/SDWebImage.git", "state": { "branch": null, - "revision": "76dd4b49110b8624317fc128e7fa0d8a252018bc", - "version": "5.11.1" + "revision": "a72df4849408da7e5d3c1b586797b7c601c41d1b", + "version": "5.12.1" } }, { @@ -141,8 +150,8 @@ "repositoryURL": "https://github.com/apple/swift-collections.git", "state": { "branch": null, - "revision": "0959ba76a1d4a98fd11163aa83fd49c25b93bfae", - "version": "0.0.5" + "revision": "9d8719c8bebdc79740b6969c912ac706eb721d7a", + "version": "0.0.7" } }, { @@ -150,8 +159,8 @@ "repositoryURL": "https://github.com/apple/swift-nio.git", "state": { "branch": null, - "revision": "8da5c5a4e6c5084c296b9f39dc54f00be146e0fa", - "version": "1.14.2" + "revision": "546610d52b19be3e19935e0880bb06b9c03f5cef", + "version": "1.14.4" } }, { @@ -207,15 +216,6 @@ "revision": "dad97167bf1be16aeecd109130900995dd01c515", "version": "2.6.0" } - }, - { - "package": "UITextView+Placeholder", - "repositoryURL": "https://github.com/MainasuK/UITextView-Placeholder", - "state": { - "branch": null, - "revision": "20f513ded04a040cdf5467f0891849b1763ede3b", - "version": "1.4.1" - } } ] }, diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index 04804dde3..9fbb2b774 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -5,24 +5,132 @@ // Created by Cirno MainasuK on 2021-1-27. import UIKit +import Combine import SafariServices import CoreDataStack +import MastodonSDK +import PanModal final public class SceneCoordinator { + private var disposeBag = Set() + private weak var scene: UIScene! private weak var sceneDelegate: SceneDelegate! private weak var appContext: AppContext! - private(set) weak var tabBarController: MainTabBarController! let id = UUID().uuidString + private(set) weak var tabBarController: MainTabBarController! + private(set) weak var splitViewController: RootSplitViewController? + private(set) var wizardViewController: WizardViewController? + + private(set) var secondaryStackHashValues = Set() + init(scene: UIScene, sceneDelegate: SceneDelegate, appContext: AppContext) { self.scene = scene self.sceneDelegate = sceneDelegate self.appContext = appContext scene.session.sceneCoordinator = self + + appContext.notificationService.requestRevealNotificationPublisher + .receive(on: DispatchQueue.main) + .compactMap { [weak self] pushNotification -> AnyPublisher in + guard let self = self else { return Just(nil).eraseToAnyPublisher() } + // skip if no available account + guard let currentActiveAuthenticationBox = appContext.authenticationService.activeMastodonAuthenticationBox.value else { + return Just(nil).eraseToAnyPublisher() + } + + let accessToken = pushNotification._accessToken // use raw accessToken value without normalize + if currentActiveAuthenticationBox.userAuthorization.accessToken == accessToken { + // do nothing if notification for current account + return Just(pushNotification).eraseToAnyPublisher() + } else { + // switch to notification's account + let request = MastodonAuthentication.sortedFetchRequest + request.predicate = MastodonAuthentication.predicate(userAccessToken: accessToken) + request.returnsObjectsAsFaults = false + request.fetchLimit = 1 + do { + guard let authentication = try appContext.managedObjectContext.fetch(request).first else { + return Just(nil).eraseToAnyPublisher() + } + let domain = authentication.domain + let userID = authentication.userID + return appContext.authenticationService.activeMastodonUser(domain: domain, userID: userID) + .receive(on: DispatchQueue.main) + .map { [weak self] result -> MastodonPushNotification? in + guard let self = self else { return nil } + switch result { + case .success: + // reset view hierarchy + self.setup() + return pushNotification + case .failure: + return nil + } + } + .delay(for: 1, scheduler: DispatchQueue.main) // set delay to slow transition (not must) + .eraseToAnyPublisher() + } catch { + assertionFailure(error.localizedDescription) + return Just(nil).eraseToAnyPublisher() + } + } + } + .switchToLatest() + .receive(on: DispatchQueue.main) + .sink { [weak self] pushNotification in + guard let self = self else { return } + guard let pushNotification = pushNotification else { return } + + // redirect to notification tab + self.switchToTabBar(tab: .notification) + + + // Delay in next run loop + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + // Note: + // show (push) on phone and pad + let from: UIViewController? = { + if let splitViewController = self.splitViewController { + if splitViewController.compactMainTabBarViewController.topMost?.view.window != nil { + // compact + return splitViewController.compactMainTabBarViewController.topMost + } else { + // expand + return splitViewController.contentSplitViewController.mainTabBarController.topMost + } + } else { + return self.tabBarController.topMost + } + }() + + // show notification related content + guard let type = Mastodon.Entity.Notification.NotificationType(rawValue: pushNotification.notificationType) else { return } + let notificationID = String(pushNotification.notificationID) + + switch type { + case .follow: + let profileViewModel = RemoteProfileViewModel(context: appContext, notificationID: notificationID) + self.present(scene: .profile(viewModel: profileViewModel), from: from, transition: .show) + case .followRequest: + // do nothing + break + case .mention, .reblog, .favourite, .poll, .status: + let threadViewModel = RemoteThreadViewModel(context: appContext, notificationID: notificationID) + self.present(scene: .thread(viewModel: threadViewModel), from: from, transition: .show) + case ._other: + assertionFailure() + break + } + } // end DispatchQueue.main.async + } + .store(in: &disposeBag) } } @@ -31,6 +139,8 @@ extension SceneCoordinator { case show // push case showDetail // replace case modal(animated: Bool, completion: (() -> Void)? = nil) + case popover(sourceView: UIView) + case panModal case custom(transitioningDelegate: UIViewControllerTransitioningDelegate) case customPush case safariPresent(animated: Bool, completion: (() -> Void)? = nil) @@ -66,9 +176,12 @@ extension SceneCoordinator { case hashtagTimeline(viewModel: HashtagTimelineViewModel) // profile + case accountList case profile(viewModel: ProfileViewModel) case favorite(viewModel: FavoriteViewModel) - + case follower(viewModel: FollowerListViewModel) + case following(viewModel: FollowingListViewModel) + // setting case settings(viewModel: SettingsViewModel) @@ -109,9 +222,34 @@ extension SceneCoordinator { extension SceneCoordinator { func setup() { - let viewController = MainTabBarController(context: appContext, coordinator: self) - sceneDelegate.window?.rootViewController = viewController - tabBarController = viewController + let rootViewController: UIViewController + switch UIDevice.current.userInterfaceIdiom { + case .phone: + let viewController = MainTabBarController(context: appContext, coordinator: self) + self.splitViewController = nil + self.tabBarController = viewController + rootViewController = viewController + default: + let splitViewController = RootSplitViewController(context: appContext, coordinator: self) + self.splitViewController = splitViewController + self.tabBarController = splitViewController.contentSplitViewController.mainTabBarController + rootViewController = splitViewController + } + + let wizardViewController = WizardViewController() + if !wizardViewController.items.isEmpty, + let delegate = rootViewController as? WizardViewControllerDelegate + { + // do not add as child view controller. + // otherwise, the tab bar controller will add as a new tab + wizardViewController.delegate = delegate + wizardViewController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + wizardViewController.view.frame = rootViewController.view.bounds + rootViewController.view.addSubview(wizardViewController.view) + self.wizardViewController = wizardViewController + } + + sceneDelegate.window?.rootViewController = rootViewController } func setupOnboardingIfNeeds(animated: Bool) { @@ -165,8 +303,8 @@ extension SceneCoordinator { switch transition { case .show: presentingViewController.show(viewController, sender: sender) - case .showDetail: + secondaryStackHashValues.insert(viewController.hashValue) let navigationController = AdaptiveStatusBarStyleNavigationController(rootViewController: viewController) presentingViewController.showDetailViewController(navigationController, sender: sender) @@ -183,11 +321,27 @@ extension SceneCoordinator { modalNavigationController.presentationController?.delegate = adaptivePresentationControllerDelegate } presentingViewController.present(modalNavigationController, animated: animated, completion: completion) + + case .panModal: + guard let panModalPresentable = viewController as? PanModalPresentable & UIViewController else { + assertionFailure() + return nil + } + // https://github.com/slackhq/PanModal/issues/74#issuecomment-572426441 + panModalPresentable.modalPresentationStyle = .custom + panModalPresentable.modalPresentationCapturesStatusBarAppearance = true + panModalPresentable.transitioningDelegate = PanModalPresentationDelegate.default + presentingViewController.present(panModalPresentable, animated: true, completion: nil) + //presentingViewController.presentPanModal(panModalPresentable) + case .popover(let sourceView): + viewController.modalPresentationStyle = .popover + viewController.popoverPresentationController?.sourceView = sourceView + (splitViewController ?? presentingViewController)?.present(viewController, animated: true, completion: nil) case .custom(let transitioningDelegate): viewController.modalPresentationStyle = .custom viewController.transitioningDelegate = transitioningDelegate - sender?.present(viewController, animated: true, completion: nil) + (splitViewController ?? presentingViewController)?.present(viewController, animated: true, completion: nil) case .customPush: // set delegate in view controller @@ -215,7 +369,13 @@ extension SceneCoordinator { } func switchToTabBar(tab: MainTabBarController.Tab) { + splitViewController?.contentSplitViewController.currentSupplementaryTab = tab + + splitViewController?.compactMainTabBarViewController.selectedIndex = tab.rawValue + splitViewController?.compactMainTabBarViewController.currentTab.value = tab + tabBarController.selectedIndex = tab.rawValue + tabBarController.currentTab.value = tab } } @@ -273,6 +433,9 @@ private extension SceneCoordinator { let _viewController = HashtagTimelineViewController() _viewController.viewModel = viewModel viewController = _viewController + case .accountList: + let _viewController = AccountListViewController() + viewController = _viewController case .profile(let viewModel): let _viewController = ProfileViewController() _viewController.viewModel = viewModel @@ -281,6 +444,14 @@ private extension SceneCoordinator { let _viewController = FavoriteViewController() _viewController.viewModel = viewModel viewController = _viewController + case .follower(let viewModel): + let _viewController = FollowerListViewController() + _viewController.viewModel = viewModel + viewController = _viewController + case .following(let viewModel): + let _viewController = FollowingListViewController() + _viewController.viewModel = viewModel + viewController = _viewController case .suggestionAccount(let viewModel): let _viewController = SuggestionAccountViewController() _viewController.viewModel = viewModel diff --git a/Mastodon/Diffiable/DataSource/TableNodeDiffableDataSource.swift b/Mastodon/Diffiable/DataSource/TableNodeDiffableDataSource.swift index 155c8d8af..54ab22a4c 100644 --- a/Mastodon/Diffiable/DataSource/TableNodeDiffableDataSource.swift +++ b/Mastodon/Diffiable/DataSource/TableNodeDiffableDataSource.swift @@ -32,7 +32,7 @@ open class TableNodeDiffableDataSource UITableViewDiffableDataSource { UITableViewDiffableDataSource(tableView: tableView) { [weak delegate, weak dependency] @@ -32,137 +33,47 @@ extension NotificationSection { switch notificationItem { case .notification(let objectID, let attribute): guard let notification = try? managedObjectContext.existingObject(with: objectID) as? MastodonNotification, - !notification.isDeleted else { - return UITableViewCell() - } + !notification.isDeleted + else { return UITableViewCell() } let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: NotificationStatusTableViewCell.self), for: indexPath) as! NotificationStatusTableViewCell - cell.delegate = delegate - - // configure author - cell.configure( - with: AvatarConfigurableViewConfiguration( - avatarImageURL: notification.account.avatarImageURL() - ) + configure( + tableView: tableView, + cell: cell, + notification: notification, + dependency: dependency, + attribute: attribute ) + cell.delegate = delegate + cell.isAccessibilityElement = true + NotificationSection.configureStatusAccessibilityLabel(cell: cell) + return cell - func createActionImage() -> UIImage? { - return UIImage( - systemName: notification.notificationType.actionImageName, - withConfiguration: UIImage.SymbolConfiguration( - pointSize: 12, weight: .semibold - ) - )? - .withTintColor(.systemBackground) - .af.imageAspectScaled(toFit: CGSize(width: 14, height: 14)) - } + case .notificationStatus(objectID: let objectID, attribute: let attribute): + guard let notification = try? managedObjectContext.existingObject(with: objectID) as? MastodonNotification, + !notification.isDeleted, + let status = notification.status, + let requestUserID = dependency.context.authenticationService.activeMastodonAuthenticationBox.value?.userID + else { return UITableViewCell() } + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusTableViewCell.self), for: indexPath) as! StatusTableViewCell - cell.avatarButton.badgeImageView.backgroundColor = notification.notificationType.color - cell.avatarButton.badgeImageView.image = createActionImage() - cell.traitCollectionDidChange - .receive(on: DispatchQueue.main) - .sink { [weak cell] in - guard let cell = cell else { return } - cell.avatarButton.badgeImageView.image = createActionImage() - } - .store(in: &cell.disposeBag) - - // configure author name, notification description, timestamp - let nameText = notification.account.displayNameWithFallback - let titleLabelText: String = { - switch notification.notificationType { - case .favourite: return L10n.Scene.Notification.userFavoritedYourPost(nameText) - case .follow: return L10n.Scene.Notification.userFollowedYou(nameText) - case .followRequest: return L10n.Scene.Notification.userRequestedToFollowYou(nameText) - case .mention: return L10n.Scene.Notification.userMentionedYou(nameText) - case .poll: return L10n.Scene.Notification.userYourPollHasEnded(nameText) - case .reblog: return L10n.Scene.Notification.userRebloggedYourPost(nameText) - default: return "" - } - }() - - do { - let nameContent = MastodonContent(content: nameText, emojis: notification.account.emojiMeta) - let nameMetaContent = try MastodonMetaContent.convert(document: nameContent) - - let mastodonContent = MastodonContent(content: titleLabelText, emojis: notification.account.emojiMeta) - let metaContent = try MastodonMetaContent.convert(document: mastodonContent) - - cell.titleLabel.configure(content: metaContent) - - if let nameRange = metaContent.string.range(of: nameMetaContent.string) { - let nsRange = NSRange(nameRange, in: metaContent.string) - cell.titleLabel.textStorage.addAttributes([ - .font: UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .semibold), maximumPointSize: 20), - .foregroundColor: Asset.Colors.brandBlue.color, - ], range: nsRange) - } - - } catch { - let metaContent = PlaintextMetaContent(string: titleLabelText) - cell.titleLabel.configure(content: metaContent) - } - - let createAt = notification.createAt - cell.timestampLabel.text = createAt.localizedSlowedTimeAgoSinceNow - AppContext.shared.timestampUpdatePublisher - .receive(on: DispatchQueue.main) - .sink { [weak cell] _ in - guard let cell = cell else { return } - cell.timestampLabel.text = createAt.localizedSlowedTimeAgoSinceNow - } - .store(in: &cell.disposeBag) - - // configure follow request (if exist) - if case .followRequest = notification.notificationType { - cell.acceptButton.publisher(for: .touchUpInside) - .sink { [weak cell] _ in - guard let cell = cell else { return } - cell.delegate?.notificationTableViewCell(cell, notification: notification, acceptButtonDidPressed: cell.acceptButton) - } - .store(in: &cell.disposeBag) - cell.rejectButton.publisher(for: .touchUpInside) - .sink { [weak cell] _ in - guard let cell = cell else { return } - cell.delegate?.notificationTableViewCell(cell, notification: notification, rejectButtonDidPressed: cell.rejectButton) - } - .store(in: &cell.disposeBag) - cell.buttonStackView.isHidden = false - } else { - cell.buttonStackView.isHidden = true - } - - // configure status (if exist) - if let status = notification.status { - let frame = CGRect( - x: 0, - y: 0, - width: tableView.readableContentGuide.layoutFrame.width - NotificationStatusTableViewCell.statusPadding.left - NotificationStatusTableViewCell.statusPadding.right, - height: tableView.readableContentGuide.layoutFrame.height - ) - StatusSection.configure( - cell: cell, - tableView: tableView, - timelineContext: .notifications, - dependency: dependency, - readableLayoutFrame: frame, - status: status, - requestUserID: notification.userID, - statusItemAttribute: attribute - ) - cell.statusContainerView.isHidden = false - cell.containerStackView.alignment = .top - cell.containerStackViewBottomLayoutConstraint.constant = 0 - } else { - if case .followRequest = notification.notificationType { - cell.containerStackView.alignment = .top - } else { - cell.containerStackView.alignment = .center - } - cell.statusContainerView.isHidden = true - cell.containerStackViewBottomLayoutConstraint.constant = 5 // 5pt margin when no status view - } - + // configure cell + StatusSection.configureStatusTableViewCell( + cell: cell, + tableView: tableView, + timelineContext: .notifications, + dependency: dependency, + readableLayoutFrame: tableView.readableContentGuide.layoutFrame, + status: status, + requestUserID: requestUserID, + statusItemAttribute: attribute + ) + cell.statusView.headerContainerView.isHidden = true // set header hide + cell.statusView.actionToolbarContainer.isHidden = true // set toolbar hide + cell.statusView.actionToolbarPlaceholderPaddingView.isHidden = false + cell.delegate = statusTableViewCellDelegate + cell.isAccessibilityElement = true + StatusSection.configureStatusAccessibilityLabel(cell: cell) return cell case .bottomLoader: @@ -174,3 +85,162 @@ extension NotificationSection { } } +extension NotificationSection { + static func configure( + tableView: UITableView, + cell: NotificationStatusTableViewCell, + notification: MastodonNotification, + dependency: NeedsDependency, + attribute: Item.StatusAttribute + ) { + // configure author + cell.configure( + with: AvatarConfigurableViewConfiguration( + avatarImageURL: notification.account.avatarImageURL() + ) + ) + + func createActionImage() -> UIImage? { + return UIImage( + systemName: notification.notificationType.actionImageName, + withConfiguration: UIImage.SymbolConfiguration( + pointSize: 12, weight: .semibold + ) + )? + .withTintColor(.systemBackground) + .af.imageAspectScaled(toFit: CGSize(width: 14, height: 14)) + } + + cell.avatarButton.badgeImageView.backgroundColor = notification.notificationType.color + cell.avatarButton.badgeImageView.image = createActionImage() + cell.traitCollectionDidChange + .receive(on: DispatchQueue.main) + .sink { [weak cell] in + guard let cell = cell else { return } + cell.avatarButton.badgeImageView.image = createActionImage() + } + .store(in: &cell.disposeBag) + + // configure author name, notification description, timestamp + let nameText = notification.account.displayNameWithFallback + let titleLabelText: String = { + switch notification.notificationType { + case .favourite: return L10n.Scene.Notification.userFavoritedYourPost(nameText) + case .follow: return L10n.Scene.Notification.userFollowedYou(nameText) + case .followRequest: return L10n.Scene.Notification.userRequestedToFollowYou(nameText) + case .mention: return L10n.Scene.Notification.userMentionedYou(nameText) + case .poll: return L10n.Scene.Notification.userYourPollHasEnded(nameText) + case .reblog: return L10n.Scene.Notification.userRebloggedYourPost(nameText) + default: return "" + } + }() + + do { + let nameContent = MastodonContent(content: nameText, emojis: notification.account.emojiMeta) + let nameMetaContent = try MastodonMetaContent.convert(document: nameContent) + + let mastodonContent = MastodonContent(content: titleLabelText, emojis: notification.account.emojiMeta) + let metaContent = try MastodonMetaContent.convert(document: mastodonContent) + + cell.titleLabel.configure(content: metaContent) + + if let nameRange = metaContent.string.range(of: nameMetaContent.string) { + let nsRange = NSRange(nameRange, in: metaContent.string) + cell.titleLabel.textStorage.addAttributes([ + .font: UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .semibold), maximumPointSize: 20), + .foregroundColor: Asset.Colors.brandBlue.color, + ], range: nsRange) + } + + } catch { + let metaContent = PlaintextMetaContent(string: titleLabelText) + cell.titleLabel.configure(content: metaContent) + } + + let createAt = notification.createAt + cell.timestampLabel.text = createAt.localizedSlowedTimeAgoSinceNow + AppContext.shared.timestampUpdatePublisher + .receive(on: DispatchQueue.main) + .sink { [weak cell] _ in + guard let cell = cell else { return } + cell.timestampLabel.text = createAt.localizedSlowedTimeAgoSinceNow + } + .store(in: &cell.disposeBag) + + // configure follow request (if exist) + if case .followRequest = notification.notificationType { + cell.acceptButton.publisher(for: .touchUpInside) + .sink { [weak cell] _ in + guard let cell = cell else { return } + cell.delegate?.notificationTableViewCell(cell, notification: notification, acceptButtonDidPressed: cell.acceptButton) + } + .store(in: &cell.disposeBag) + cell.rejectButton.publisher(for: .touchUpInside) + .sink { [weak cell] _ in + guard let cell = cell else { return } + cell.delegate?.notificationTableViewCell(cell, notification: notification, rejectButtonDidPressed: cell.rejectButton) + } + .store(in: &cell.disposeBag) + cell.buttonStackView.isHidden = false + } else { + cell.buttonStackView.isHidden = true + } + + // configure status (if exist) + if let status = notification.status { + let frame = CGRect( + x: 0, + y: 0, + width: tableView.readableContentGuide.layoutFrame.width - NotificationStatusTableViewCell.statusPadding.left - NotificationStatusTableViewCell.statusPadding.right, + height: tableView.readableContentGuide.layoutFrame.height + ) + StatusSection.configure( + cell: cell, + tableView: tableView, + timelineContext: .notifications, + dependency: dependency, + readableLayoutFrame: frame, + status: status, + requestUserID: notification.userID, + statusItemAttribute: attribute + ) + cell.statusContainerView.isHidden = false + cell.containerStackView.alignment = .top + cell.containerStackViewBottomLayoutConstraint.constant = 0 + } else { + if case .followRequest = notification.notificationType { + cell.containerStackView.alignment = .top + } else { + cell.containerStackView.alignment = .center + } + cell.statusContainerView.isHidden = true + cell.containerStackViewBottomLayoutConstraint.constant = 5 // 5pt margin when no status view + } + } + + static func configureStatusAccessibilityLabel(cell: NotificationStatusTableViewCell) { + // FIXME: + cell.accessibilityLabel = { + var accessibilityViews: [UIView?] = [] + accessibilityViews.append(contentsOf: [ + cell.titleLabel, + cell.timestampLabel, + cell.statusView + ]) + if !cell.statusContainerView.isHidden { + if !cell.statusView.headerContainerView.isHidden { + accessibilityViews.append(cell.statusView.headerInfoLabel) + } + accessibilityViews.append(contentsOf: [ + cell.statusView.nameMetaLabel, + cell.statusView.dateLabel, + cell.statusView.contentMetaText.textView, + ]) + } + return accessibilityViews + .compactMap { $0?.accessibilityLabel } + .joined(separator: " ") + }() + } +} + diff --git a/Mastodon/Diffiable/Section/Status/PollSection.swift b/Mastodon/Diffiable/Section/Status/PollSection.swift index add2a79b2..682a2abc0 100644 --- a/Mastodon/Diffiable/Section/Status/PollSection.swift +++ b/Mastodon/Diffiable/Section/Status/PollSection.swift @@ -37,6 +37,15 @@ extension PollSection { managedObjectContext.performAndWait { let option = managedObjectContext.object(with: objectID) as! PollOption PollSection.configure(cell: cell, pollOption: option, pollItemAttribute: attribute) + + cell.isAccessibilityElement = true + cell.accessibilityLabel = { + var labels: [String] = [option.title] + if let percentage = cell.pollOptionView.optionPercentageLabel.text { + labels.append(percentage) + } + return labels.joined(separator: ",") + }() } return cell } diff --git a/Mastodon/Diffiable/Section/Status/StatusSection.swift b/Mastodon/Diffiable/Section/Status/StatusSection.swift index f16311b6a..61217c790 100644 --- a/Mastodon/Diffiable/Section/Status/StatusSection.swift +++ b/Mastodon/Diffiable/Section/Status/StatusSection.swift @@ -67,7 +67,6 @@ extension StatusSection { timelineContext: TimelineContext, dependency: NeedsDependency, managedObjectContext: NSManagedObjectContext, - timestampUpdatePublisher: AnyPublisher, statusTableViewCellDelegate: StatusTableViewCellDelegate, timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate?, threadReplyLoaderTableViewCellDelegate: ThreadReplyLoaderTableViewCellDelegate? @@ -159,6 +158,11 @@ extension StatusSection { accessibilityElements.append(cell.statusView.avatarView) accessibilityElements.append(cell.statusView.nameMetaLabel) accessibilityElements.append(cell.statusView.dateLabel) + // poll + accessibilityElements.append(cell.statusView.pollTableView) + accessibilityElements.append(cell.statusView.pollVoteCountLabel) + accessibilityElements.append(cell.statusView.pollCountdownLabel) + accessibilityElements.append(cell.statusView.pollVoteButton) // TODO: a11y accessibilityElements.append(cell.statusView.contentMetaText.textView) accessibilityElements.append(contentsOf: cell.statusView.statusMosaicImageViewContainer.imageViews) @@ -363,7 +367,6 @@ extension StatusSection { } }() - if status.author.id == requestUserID || status.reblog?.author.id == requestUserID { // do not filter myself } else { @@ -391,7 +394,7 @@ extension StatusSection { // set timestamp let createdAt = (status.reblog ?? status).createdAt cell.statusView.dateLabel.text = createdAt.localizedSlowedTimeAgoSinceNow - cell.statusView.dateLabel.accessibilityValue = createdAt.timeAgoSinceNow + cell.statusView.dateLabel.accessibilityLabel = createdAt.timeAgoSinceNow AppContext.shared.timestampUpdatePublisher .receive(on: RunLoop.main) // will be paused when scrolling (on purpose) .sink { [weak cell] _ in @@ -473,9 +476,10 @@ extension StatusSection { .receive(on: RunLoop.main) .sink { _ in // do nothing - } receiveValue: { [weak cell, weak tableView] change in + } receiveValue: { [weak cell, weak tableView, weak dependency] change in guard let cell = cell else { return } guard let tableView = tableView else { return } + guard let dependency = dependency else { return } guard case .update(let object) = change.changeType, let status = object as? Status, !status.isDeleted else { return @@ -640,7 +644,7 @@ extension StatusSection { ) { if status.reblog != nil { cell.statusView.headerContainerView.isHidden = false - cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.reblogIconImage) + cell.statusView.headerIconLabel.configure(attributedString: StatusView.iconAttributedString(image: StatusView.reblogIconImage)) let headerText: String = { let author = status.author let name = author.displayName.isEmpty ? author.username : author.displayName @@ -658,7 +662,7 @@ extension StatusSection { cell.statusView.headerInfoLabel.isAccessibilityElement = true } else if status.inReplyToID != nil { cell.statusView.headerContainerView.isHidden = false - cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.replyIconImage) + cell.statusView.headerIconLabel.configure(attributedString: StatusView.iconAttributedString(image: StatusView.replyIconImage)) let headerText: String = { guard let replyTo = status.replyTo else { return L10n.Common.Controls.Status.userRepliedTo("-") @@ -721,6 +725,15 @@ extension StatusSection { statusItemAttribute: Item.StatusAttribute ) { // set content + let paragraphStyle = cell.statusView.contentMetaText.paragraphStyle + if let language = (status.reblog ?? status).language { + let direction = Locale.characterDirection(forLanguage: language) + paragraphStyle.alignment = direction == .rightToLeft ? .right : .left + } else { + paragraphStyle.alignment = .natural + } + cell.statusView.contentMetaText.paragraphStyle = paragraphStyle + if let content = content { cell.statusView.contentMetaText.configure(content: content) cell.statusView.contentMetaText.textView.accessibilityLabel = content.trimmed @@ -970,6 +983,7 @@ extension StatusSection { cell.statusView.pollCountdownLabel.text = "-" } + cell.statusView.isUserInteractionEnabled = !poll.expired // make voice over touch passthroughable cell.statusView.pollTableView.allowsSelection = !poll.expired let votedOptions = poll.options.filter { option in @@ -1072,7 +1086,7 @@ extension StatusSection { cell.statusView.actionToolbarContainer.reblogButton.isEnabled = false } } - + // set like let isLike = status.favouritedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false let favoriteCountTitle: String = { @@ -1107,7 +1121,7 @@ extension StatusSection { StatusSection.setupStatusMoreButtonMenu(cell: cell, dependency: dependency, status: status) }) .store(in: &cell.disposeBag) - self.setupStatusMoreButtonMenu(cell: cell, dependency: dependency, status: status) + setupStatusMoreButtonMenu(cell: cell, dependency: dependency, status: status) } static func configureStatusAccessibilityLabel(cell: StatusTableViewCell) { diff --git a/Mastodon/Diffiable/Section/UserSection.swift b/Mastodon/Diffiable/Section/UserSection.swift new file mode 100644 index 000000000..9c7e2f212 --- /dev/null +++ b/Mastodon/Diffiable/Section/UserSection.swift @@ -0,0 +1,64 @@ +// +// UserSection.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-11-1. +// + +import os.log +import UIKit +import CoreData +import CoreDataStack +import MetaTextKit +import MastodonMeta + +enum UserSection: Hashable { + case main +} + +extension UserSection { + + static let logger = Logger(subsystem: "StatusSection", category: "logic") + + static func tableViewDiffableDataSource( + for tableView: UITableView, + dependency: NeedsDependency, + managedObjectContext: NSManagedObjectContext + ) -> UITableViewDiffableDataSource { + UITableViewDiffableDataSource(tableView: tableView) { [ + weak dependency + ] tableView, indexPath, item -> UITableViewCell? in + guard let dependency = dependency else { return UITableViewCell() } + switch item { + case .follower(let objectID), + .following(let objectID): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: UserTableViewCell.self), for: indexPath) as! UserTableViewCell + managedObjectContext.performAndWait { + let user = managedObjectContext.object(with: objectID) as! MastodonUser + configure(cell: cell, user: user) + } + return cell + case .bottomLoader: + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell + cell.startAnimating() + return cell + case .bottomHeader(let text): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineFooterTableViewCell.self), for: indexPath) as! TimelineFooterTableViewCell + cell.messageLabel.text = text + return cell + } // end switch + } // end UITableViewDiffableDataSource + } // end static func tableViewDiffableDataSource { … } + +} + +extension UserSection { + + static func configure( + cell: UserTableViewCell, + user: MastodonUser + ) { + cell.configure(user: user) + } + +} diff --git a/Mastodon/Extension/CoreDataStack/Instance.swift b/Mastodon/Extension/CoreDataStack/Instance.swift new file mode 100644 index 000000000..6cacd9db9 --- /dev/null +++ b/Mastodon/Extension/CoreDataStack/Instance.swift @@ -0,0 +1,25 @@ +// +// Instance.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-10-9. +// + +import UIKit +import CoreDataStack +import MastodonSDK + +extension Instance { + var configuration: Mastodon.Entity.Instance.Configuration? { + guard let configurationRaw = configurationRaw else { return nil } + guard let configuration = try? JSONDecoder().decode(Mastodon.Entity.Instance.Configuration.self, from: configurationRaw) else { + return nil + } + + return configuration + } + + static func encode(configuration: Mastodon.Entity.Instance.Configuration) -> Data? { + return try? JSONEncoder().encode(configuration) + } +} diff --git a/Mastodon/Extension/CoreDataStack/Setting.swift b/Mastodon/Extension/CoreDataStack/Setting.swift index b995b80e3..4d1fc0ca5 100644 --- a/Mastodon/Extension/CoreDataStack/Setting.swift +++ b/Mastodon/Extension/CoreDataStack/Setting.swift @@ -11,9 +11,9 @@ import MastodonSDK extension Setting { - var appearance: SettingsItem.AppearanceMode { - return SettingsItem.AppearanceMode(rawValue: appearanceRaw) ?? .automatic - } +// var appearance: SettingsItem.AppearanceMode { +// return SettingsItem.AppearanceMode(rawValue: appearanceRaw) ?? .automatic +// } var activeSubscription: Subscription? { return (subscriptions ?? Set()) diff --git a/Mastodon/Extension/MetaLabel.swift b/Mastodon/Extension/MetaLabel.swift index 9e7920a8e..cf7d27cc0 100644 --- a/Mastodon/Extension/MetaLabel.swift +++ b/Mastodon/Extension/MetaLabel.swift @@ -20,6 +20,10 @@ extension MetaLabel { case titleView case settingTableFooter case autoCompletion + case accountListName + case accountListUsername + case sidebarHeadline(isSelected: Bool) + case sidebarSubheadline(isSelected: Bool) } convenience init(style: Style) { @@ -30,41 +34,45 @@ extension MetaLabel { textContainer.lineBreakMode = .byTruncatingTail textContainer.lineFragmentPadding = 0 + setup(style: style) + } + + func setup(style: Style) { let font: UIFont let textColor: UIColor - + switch style { case .statusHeader: font = UIFontMetrics(forTextStyle: .footnote).scaledFont(for: .systemFont(ofSize: 13, weight: .medium), maximumPointSize: 17) textColor = Asset.Colors.Label.secondary.color - + case .statusName: font = .systemFont(ofSize: 17, weight: .semibold) textColor = Asset.Colors.Label.primary.color - + case .notificationTitle: font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .regular), maximumPointSize: 20) textColor = Asset.Colors.Label.secondary.color - + case .profileFieldName: font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold), maximumPointSize: 20) textColor = Asset.Colors.Label.primary.color - + case .profileFieldValue: font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .regular), maximumPointSize: 20) textColor = Asset.Colors.Label.primary.color textAlignment = .right - + case .titleView: font = .systemFont(ofSize: 17, weight: .semibold) textColor = Asset.Colors.Label.primary.color textAlignment = .center paragraphStyle.alignment = .center - + case .recommendAccountName: font = .systemFont(ofSize: 18, weight: .semibold) textColor = .white - + case .settingTableFooter: font = .preferredFont(forTextStyle: .footnote) textColor = Asset.Colors.Label.secondary.color @@ -74,8 +82,20 @@ extension MetaLabel { case .autoCompletion: font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold), maximumPointSize: 22) textColor = Asset.Colors.brandBlue.color + case .accountListName: + font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .regular), maximumPointSize: 22) + textColor = Asset.Colors.Label.primary.color + case .accountListUsername: + font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .regular), maximumPointSize: 20) + textColor = Asset.Colors.Label.secondary.color + case .sidebarHeadline(let isSelected): + font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 22, weight: .regular), maximumPointSize: 20) + textColor = isSelected ? .white : Asset.Colors.Label.primary.color + case .sidebarSubheadline(let isSelected): + font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 13, weight: .regular), maximumPointSize: 18) + textColor = isSelected ? .white : Asset.Colors.Label.secondary.color } - + self.font = font self.textColor = textColor @@ -91,6 +111,24 @@ extension MetaLabel { } +extension MetaLabel { + func configure(attributedString: NSAttributedString) { + let attributedString = NSMutableAttributedString(attributedString: attributedString) + + MetaText.setAttributes( + for: attributedString, + textAttributes: textAttributes, + linkAttributes: linkAttributes, + paragraphStyle: paragraphStyle, + content: PlaintextMetaContent(string: "") + ) + + textStorage.setAttributedString(attributedString) + self.attributedText = attributedString + setNeedsDisplay() + } +} + struct PlaintextMetaContent: MetaContent { let string: String let entities: [Meta.Entity] = [] diff --git a/Mastodon/Extension/NSDiffableDataSourceSnapshot.swift b/Mastodon/Extension/NSDiffableDataSourceSnapshot.swift deleted file mode 100644 index c2ff341d9..000000000 --- a/Mastodon/Extension/NSDiffableDataSourceSnapshot.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// NSDiffableDataSourceSnapshot.swift -// Mastodon -// -// Created by Cirno MainasuK on 2021-6-19. -// - -import UIKit - -//extension NSDiffableDataSourceSnapshot { -// func itemIdentifier(for indexPath: IndexPath) -> ItemIdentifierType? { -// guard 0.., + completion: (() -> Void)? = nil + ) { + if #available(iOS 15.0, *) { + self.applySnapshotUsingReloadData(snapshot, completion: completion) + } else { + self.apply(snapshot, animatingDifferences: false, completion: completion) + } + } + + func applySnapshot( + _ snapshot: NSDiffableDataSourceSnapshot, + animated: Bool, + completion: (() -> Void)? = nil) { + + if #available(iOS 15.0, *) { + self.apply(snapshot, animatingDifferences: animated, completion: completion) + } else { + if animated { + self.apply(snapshot, animatingDifferences: true, completion: completion) + } else { + UIView.performWithoutAnimation { + self.apply(snapshot, animatingDifferences: true, completion: completion) + } + } + } + } +} diff --git a/Mastodon/Extension/UITableViewDiffableDataSource.swift b/Mastodon/Extension/UITableViewDiffableDataSource.swift new file mode 100644 index 000000000..5006417a4 --- /dev/null +++ b/Mastodon/Extension/UITableViewDiffableDataSource.swift @@ -0,0 +1,40 @@ +// +// UITableViewDiffableDataSource.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-10-11. +// + +import UIKit + +// ref: https://www.jessesquires.com/blog/2021/07/08/diffable-data-source-behavior-changes-and-reconfiguring-cells-in-ios-15/ +extension UITableViewDiffableDataSource { + func reloadData( + snapshot: NSDiffableDataSourceSnapshot, + completion: (() -> Void)? = nil + ) { + if #available(iOS 15.0, *) { + self.applySnapshotUsingReloadData(snapshot, completion: completion) + } else { + self.apply(snapshot, animatingDifferences: false, completion: completion) + } + } + + func applySnapshot( + _ snapshot: NSDiffableDataSourceSnapshot, + animated: Bool, + completion: (() -> Void)? = nil) { + + if #available(iOS 15.0, *) { + self.apply(snapshot, animatingDifferences: animated, completion: completion) + } else { + if animated { + self.apply(snapshot, animatingDifferences: true, completion: completion) + } else { + UIView.performWithoutAnimation { + self.apply(snapshot, animatingDifferences: true, completion: completion) + } + } + } + } +} diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index f3ef1088e..906dd74e2 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -70,6 +70,7 @@ internal enum Asset { internal static let valid = ColorAsset(name: "Colors/TextField/valid") } internal static let alertYellow = ColorAsset(name: "Colors/alert.yellow") + internal static let badgeBackground = ColorAsset(name: "Colors/badge.background") internal static let battleshipGrey = ColorAsset(name: "Colors/battleshipGrey") internal static let brandBlue = ColorAsset(name: "Colors/brand.blue") internal static let brandBlueDarken20 = ColorAsset(name: "Colors/brand.blue.darken.20") @@ -95,6 +96,9 @@ internal enum Asset { internal static let usernameGray = ColorAsset(name: "Scene/Profile/Banner/username.gray") } } + internal enum Sidebar { + internal static let logo = ImageAsset(name: "Scene/Sidebar/logo") + } internal enum Welcome { internal enum Illustration { internal static let backgroundCyan = ColorAsset(name: "Scene/Welcome/illustration/background.cyan") @@ -125,6 +129,7 @@ internal enum Asset { internal static let profileFieldCollectionViewBackground = ColorAsset(name: "Theme/Mastodon/profile.field.collection.view.background") internal static let secondaryGroupedSystemBackground = ColorAsset(name: "Theme/Mastodon/secondary.grouped.system.background") internal static let secondarySystemBackground = ColorAsset(name: "Theme/Mastodon/secondary.system.background") + internal static let sidebarBackground = ColorAsset(name: "Theme/Mastodon/sidebar.background") internal static let systemBackground = ColorAsset(name: "Theme/Mastodon/system.background") internal static let systemElevatedBackground = ColorAsset(name: "Theme/Mastodon/system.elevated.background") internal static let systemGroupedBackground = ColorAsset(name: "Theme/Mastodon/system.grouped.background") @@ -144,6 +149,7 @@ internal enum Asset { internal static let profileFieldCollectionViewBackground = ColorAsset(name: "Theme/system/profile.field.collection.view.background") internal static let secondaryGroupedSystemBackground = ColorAsset(name: "Theme/system/secondary.grouped.system.background") internal static let secondarySystemBackground = ColorAsset(name: "Theme/system/secondary.system.background") + internal static let sidebarBackground = ColorAsset(name: "Theme/system/sidebar.background") internal static let systemBackground = ColorAsset(name: "Theme/system/system.background") internal static let systemElevatedBackground = ColorAsset(name: "Theme/system/system.elevated.background") internal static let systemGroupedBackground = ColorAsset(name: "Theme/system/system.grouped.background") @@ -157,23 +163,6 @@ internal enum Asset { internal static let tabBarItemInactiveIconColor = ColorAsset(name: "Theme/system/tab.bar.item.inactive.icon.color") } } - internal enum Deprecated { - internal enum Background { - internal static let danger = ColorAsset(name: "_Deprecated/Background/danger") - internal static let onboardingBackground = ColorAsset(name: "_Deprecated/Background/onboarding.background") - internal static let secondaryGroupedSystemBackground = ColorAsset(name: "_Deprecated/Background/secondary.grouped.system.background") - internal static let secondarySystemBackground = ColorAsset(name: "_Deprecated/Background/secondary.system.background") - internal static let systemBackground = ColorAsset(name: "_Deprecated/Background/system.background") - internal static let systemElevatedBackground = ColorAsset(name: "_Deprecated/Background/system.elevated.background") - internal static let systemGroupedBackground = ColorAsset(name: "_Deprecated/Background/system.grouped.background") - internal static let tertiarySystemBackground = ColorAsset(name: "_Deprecated/Background/tertiary.system.background") - internal static let tertiarySystemGroupedBackground = ColorAsset(name: "_Deprecated/Background/tertiary.system.grouped.background") - } - internal enum Compose { - internal static let background = ColorAsset(name: "_Deprecated/Compose/background") - internal static let toolbarBackground = ColorAsset(name: "_Deprecated/Compose/toolbar.background") - } - } } // swiftlint:enable identifier_name line_length nesting type_body_length type_name diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 86dc89025..ebf9869c4 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -106,6 +106,8 @@ internal enum L10n { } /// Cancel internal static let cancel = L10n.tr("Localizable", "Common.Controls.Actions.Cancel") + /// Compose + internal static let compose = L10n.tr("Localizable", "Common.Controls.Actions.Compose") /// Confirm internal static let confirm = L10n.tr("Localizable", "Common.Controls.Actions.Confirm") /// Continue @@ -365,6 +367,16 @@ internal enum L10n { } internal enum Scene { + internal enum AccountList { + /// Add Account + internal static let addAccount = L10n.tr("Localizable", "Scene.AccountList.AddAccount") + /// Dismiss Account Switcher + internal static let dismissAccountSwitcher = L10n.tr("Localizable", "Scene.AccountList.DismissAccountSwitcher") + /// Current selected profile: %@. Double tap then hold to show account switcher + internal static func tabBarHint(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.AccountList.TabBarHint", String(describing: p1)) + } + } internal enum Compose { /// Publish internal static let composeAction = L10n.tr("Localizable", "Scene.Compose.ComposeAction") @@ -513,6 +525,14 @@ internal enum L10n { /// Your Favorites internal static let title = L10n.tr("Localizable", "Scene.Favorite.Title") } + internal enum Follower { + /// Followers from other servers are not displayed. + internal static let footer = L10n.tr("Localizable", "Scene.Follower.Footer") + } + internal enum Following { + /// Follows from other servers are not displayed. + internal static let footer = L10n.tr("Localizable", "Scene.Following.Footer") + } internal enum HomeTimeline { /// Home internal static let title = L10n.tr("Localizable", "Scene.HomeTimeline.Title") @@ -973,6 +993,14 @@ internal enum L10n { /// Social networking\nback in your hands. internal static let slogan = L10n.tr("Localizable", "Scene.Welcome.Slogan") } + internal enum Wizard { + /// Double tap to dismiss this wizard + internal static let accessibilityHint = L10n.tr("Localizable", "Scene.Wizard.AccessibilityHint") + /// Switch between multiple accounts by holding the profile button. + internal static let multipleAccountSwitchIntroDescription = L10n.tr("Localizable", "Scene.Wizard.MultipleAccountSwitchIntroDescription") + /// New in Mastodon + internal static let newInMastodon = L10n.tr("Localizable", "Scene.Wizard.NewInMastodon") + } } internal enum A11y { @@ -986,6 +1014,12 @@ internal enum L10n { internal static func inputLimitRemains(_ p1: Int) -> String { return L10n.tr("Localizable", "a11y.plural.count.input_limit_remains", p1) } + internal enum Unread { + /// Plural format key: "%#@notification_count_unread_notification@" + internal static func notification(_ p1: Int) -> String { + return L10n.tr("Localizable", "a11y.plural.count.unread.notification", p1) + } + } } } } diff --git a/Mastodon/Helper/MastodonLocalCode.swift b/Mastodon/Helper/MastodonLocalCode.swift new file mode 100644 index 000000000..65d71895e --- /dev/null +++ b/Mastodon/Helper/MastodonLocalCode.swift @@ -0,0 +1,12 @@ +// +// MastodonLocalCode.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-11-4. +// + +import Foundation + +// https://github.com/gunchleoc/mastodon/blob/ed6153b8f24d3a8f5a124cc95683bd1f20aec882/app/helpers/settings_helper.rb +// last update 2021/11/4 +typealias MastodonLocalCode = [String: String] diff --git a/Mastodon/Info.plist b/Mastodon/Info.plist index 8190cdbbe..affa5b059 100644 --- a/Mastodon/Info.plist +++ b/Mastodon/Info.plist @@ -2,6 +2,8 @@ + CADisableMinimumFrameDurationOnPhone + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable @@ -15,7 +17,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.0.8 + 1.2.0 CFBundleURLTypes @@ -28,7 +30,7 @@ CFBundleVersion - 60 + 88 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes @@ -109,5 +111,7 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + UIViewControllerBasedStatusBarAppearance + diff --git a/Mastodon/Preference/StoreReviewPreference.swift b/Mastodon/Preference/StoreReviewPreference.swift new file mode 100644 index 000000000..e3a403f6d --- /dev/null +++ b/Mastodon/Preference/StoreReviewPreference.swift @@ -0,0 +1,26 @@ +// +// StoreReviewPreference.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-11-3. +// + +import Foundation + +extension UserDefaults { + + @objc dynamic var processCompletedCount: Int { + get { + return integer(forKey: #function) + } + set { self[#function] = newValue } + } + + @objc dynamic var lastVersionPromptedForReview: String? { + get { + return string(forKey: #function) + } + set { self[#function] = newValue } + } + +} diff --git a/Mastodon/Preference/WizardPreference.swift b/Mastodon/Preference/WizardPreference.swift new file mode 100644 index 000000000..c34e34a8a --- /dev/null +++ b/Mastodon/Preference/WizardPreference.swift @@ -0,0 +1,15 @@ +// +// WizardPreference.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-9-15. +// + +import UIKit + +extension UserDefaults { + @objc dynamic var didShowMultipleAccountSwitchWizard: Bool { + get { return bool(forKey: #function) } + set { self[#function] = newValue } + } +} diff --git a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift index 4c34c6749..d11870ed2 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift @@ -259,8 +259,8 @@ extension StatusProviderFacade { guard let context = provider.context else { return } // haptic feedback generator - let generator = UIImpactFeedbackGenerator(style: .light) - let responseFeedbackGenerator = UINotificationFeedbackGenerator() + let generator = UISelectionFeedbackGenerator() + // let responseFeedbackGenerator = UINotificationFeedbackGenerator() status .compactMap { status -> (NSManagedObjectID, Mastodon.API.Favorites.FavoriteKind)? in @@ -287,10 +287,10 @@ extension StatusProviderFacade { .handleEvents(receiveSubscription: { _ in generator.prepare() }, receiveOutput: { _, favoriteKind in - generator.impactOccurred() + generator.selectionChanged() os_log("%{public}s[%{public}ld], %{public}s: [Like] update local status like status to: %s", ((#file as NSString).lastPathComponent), #line, #function, favoriteKind == .create ? "like" : "unlike") }, receiveCompletion: { completion in - responseFeedbackGenerator.prepare() + // responseFeedbackGenerator.prepare() switch completion { case .failure: // TODO: handle error @@ -312,10 +312,10 @@ extension StatusProviderFacade { guard let _ = provider else { return } switch completion { case .failure(let error): - responseFeedbackGenerator.notificationOccurred(.error) + // responseFeedbackGenerator.notificationOccurred(.error) os_log("%{public}s[%{public}ld], %{public}s: [Like] remote like request fail: %{public}s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) case .finished: - responseFeedbackGenerator.notificationOccurred(.success) + // responseFeedbackGenerator.notificationOccurred(.success) os_log("%{public}s[%{public}ld], %{public}s: [Like] remote like request success", ((#file as NSString).lastPathComponent), #line, #function) } } receiveValue: { response in @@ -368,8 +368,8 @@ extension StatusProviderFacade { guard let context = provider.context else { return } // haptic feedback generator - let generator = UIImpactFeedbackGenerator(style: .light) - let responseFeedbackGenerator = UINotificationFeedbackGenerator() + let generator = UISelectionFeedbackGenerator() + // let responseFeedbackGenerator = UINotificationFeedbackGenerator() status .compactMap { status -> (NSManagedObjectID, Mastodon.API.Reblog.ReblogKind)? in @@ -396,7 +396,7 @@ extension StatusProviderFacade { .handleEvents(receiveSubscription: { _ in generator.prepare() }, receiveOutput: { _, reblogKind in - generator.impactOccurred() + generator.selectionChanged() switch reblogKind { case .reblog: os_log("%{public}s[%{public}ld], %{public}s: [Reblog] update local status reblog status to: %s", ((#file as NSString).lastPathComponent), #line, #function, "reblog") @@ -404,7 +404,7 @@ extension StatusProviderFacade { os_log("%{public}s[%{public}ld], %{public}s: [Reblog] update local status reblog status to: %s", ((#file as NSString).lastPathComponent), #line, #function, "unreblog") } }, receiveCompletion: { completion in - responseFeedbackGenerator.prepare() + // responseFeedbackGenerator.prepare() switch completion { case .failure: // TODO: handle error @@ -426,10 +426,10 @@ extension StatusProviderFacade { guard let _ = provider else { return } switch completion { case .failure(let error): - responseFeedbackGenerator.notificationOccurred(.error) + // responseFeedbackGenerator.notificationOccurred(.error) os_log("%{public}s[%{public}ld], %{public}s: [Reblog] remote reblog request fail: %{public}s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) case .finished: - responseFeedbackGenerator.notificationOccurred(.success) + // responseFeedbackGenerator.notificationOccurred(.success) os_log("%{public}s[%{public}ld], %{public}s: [Reblog] remote reblog request success", ((#file as NSString).lastPathComponent), #line, #function) } } receiveValue: { response in @@ -469,8 +469,8 @@ extension StatusProviderFacade { guard let provider = provider else { return } guard let status = status?.reblog ?? status else { return } - let generator = UIImpactFeedbackGenerator(style: .light) - generator.impactOccurred() + let generator = UISelectionFeedbackGenerator() + generator.selectionChanged() let composeViewModel = ComposeViewModel(context: provider.context, composeKind: .reply(repliedToStatusObjectID: status.objectID)) provider.coordinator.present(scene: .compose(viewModel: composeViewModel), from: provider, transition: .modal(animated: true, completion: nil)) diff --git a/Mastodon/Protocol/UserProvider/UserProviderFacade+UITableViewDelegate.swift b/Mastodon/Protocol/UserProvider/UserProviderFacade+UITableViewDelegate.swift new file mode 100644 index 000000000..a6e3cf215 --- /dev/null +++ b/Mastodon/Protocol/UserProvider/UserProviderFacade+UITableViewDelegate.swift @@ -0,0 +1,22 @@ +// +// UserProviderFacade+UITableViewDelegate.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-11-1. +// + +import Combine +import CoreDataStack +import MastodonSDK +import os.log +import UIKit + +extension UserTableViewCellDelegate where Self: UserProvider { + + func handleTableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard let cell = tableView.cellForRow(at: indexPath) else { return } + let user = self.mastodonUser(for: cell) + UserProviderFacade.coordinatorToUserProfileScene(provider: self, user: user) + } + +} diff --git a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift index bf634b07b..edbe311c7 100644 --- a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift +++ b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift @@ -212,7 +212,14 @@ extension UserProviderFacade { let name = mastodonUser.displayNameWithFallback if let shareUser = shareUser { - let shareAction = UIAction(title: L10n.Common.Controls.Actions.shareUser(name), image: UIImage(systemName: "square.and.arrow.up"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak provider] _ in + let shareAction = UIAction( + title: L10n.Common.Controls.Actions.shareUser(name), + image: UIImage(systemName: "square.and.arrow.up"), + identifier: nil, + discoverabilityTitle: nil, + attributes: [], + state: .off + ) { [weak provider, weak sourceView, weak barButtonItem] _ in guard let provider = provider else { return } let activityViewController = createActivityViewControllerForMastodonUser(mastodonUser: shareUser, dependency: provider) provider.coordinator.present( @@ -229,7 +236,14 @@ extension UserProviderFacade { } if let shareStatus = shareStatus { - let shareAction = UIAction(title: L10n.Common.Controls.Actions.sharePost, image: UIImage(systemName: "square.and.arrow.up"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak provider] _ in + let shareAction = UIAction( + title: L10n.Common.Controls.Actions.sharePost, + image: UIImage(systemName: "square.and.arrow.up"), + identifier: nil, + discoverabilityTitle: nil, + attributes: [], + state: .off + ) { [weak provider, weak sourceView, weak barButtonItem] _ in guard let provider = provider else { return } let activityViewController = createActivityViewControllerForMastodonUser(status: shareStatus, dependency: provider) provider.coordinator.present( @@ -253,7 +267,7 @@ extension UserProviderFacade { discoverabilityTitle: isMuting ? nil : L10n.Common.Controls.Friendship.muteUser(name), attributes: isMuting ? [] : .destructive, state: .off - ) { [weak provider] _ in + ) { [weak provider, weak cell] _ in guard let provider = provider else { return } UserProviderFacade.toggleUserMuteRelationship( @@ -283,7 +297,7 @@ extension UserProviderFacade { discoverabilityTitle: isBlocking ? nil : L10n.Common.Controls.Friendship.blockUser(name), attributes: isBlocking ? [] : .destructive, state: .off - ) { [weak provider] _ in + ) { [weak provider, weak cell] _ in guard let provider = provider else { return } UserProviderFacade.toggleUserBlockRelationship( @@ -306,7 +320,14 @@ extension UserProviderFacade { } if !isMyself { - let reportAction = UIAction(title: L10n.Common.Controls.Actions.reportUser(name), image: UIImage(systemName: "flag"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak provider] _ in + let reportAction = UIAction( + title: L10n.Common.Controls.Actions.reportUser(name), + image: UIImage(systemName: "flag"), + identifier: nil, + discoverabilityTitle: nil, + attributes: [], + state: .off + ) { [weak provider] _ in guard let provider = provider else { return } guard let authenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { return @@ -328,19 +349,34 @@ extension UserProviderFacade { if !isInSameDomain { if isDomainBlocking { - let unblockDomainAction = UIAction(title: L10n.Common.Controls.Actions.unblockDomain(mastodonUser.domainFromAcct), image: UIImage(systemName: "nosign"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak provider] _ in + let unblockDomainAction = UIAction( + title: L10n.Common.Controls.Actions.unblockDomain(mastodonUser.domainFromAcct), + image: UIImage(systemName: "nosign"), + identifier: nil, + discoverabilityTitle: nil, + attributes: [], + state: .off + ) { [weak provider, weak cell] _ in guard let provider = provider else { return } provider.context.blockDomainService.unblockDomain(userProvider: provider, cell: cell) } children.append(unblockDomainAction) } else { - let blockDomainAction = UIAction(title: L10n.Common.Controls.Actions.blockDomain(mastodonUser.domainFromAcct), image: UIImage(systemName: "nosign"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak provider] _ in + let blockDomainAction = UIAction( + title: L10n.Common.Controls.Actions.blockDomain(mastodonUser.domainFromAcct), + image: UIImage(systemName: "nosign"), + identifier: nil, + discoverabilityTitle: nil, + attributes: [], + state: .off + ) { [weak provider, weak cell] _ in guard let provider = provider else { return } + let alertController = UIAlertController(title: L10n.Common.Alerts.BlockDomain.title(mastodonUser.domainFromAcct), message: nil, preferredStyle: .alert) - let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .default) { _ in - } + let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .default) { _ in } alertController.addAction(cancelAction) - let blockDomainAction = UIAlertAction(title: L10n.Common.Alerts.BlockDomain.blockEntireDomain, style: .destructive) { _ in + let blockDomainAction = UIAlertAction(title: L10n.Common.Alerts.BlockDomain.blockEntireDomain, style: .destructive) { [weak provider, weak cell] _ in + guard let provider = provider else { return } provider.context.blockDomainService.blockDomain(userProvider: provider, cell: cell) } alertController.addAction(blockDomainAction) @@ -351,19 +387,26 @@ extension UserProviderFacade { } if let status = shareStatus, isMyself { - let deleteAction = UIAction(title: L10n.Common.Controls.Actions.delete, image: UIImage(systemName: "delete.left"), identifier: nil, discoverabilityTitle: nil, attributes: [.destructive], state: .off) { - [weak provider] _ in + let deleteAction = UIAction( + title: L10n.Common.Controls.Actions.delete, + image: UIImage(systemName: "delete.left"), + identifier: nil, + discoverabilityTitle: nil, + attributes: [.destructive], + state: .off + ) { [weak provider] _ in guard let provider = provider else { return } - + let alertController = UIAlertController(title: L10n.Common.Alerts.DeletePost.title, message: nil, preferredStyle: .alert) - let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .default) { _ in - } + let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .default) { _ in } alertController.addAction(cancelAction) - let deleteAction = UIAlertAction(title: L10n.Common.Alerts.DeletePost.delete, style: .destructive) { _ in + let deleteAction = UIAlertAction(title: L10n.Common.Alerts.DeletePost.delete, style: .destructive) { [weak provider] _ in + guard let provider = provider else { return } guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { return } - provider.context.apiService.deleteStatus(domain: activeMastodonAuthenticationBox.domain, - statusID: status.id, - authorizationBox: activeMastodonAuthenticationBox + provider.context.apiService.deleteStatus( + domain: activeMastodonAuthenticationBox.domain, + statusID: status.id, + authorizationBox: activeMastodonAuthenticationBox ) .sink { _ in // do nothing @@ -374,7 +417,6 @@ extension UserProviderFacade { } alertController.addAction(deleteAction) provider.present(alertController, animated: true, completion: nil) - } children.append(deleteAction) } @@ -398,3 +440,25 @@ extension UserProviderFacade { return activityViewController } } + +extension UserProviderFacade { + static func coordinatorToUserProfileScene(provider: UserProvider, user: Future) { + user + .sink { [weak provider] mastodonUser in + guard let provider = provider else { return } + guard let mastodonUser = mastodonUser else { return } + let profileViewModel = CachedProfileViewModel(context: provider.context, mastodonUser: mastodonUser) + DispatchQueue.main.async { + if provider.navigationController == nil { + let from = provider.presentingViewController ?? provider + provider.dismiss(animated: true) { + provider.coordinator.present(scene: .profile(viewModel: profileViewModel), from: from, transition: .show) + } + } else { + provider.coordinator.present(scene: .profile(viewModel: profileViewModel), from: provider, transition: .show) + } + } + } + .store(in: &provider.disposeBag) + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Asset/email.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Asset/email.imageset/Contents.json index ba3df05b6..bcc8dce80 100644 --- a/Mastodon/Resources/Assets.xcassets/Asset/email.imageset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Asset/email.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "c1 1.svg", + "filename" : "c1 1~universal.pdf", "idiom" : "universal" } ], diff --git a/Mastodon/Resources/Assets.xcassets/Asset/email.imageset/c1 1.svg b/Mastodon/Resources/Assets.xcassets/Asset/email.imageset/c1 1.svg deleted file mode 100644 index d3869cda5..000000000 --- a/Mastodon/Resources/Assets.xcassets/Asset/email.imageset/c1 1.svg +++ /dev/null @@ -1,73 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Mastodon/Resources/Assets.xcassets/Asset/email.imageset/c1 1~universal.pdf b/Mastodon/Resources/Assets.xcassets/Asset/email.imageset/c1 1~universal.pdf new file mode 100644 index 000000000..93c448982 Binary files /dev/null and b/Mastodon/Resources/Assets.xcassets/Asset/email.imageset/c1 1~universal.pdf differ diff --git a/Mastodon/Resources/Assets.xcassets/_Deprecated/Background/danger.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/badge.background.colorset/Contents.json similarity index 74% rename from Mastodon/Resources/Assets.xcassets/_Deprecated/Background/danger.colorset/Contents.json rename to Mastodon/Resources/Assets.xcassets/Colors/badge.background.colorset/Contents.json index dabccc33e..f58a604a1 100644 --- a/Mastodon/Resources/Assets.xcassets/_Deprecated/Background/danger.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/badge.background.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.353", - "green" : "0.251", - "red" : "0.875" + "blue" : "48", + "green" : "59", + "red" : "255" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/danger.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/danger.colorset/Contents.json index 8ea3105e6..b77cb3c75 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/danger.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/danger.colorset/Contents.json @@ -1,20 +1,20 @@ { - "info" : { - "version" : 1, - "author" : "xcode" - }, "colors" : [ { "color" : { "color-space" : "srgb", "components" : { "alpha" : "1.000", - "red" : "0.875", - "blue" : "0.353", - "green" : "0.251" + "blue" : "90", + "green" : "64", + "red" : "223" } }, "idiom" : "universal" } - ] -} \ No newline at end of file + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/_Deprecated/Background/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Sidebar/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/_Deprecated/Background/Contents.json rename to Mastodon/Resources/Assets.xcassets/Scene/Sidebar/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Sidebar/logo.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Sidebar/logo.imageset/Contents.json new file mode 100644 index 000000000..4f547d09b --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Scene/Sidebar/logo.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "logo.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Sidebar/logo.imageset/logo.pdf b/Mastodon/Resources/Assets.xcassets/Scene/Sidebar/logo.imageset/logo.pdf new file mode 100644 index 000000000..908727a57 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Scene/Sidebar/logo.imageset/logo.pdf @@ -0,0 +1,108 @@ +%PDF-1.7 + +1 0 obj + << >> +endobj + +2 0 obj + << /Length 3 0 R >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +1.000000 0.000000 -0.000000 1.000000 0.000000 -0.103455 cm +0.168627 0.564706 0.850980 scn +27.796436 10.091343 m +33.035133 10.719734 37.596470 13.962151 38.169762 16.924883 c +39.073063 21.591980 38.998501 28.314186 38.998501 28.314186 c +38.998501 37.425270 33.056084 40.095867 33.056084 40.095867 c +30.059872 41.478233 24.914881 42.059555 19.569633 42.103455 c +19.438305 42.103455 l +14.093056 42.059555 8.951445 41.478233 5.955006 40.095867 c +5.955006 40.095867 0.012361 37.425270 0.012361 28.314186 c +0.012361 27.761837 0.009520 27.180878 0.006561 26.576080 c +-0.001656 24.896429 -0.010772 23.032921 0.037591 21.087820 c +0.253392 12.177679 1.663759 3.396290 9.864657 1.215820 c +13.645910 0.210445 16.892391 0.000000 19.507011 0.144371 c +24.248556 0.408443 26.910255 1.844212 26.910255 1.844212 c +26.753922 5.300014 l +26.753922 5.300014 23.365528 4.226753 19.560173 4.357544 c +15.789957 4.487431 11.809797 4.765984 11.200012 9.415886 c +11.143697 9.824329 11.115539 10.261055 11.115539 10.719732 c +11.115539 10.719732 14.816599 9.810978 19.507011 9.595104 c +22.375050 9.462955 25.064680 9.763912 27.796436 10.091343 c +h +31.989010 16.575367 m +31.989010 27.607372 l +31.989010 29.862061 31.417519 31.653776 30.269808 32.979347 c +29.085829 34.304916 27.535576 34.984444 25.611385 34.984444 c +23.384670 34.984444 21.698582 34.124794 20.583984 32.405266 c +19.500023 30.580288 l +18.416286 32.405266 l +17.301464 34.124794 15.615376 34.984444 13.388884 34.984444 c +11.464469 34.984444 9.914215 34.304916 8.730462 32.979347 c +7.582527 31.653776 7.011036 29.862061 7.011036 27.607372 c +7.011036 16.575367 l +11.361976 16.575367 l +11.361976 27.283108 l +11.361976 29.540287 12.307401 30.685961 14.198477 30.685961 c +16.289360 30.685961 17.337505 29.326900 17.337505 26.639557 c +17.337505 20.778585 l +21.662764 20.778585 l +21.662764 26.639557 l +21.662764 29.326900 22.710684 30.685961 24.801567 30.685961 c +26.692642 30.685961 27.638069 29.540287 27.638069 27.283108 c +27.638069 16.575367 l +31.989010 16.575367 l +h +f* +n +Q + +endstream +endobj + +3 0 obj + 2035 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 39.000000 42.000000 ] + /Resources 1 0 R + /Contents 2 0 R + /Parent 5 0 R + >> +endobj + +5 0 obj + << /Kids [ 4 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +6 0 obj + << /Type /Catalog + /Pages 5 0 R + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000002125 00000 n +0000002148 00000 n +0000002321 00000 n +0000002395 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +2454 +%%EOF \ No newline at end of file diff --git a/Mastodon/Resources/Assets.xcassets/_Deprecated/Background/secondary.grouped.system.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Theme/Mastodon/Background/sidebar.background.colorset/Contents.json similarity index 88% rename from Mastodon/Resources/Assets.xcassets/_Deprecated/Background/secondary.grouped.system.background.colorset/Contents.json rename to Mastodon/Resources/Assets.xcassets/Theme/Mastodon/Background/sidebar.background.colorset/Contents.json index ef6c7f7b1..c24074078 100644 --- a/Mastodon/Resources/Assets.xcassets/_Deprecated/Background/secondary.grouped.system.background.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Theme/Mastodon/Background/sidebar.background.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.996", - "green" : "1.000", - "red" : "0.996" + "blue" : "0xF1", + "green" : "0xF1", + "red" : "0xF1" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Theme/Mastodon/tab.bar.item.inactive.icon.color.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Theme/Mastodon/tab.bar.item.inactive.icon.color.colorset/Contents.json index 1accfacdf..bfc2a11b2 100644 --- a/Mastodon/Resources/Assets.xcassets/Theme/Mastodon/tab.bar.item.inactive.icon.color.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Theme/Mastodon/tab.bar.item.inactive.icon.color.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.549", - "green" : "0.510", - "red" : "0.431" + "blue" : "0x99", + "green" : "0x99", + "red" : "0x99" } }, "idiom" : "universal" @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "200", - "green" : "174", - "red" : "155" + "blue" : "0x99", + "green" : "0x99", + "red" : "0x99" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Theme/system/Background/secondary.system.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Theme/system/Background/secondary.system.background.colorset/Contents.json index b9a69ec7d..77d24b11d 100644 --- a/Mastodon/Resources/Assets.xcassets/Theme/system/Background/secondary.system.background.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Theme/system/Background/secondary.system.background.colorset/Contents.json @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "46", - "green" : "44", - "red" : "44" + "blue" : "0x2E", + "green" : "0x2C", + "red" : "0x2C" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/_Deprecated/Background/secondary.system.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Theme/system/Background/sidebar.background.colorset/Contents.json similarity index 76% rename from Mastodon/Resources/Assets.xcassets/_Deprecated/Background/secondary.system.background.colorset/Contents.json rename to Mastodon/Resources/Assets.xcassets/Theme/system/Background/sidebar.background.colorset/Contents.json index c915c8911..ee5b1c373 100644 --- a/Mastodon/Resources/Assets.xcassets/_Deprecated/Background/secondary.system.background.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Theme/system/Background/sidebar.background.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.910", - "green" : "0.882", - "red" : "0.851" + "blue" : "0.945", + "green" : "0.945", + "red" : "0.945" } }, "idiom" : "universal" @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.133", - "green" : "0.106", - "red" : "0.098" + "blue" : "0x2E", + "green" : "0x2C", + "red" : "0x2C" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Theme/system/tab.bar.item.inactive.icon.color.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Theme/system/tab.bar.item.inactive.icon.color.colorset/Contents.json index ece9000aa..bfc2a11b2 100644 --- a/Mastodon/Resources/Assets.xcassets/Theme/system/tab.bar.item.inactive.icon.color.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Theme/system/tab.bar.item.inactive.icon.color.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.549", - "green" : "0.510", - "red" : "0.431" + "blue" : "0x99", + "green" : "0x99", + "red" : "0x99" } }, "idiom" : "universal" @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "140", - "green" : "130", - "red" : "110" + "blue" : "0x99", + "green" : "0x99", + "red" : "0x99" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/_Deprecated/Background/onboarding.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/_Deprecated/Background/onboarding.background.colorset/Contents.json deleted file mode 100644 index 0e4687fb4..000000000 --- a/Mastodon/Resources/Assets.xcassets/_Deprecated/Background/onboarding.background.colorset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.910", - "green" : "0.882", - "red" : "0.851" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Mastodon/Resources/Assets.xcassets/_Deprecated/Background/system.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/_Deprecated/Background/system.background.colorset/Contents.json deleted file mode 100644 index 4572c2409..000000000 --- a/Mastodon/Resources/Assets.xcassets/_Deprecated/Background/system.background.colorset/Contents.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.996", - "green" : "1.000", - "red" : "0.996" - } - }, - "idiom" : "universal" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.216", - "green" : "0.173", - "red" : "0.157" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Mastodon/Resources/Assets.xcassets/_Deprecated/Background/system.elevated.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/_Deprecated/Background/system.elevated.background.colorset/Contents.json deleted file mode 100644 index 33b71ef90..000000000 --- a/Mastodon/Resources/Assets.xcassets/_Deprecated/Background/system.elevated.background.colorset/Contents.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "1.000", - "green" : "1.000", - "red" : "1.000" - } - }, - "idiom" : "universal" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.216", - "green" : "0.173", - "red" : "0.157" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Mastodon/Resources/Assets.xcassets/_Deprecated/Background/system.grouped.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/_Deprecated/Background/system.grouped.background.colorset/Contents.json deleted file mode 100644 index c915c8911..000000000 --- a/Mastodon/Resources/Assets.xcassets/_Deprecated/Background/system.grouped.background.colorset/Contents.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.910", - "green" : "0.882", - "red" : "0.851" - } - }, - "idiom" : "universal" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.133", - "green" : "0.106", - "red" : "0.098" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Mastodon/Resources/Assets.xcassets/_Deprecated/Background/tertiary.system.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/_Deprecated/Background/tertiary.system.background.colorset/Contents.json deleted file mode 100644 index 4572c2409..000000000 --- a/Mastodon/Resources/Assets.xcassets/_Deprecated/Background/tertiary.system.background.colorset/Contents.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.996", - "green" : "1.000", - "red" : "0.996" - } - }, - "idiom" : "universal" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.216", - "green" : "0.173", - "red" : "0.157" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Mastodon/Resources/Assets.xcassets/_Deprecated/Background/tertiary.system.grouped.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/_Deprecated/Background/tertiary.system.grouped.background.colorset/Contents.json deleted file mode 100644 index 98dd7bbde..000000000 --- a/Mastodon/Resources/Assets.xcassets/_Deprecated/Background/tertiary.system.grouped.background.colorset/Contents.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.910", - "green" : "0.882", - "red" : "0.851" - } - }, - "idiom" : "universal" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.263", - "green" : "0.208", - "red" : "0.192" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Mastodon/Resources/Assets.xcassets/_Deprecated/Compose/Contents.json b/Mastodon/Resources/Assets.xcassets/_Deprecated/Compose/Contents.json deleted file mode 100644 index 6e965652d..000000000 --- a/Mastodon/Resources/Assets.xcassets/_Deprecated/Compose/Contents.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "provides-namespace" : true - } -} diff --git a/Mastodon/Resources/Assets.xcassets/_Deprecated/Compose/background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/_Deprecated/Compose/background.colorset/Contents.json deleted file mode 100644 index 33b71ef90..000000000 --- a/Mastodon/Resources/Assets.xcassets/_Deprecated/Compose/background.colorset/Contents.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "1.000", - "green" : "1.000", - "red" : "1.000" - } - }, - "idiom" : "universal" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.216", - "green" : "0.173", - "red" : "0.157" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Mastodon/Resources/Assets.xcassets/_Deprecated/Compose/toolbar.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/_Deprecated/Compose/toolbar.background.colorset/Contents.json deleted file mode 100644 index da7b76069..000000000 --- a/Mastodon/Resources/Assets.xcassets/_Deprecated/Compose/toolbar.background.colorset/Contents.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.871", - "green" : "0.847", - "red" : "0.839" - } - }, - "idiom" : "universal" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "0.920", - "blue" : "0.125", - "green" : "0.125", - "red" : "0.125" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Mastodon/Resources/Assets.xcassets/_Deprecated/Contents.json b/Mastodon/Resources/Assets.xcassets/_Deprecated/Contents.json deleted file mode 100644 index 6e965652d..000000000 --- a/Mastodon/Resources/Assets.xcassets/_Deprecated/Contents.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "provides-namespace" : true - } -} diff --git a/Mastodon/Resources/ar.lproj/InfoPlist.strings b/Mastodon/Resources/ar.lproj/InfoPlist.strings index 2531bd454..c3b26f14a 100644 --- a/Mastodon/Resources/ar.lproj/InfoPlist.strings +++ b/Mastodon/Resources/ar.lproj/InfoPlist.strings @@ -1,4 +1,4 @@ -"NSCameraUsageDescription" = "Used to take photo for post status"; -"NSPhotoLibraryAddUsageDescription" = "Used to save photo into the Photo Library"; -"NewPostShortcutItemTitle" = "New Post"; +"NSCameraUsageDescription" = "يُستخدم لالتقاط الصورة عِندَ نشر الحالات"; +"NSPhotoLibraryAddUsageDescription" = "يُستخدم لحِفظ الصورة في مكتبة الصور"; +"NewPostShortcutItemTitle" = "منشور جديد"; "SearchShortcutItemTitle" = "البحث"; \ No newline at end of file diff --git a/Mastodon/Resources/ar.lproj/Localizable.strings b/Mastodon/Resources/ar.lproj/Localizable.strings index 475d27886..b878e0342 100644 --- a/Mastodon/Resources/ar.lproj/Localizable.strings +++ b/Mastodon/Resources/ar.lproj/Localizable.strings @@ -1,81 +1,82 @@ -"Common.Alerts.BlockDomain.BlockEntireDomain" = "حظر النطاق"; -"Common.Alerts.BlockDomain.Title" = "Are you really, really sure you want to block the entire %@? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain and any of your followers from that domain will be removed."; -"Common.Alerts.CleanCache.Message" = "تم تنظيف ذاكرة التخزين المؤقت %@ بنجاح."; -"Common.Alerts.CleanCache.Title" = "تنظيف ذاكرة التخزين المؤقت"; -"Common.Alerts.Common.PleaseTryAgain" = "الرجاء المحاولة مرة أخرى."; -"Common.Alerts.Common.PleaseTryAgainLater" = "الرجاء المحاولة مرة أخرى لاحقاً."; +"Common.Alerts.BlockDomain.BlockEntireDomain" = "حظر النِطاق"; +"Common.Alerts.BlockDomain.Title" = "هل أنتَ مُتأكِّدٌ حقًا مِن رغبتك في حظر %@ بالكامل؟ في معظم الحالات، يكون مِنَ الكافي والمُفَضَّل استهداف عدد محدود للحظر أو الكتم. لن ترى محتوى من هذا النطاق وسوف يتم إزالة جميع متابعيك المتواجدين فيه."; +"Common.Alerts.CleanCache.Message" = "تمَّ مَحو ذاكرة التخزين المؤقت %@ بنجاح."; +"Common.Alerts.CleanCache.Title" = "مَحو ذاكرة التخزين المؤقت"; +"Common.Alerts.Common.PleaseTryAgain" = "يُرجى المحاولة مرة أُخرى."; +"Common.Alerts.Common.PleaseTryAgainLater" = "يُرجى المحاولة مرة أُخرى لاحقاً."; "Common.Alerts.DeletePost.Delete" = "احذف"; -"Common.Alerts.DeletePost.Title" = "هل أنت متأكد من أنك تريد حذف هذا المنشور؟"; -"Common.Alerts.DiscardPostContent.Message" = "Confirm to discard composed post content."; -"Common.Alerts.DiscardPostContent.Title" = "تجاهل المسودة"; -"Common.Alerts.EditProfileFailure.Message" = "Cannot edit profile. Please try again."; -"Common.Alerts.EditProfileFailure.Title" = "Edit Profile Error"; -"Common.Alerts.PublishPostFailure.AttachmentsMessage.MoreThanOneVideo" = "Cannot attach more than one video."; -"Common.Alerts.PublishPostFailure.AttachmentsMessage.VideoAttachWithPhoto" = "Cannot attach a video to a post that already contains images."; -"Common.Alerts.PublishPostFailure.Message" = "Failed to publish the post. -Please check your internet connection."; -"Common.Alerts.PublishPostFailure.Title" = "أخفقت عملية النشر"; -"Common.Alerts.SavePhotoFailure.Message" = "Please enable the photo library access permission to save the photo."; -"Common.Alerts.SavePhotoFailure.Title" = "فشل حفظ الصورة"; +"Common.Alerts.DeletePost.Title" = "هل أنت متأكد من رغبتك في حذف هذا المنشور؟"; +"Common.Alerts.DiscardPostContent.Message" = "أكِّد للتخلص مِن مُحتوى مَنشور مؤلَّف."; +"Common.Alerts.DiscardPostContent.Title" = "التخلص من المسودة"; +"Common.Alerts.EditProfileFailure.Message" = "لا يمكن تعديل الملف الشخصي. يُرجى المحاولة مرة أُخرى."; +"Common.Alerts.EditProfileFailure.Title" = "خطأ في تَحرير الملف الشخصي"; +"Common.Alerts.PublishPostFailure.AttachmentsMessage.MoreThanOneVideo" = "لا يُمكِنُ إرفاق أكثر مِن مَقطع مرئي واحِد."; +"Common.Alerts.PublishPostFailure.AttachmentsMessage.VideoAttachWithPhoto" = "لا يُمكن إرفاق مقطع مرئي إلى مَنشور يحتوي بالفعل على صُوَر."; +"Common.Alerts.PublishPostFailure.Message" = "فَشَلَ نَشر المَنشور. +يُرجى التحقق من اتصالك بالإنترنت."; +"Common.Alerts.PublishPostFailure.Title" = "إخفاق في عمليَّة النشر"; +"Common.Alerts.SavePhotoFailure.Message" = "يُرجى إتاحة إذن الوصول إلى مكتبة الصور لحفظ الصورة."; +"Common.Alerts.SavePhotoFailure.Title" = "إخفاق في حفظ الصورة"; "Common.Alerts.ServerError.Title" = "خطأ في الخادم"; "Common.Alerts.SignOut.Confirm" = "تسجيل الخروج"; -"Common.Alerts.SignOut.Message" = "هل أنت متأكد من أنك تريد تسجيل الخروج؟"; +"Common.Alerts.SignOut.Message" = "هل أنت متأكد من رغبتك في تسجيل الخروج؟"; "Common.Alerts.SignOut.Title" = "تسجيل الخروج"; -"Common.Alerts.SignUpFailure.Title" = "فشل التسجيل"; -"Common.Alerts.VoteFailure.PollEnded" = "The poll has ended"; -"Common.Alerts.VoteFailure.Title" = "فشل التصويت"; +"Common.Alerts.SignUpFailure.Title" = "إخفاق في التسجيل"; +"Common.Alerts.VoteFailure.PollEnded" = "انتهى استطلاع الرأي"; +"Common.Alerts.VoteFailure.Title" = "إخفاق في التصويت"; "Common.Controls.Actions.Add" = "إضافة"; "Common.Controls.Actions.Back" = "العودة"; "Common.Controls.Actions.BlockDomain" = "حظر %@"; "Common.Controls.Actions.Cancel" = "إلغاء"; +"Common.Controls.Actions.Compose" = "تأليف"; "Common.Controls.Actions.Confirm" = "تأكيد"; "Common.Controls.Actions.Continue" = "واصل"; "Common.Controls.Actions.CopyPhoto" = "نسخ الصورة"; "Common.Controls.Actions.Delete" = "احذف"; "Common.Controls.Actions.Discard" = "تجاهل"; -"Common.Controls.Actions.Done" = "تم"; -"Common.Controls.Actions.Edit" = "تعديل"; +"Common.Controls.Actions.Done" = "تمّ"; +"Common.Controls.Actions.Edit" = "تحرير"; "Common.Controls.Actions.FindPeople" = "ابحث عن أشخاص لمتابعتهم"; "Common.Controls.Actions.ManuallySearch" = "البحث يدوياً بدلاً من ذلك"; "Common.Controls.Actions.Next" = "التالي"; "Common.Controls.Actions.Ok" = "حسنًا"; "Common.Controls.Actions.Open" = "افتح"; -"Common.Controls.Actions.OpenInSafari" = "افتحه في سفاري"; -"Common.Controls.Actions.Preview" = "معاينة"; +"Common.Controls.Actions.OpenInSafari" = "الفتح في Safari"; +"Common.Controls.Actions.Preview" = "مُعاينة"; "Common.Controls.Actions.Previous" = "السابق"; "Common.Controls.Actions.Remove" = "احذف"; -"Common.Controls.Actions.Reply" = "رد"; +"Common.Controls.Actions.Reply" = "الرَد"; "Common.Controls.Actions.ReportUser" = "ابلغ عن %@"; "Common.Controls.Actions.Save" = "حفظ"; "Common.Controls.Actions.SavePhoto" = "حفظ الصورة"; "Common.Controls.Actions.SeeMore" = "عرض المزيد"; "Common.Controls.Actions.Settings" = "الإعدادات"; -"Common.Controls.Actions.Share" = "شارك"; -"Common.Controls.Actions.SharePost" = "شارك المنشور"; -"Common.Controls.Actions.ShareUser" = "شارك %@"; -"Common.Controls.Actions.SignIn" = "لِج"; -"Common.Controls.Actions.SignUp" = "انشئ حسابًا"; +"Common.Controls.Actions.Share" = "المُشارك"; +"Common.Controls.Actions.SharePost" = "مشارك المنشور"; +"Common.Controls.Actions.ShareUser" = "مُشاركة %@"; +"Common.Controls.Actions.SignIn" = "تسجيل الدخول"; +"Common.Controls.Actions.SignUp" = "إنشاء حِساب"; "Common.Controls.Actions.Skip" = "تخطي"; -"Common.Controls.Actions.TakePhoto" = "التقط صورة"; -"Common.Controls.Actions.TryAgain" = "حاول مرة أخرى"; +"Common.Controls.Actions.TakePhoto" = "التقاط صورة"; +"Common.Controls.Actions.TryAgain" = "المُحاولة مرة أُخرى"; "Common.Controls.Actions.UnblockDomain" = "إلغاء حظر %@"; -"Common.Controls.Friendship.Block" = "Block"; -"Common.Controls.Friendship.BlockDomain" = "Block %@"; -"Common.Controls.Friendship.BlockUser" = "Block %@"; -"Common.Controls.Friendship.Blocked" = "Blocked"; +"Common.Controls.Friendship.Block" = "حظر"; +"Common.Controls.Friendship.BlockDomain" = "حظر %@"; +"Common.Controls.Friendship.BlockUser" = "حظر %@"; +"Common.Controls.Friendship.Blocked" = "محظور"; "Common.Controls.Friendship.EditInfo" = "تعديل المعلومات"; -"Common.Controls.Friendship.Follow" = "Follow"; -"Common.Controls.Friendship.Following" = "Following"; +"Common.Controls.Friendship.Follow" = "اتبع"; +"Common.Controls.Friendship.Following" = "مُتابَع"; "Common.Controls.Friendship.Mute" = "أكتم"; "Common.Controls.Friendship.MuteUser" = "أكتم %@"; "Common.Controls.Friendship.Muted" = "مكتوم"; -"Common.Controls.Friendship.Pending" = "Pending"; -"Common.Controls.Friendship.Request" = "Request"; -"Common.Controls.Friendship.Unblock" = "Unblock"; -"Common.Controls.Friendship.UnblockUser" = "Unblock %@"; +"Common.Controls.Friendship.Pending" = "قيد المُراجعة"; +"Common.Controls.Friendship.Request" = "إرسال طَلَب"; +"Common.Controls.Friendship.Unblock" = "إلغاء الحَظر"; +"Common.Controls.Friendship.UnblockUser" = "إلغاء حظر %@"; "Common.Controls.Friendship.Unmute" = "إلغاء الكتم"; "Common.Controls.Friendship.UnmuteUser" = "إلغاء كتم %@"; -"Common.Controls.Keyboard.Common.ComposeNewPost" = "إنشاء منشور جديد"; +"Common.Controls.Keyboard.Common.ComposeNewPost" = "تأليف منشور جديد"; "Common.Controls.Keyboard.Common.OpenSettings" = "أفتح الإعدادات"; "Common.Controls.Keyboard.Common.ShowFavorites" = "إظهار المفضلة"; "Common.Controls.Keyboard.Common.SwitchToTab" = "التبديل إلى %@"; @@ -88,81 +89,83 @@ Please check your internet connection."; "Common.Controls.Keyboard.Timeline.PreviewImage" = "معاينة الصورة"; "Common.Controls.Keyboard.Timeline.PreviousStatus" = "المنشور السابق"; "Common.Controls.Keyboard.Timeline.ReplyStatus" = "رد على المنشور"; -"Common.Controls.Keyboard.Timeline.ToggleContentWarning" = "Toggle Content Warning"; -"Common.Controls.Keyboard.Timeline.ToggleFavorite" = "Toggle Favorite on Post"; -"Common.Controls.Keyboard.Timeline.ToggleReblog" = "Toggle Reblog on Post"; -"Common.Controls.Status.Actions.Favorite" = "Favorite"; -"Common.Controls.Status.Actions.Menu" = "Menu"; -"Common.Controls.Status.Actions.Reblog" = "Reblog"; -"Common.Controls.Status.Actions.Reply" = "Reply"; -"Common.Controls.Status.Actions.Unfavorite" = "Unfavorite"; -"Common.Controls.Status.Actions.Unreblog" = "Undo reblog"; -"Common.Controls.Status.ContentWarning" = "Content Warning"; -"Common.Controls.Status.MediaContentWarning" = "Tap anywhere to reveal"; -"Common.Controls.Status.Poll.Closed" = "Closed"; -"Common.Controls.Status.Poll.Vote" = "Vote"; -"Common.Controls.Status.ShowPost" = "Show Post"; -"Common.Controls.Status.ShowUserProfile" = "Show user profile"; +"Common.Controls.Keyboard.Timeline.ToggleContentWarning" = "تبديل تحذير المُحتَوى"; +"Common.Controls.Keyboard.Timeline.ToggleFavorite" = "تبديل المفضلة لِمنشور"; +"Common.Controls.Keyboard.Timeline.ToggleReblog" = "تبديل إعادة تدوين منشور"; +"Common.Controls.Status.Actions.Favorite" = "إضافة إلى المفضلة"; +"Common.Controls.Status.Actions.Menu" = "القائمة"; +"Common.Controls.Status.Actions.Reblog" = "إعادة النشر"; +"Common.Controls.Status.Actions.Reply" = "رد"; +"Common.Controls.Status.Actions.Unfavorite" = "إزالة من المفضلة"; +"Common.Controls.Status.Actions.Unreblog" = "تراجع عن إعادة النشر"; +"Common.Controls.Status.ContentWarning" = "تحذير عن المحتوى"; +"Common.Controls.Status.MediaContentWarning" = "انقر على أي مكان للكشف"; +"Common.Controls.Status.Poll.Closed" = "انتهى"; +"Common.Controls.Status.Poll.Vote" = "صَوِّت"; +"Common.Controls.Status.ShowPost" = "اظهر المنشور"; +"Common.Controls.Status.ShowUserProfile" = "اظهر الملف التعريفي للمستخدم"; "Common.Controls.Status.Tag.Email" = "البريد الإلكتروني"; -"Common.Controls.Status.Tag.Emoji" = "Emoji"; -"Common.Controls.Status.Tag.Hashtag" = "Hashtag"; -"Common.Controls.Status.Tag.Link" = "Link"; -"Common.Controls.Status.Tag.Mention" = "Mention"; -"Common.Controls.Status.Tag.Url" = "URL"; -"Common.Controls.Status.UserReblogged" = "%@ reblogged"; -"Common.Controls.Status.UserRepliedTo" = "Replied to %@"; +"Common.Controls.Status.Tag.Emoji" = "إيموجي"; +"Common.Controls.Status.Tag.Hashtag" = "الوسم"; +"Common.Controls.Status.Tag.Link" = "الرابط"; +"Common.Controls.Status.Tag.Mention" = "أشر إلى"; +"Common.Controls.Status.Tag.Url" = "عنوان URL"; +"Common.Controls.Status.UserReblogged" = "أعادَ %@ تدوينها"; +"Common.Controls.Status.UserRepliedTo" = "رد على %@"; "Common.Controls.Tabs.Home" = "الخيط الرئيسي"; "Common.Controls.Tabs.Notification" = "الإشعارات"; "Common.Controls.Tabs.Profile" = "الملف التعريفي"; "Common.Controls.Tabs.Search" = "بحث"; -"Common.Controls.Timeline.Filtered" = "Filtered"; -"Common.Controls.Timeline.Header.BlockedWarning" = "You can’t view this user’s profile -until they unblock you."; -"Common.Controls.Timeline.Header.BlockingWarning" = "You can’t view this user's profile -until you unblock them. -Your profile looks like this to them."; +"Common.Controls.Timeline.Filtered" = "مُصفَّى"; +"Common.Controls.Timeline.Header.BlockedWarning" = "لا يُمكِنُكَ عَرض الملف الشخصي لهذا المُستخدِم +حتَّى يَرفَعَ الحَظر عَنك."; +"Common.Controls.Timeline.Header.BlockingWarning" = "لا يُمكنك الاطلاع على الملف الشخصي لهذا المُستخدِم +حتَّى تَرفعَ الحَظر عنه. +ملفًّكَ الشخصي يَظهَرُ بِمثل هذِهِ الحالة بالنسبةِ لَهُ أيضًا."; "Common.Controls.Timeline.Header.NoStatusFound" = "لا توجد هناك منشورات"; -"Common.Controls.Timeline.Header.SuspendedWarning" = "This user has been suspended."; -"Common.Controls.Timeline.Header.UserBlockedWarning" = "You can’t view %@’s profile -until they unblock you."; -"Common.Controls.Timeline.Header.UserBlockingWarning" = "You can’t view %@’s profile -until you unblock them. -Your profile looks like this to them."; -"Common.Controls.Timeline.Header.UserSuspendedWarning" = "%@’s account has been suspended."; -"Common.Controls.Timeline.Loader.LoadMissingPosts" = "Load missing posts"; +"Common.Controls.Timeline.Header.SuspendedWarning" = "تمَّ إيقاف هذا المُستخدِم."; +"Common.Controls.Timeline.Header.UserBlockedWarning" = "لا يُمكِنُكَ عَرض ملف %@ الشخصي +حتَّى يَرفَعَ الحَظر عَنك."; +"Common.Controls.Timeline.Header.UserBlockingWarning" = "لا يُمكنك الاطلاع على ملف %@ الشخصي +حتَّى تَرفعَ الحَظر عنه. +ملفًّكَ الشخصي يَظهَرُ بِمثل هذِهِ الحالة بالنسبةِ لَهُ أيضًا."; +"Common.Controls.Timeline.Header.UserSuspendedWarning" = "لقد أوقِفَ حِساب %@."; +"Common.Controls.Timeline.Loader.LoadMissingPosts" = "تحميل المنشورات المَفقودة"; "Common.Controls.Timeline.Loader.LoadingMissingPosts" = "تحميل المزيد من المنشورات..."; "Common.Controls.Timeline.Loader.ShowMoreReplies" = "إظهار المزيد من الردود"; "Common.Controls.Timeline.Timestamp.Now" = "الأن"; -"Scene.Compose.Accessibility.AppendAttachment" = "Add Attachment"; +"Scene.AccountList.AddAccount" = "إضافة حساب"; +"Scene.AccountList.DismissAccountSwitcher" = "تجاهُل مبدِّل الحساب"; +"Scene.AccountList.TabBarHint" = "المِلف المُحدَّد حاليًا: %@. انقر نقرًا مزدوجًا ثم اضغط مع الاستمرار لإظهار مُبدِّل الحِساب"; +"Scene.Compose.Accessibility.AppendAttachment" = "إضافة مُرفَق"; "Scene.Compose.Accessibility.AppendPoll" = "اضافة استطلاع رأي"; "Scene.Compose.Accessibility.CustomEmojiPicker" = "منتقي مخصص للإيموجي"; "Scene.Compose.Accessibility.DisableContentWarning" = "تعطيل تحذير الحتوى"; "Scene.Compose.Accessibility.EnableContentWarning" = "تنشيط تحذير المحتوى"; -"Scene.Compose.Accessibility.PostVisibilityMenu" = "Post Visibility Menu"; +"Scene.Compose.Accessibility.PostVisibilityMenu" = "قائمة ظهور المنشور"; "Scene.Compose.Accessibility.RemovePoll" = "إزالة الاستطلاع"; -"Scene.Compose.Attachment.AttachmentBroken" = "This %@ is broken and can’t be -uploaded to Mastodon."; -"Scene.Compose.Attachment.DescriptionPhoto" = "Describe the photo for the visually-impaired..."; -"Scene.Compose.Attachment.DescriptionVideo" = "Describe the video for the visually-impaired..."; +"Scene.Compose.Attachment.AttachmentBroken" = "هذا ال%@ مُعطَّل ويتعذَّر رفعه إلى ماستودون."; +"Scene.Compose.Attachment.DescriptionPhoto" = "صِف الصورة للمكفوفين..."; +"Scene.Compose.Attachment.DescriptionVideo" = "صِف المقطع المرئي للمكفوفين..."; "Scene.Compose.Attachment.Photo" = "صورة"; "Scene.Compose.Attachment.Video" = "فيديو"; -"Scene.Compose.AutoComplete.SpaceToAdd" = "Space to add"; +"Scene.Compose.AutoComplete.SpaceToAdd" = "انقر مساحة لإضافتِها"; "Scene.Compose.ComposeAction" = "انشر"; -"Scene.Compose.ContentInputPlaceholder" = "ما الذي يجول ببالك"; -"Scene.Compose.ContentWarning.Placeholder" = "Write an accurate warning here..."; -"Scene.Compose.Keyboard.AppendAttachmentEntry" = "Add Attachment - %@"; -"Scene.Compose.Keyboard.DiscardPost" = "Discard Post"; -"Scene.Compose.Keyboard.PublishPost" = "Publish Post"; -"Scene.Compose.Keyboard.SelectVisibilityEntry" = "Select Visibility - %@"; -"Scene.Compose.Keyboard.ToggleContentWarning" = "Toggle Content Warning"; -"Scene.Compose.Keyboard.TogglePoll" = "Toggle Poll"; +"Scene.Compose.ContentInputPlaceholder" = "أخبِرنا بِما يَجُولُ فِي ذِهنَك"; +"Scene.Compose.ContentWarning.Placeholder" = "اكتب تَحذيرًا دَقيقًا هُنا..."; +"Scene.Compose.Keyboard.AppendAttachmentEntry" = "إضافة مُرفَق - %@"; +"Scene.Compose.Keyboard.DiscardPost" = "تجاهُل المنشور"; +"Scene.Compose.Keyboard.PublishPost" = "نَشر المَنشُور"; +"Scene.Compose.Keyboard.SelectVisibilityEntry" = "اختر مدى الظهور - %@"; +"Scene.Compose.Keyboard.ToggleContentWarning" = "تبديل تحذير المُحتوى"; +"Scene.Compose.Keyboard.TogglePoll" = "تبديل الاستطلاع"; "Scene.Compose.MediaSelection.Browse" = "تصفح"; "Scene.Compose.MediaSelection.Camera" = "التقط صورة"; "Scene.Compose.MediaSelection.PhotoLibrary" = "مكتبة الصور"; -"Scene.Compose.Poll.DurationTime" = "Duration: %@"; +"Scene.Compose.Poll.DurationTime" = "المدة: %@"; "Scene.Compose.Poll.OneDay" = "يوم واحد"; "Scene.Compose.Poll.OneHour" = "ساعة واحدة"; -"Scene.Compose.Poll.OptionNumber" = "Option %ld"; +"Scene.Compose.Poll.OptionNumber" = "الخيار %ld"; "Scene.Compose.Poll.SevenDays" = "7 أيام"; "Scene.Compose.Poll.SixHours" = "6 ساعات"; "Scene.Compose.Poll.ThirtyMinutes" = "30 دقيقة"; @@ -170,39 +173,41 @@ uploaded to Mastodon."; "Scene.Compose.ReplyingToUser" = "رد على %@"; "Scene.Compose.Title.NewPost" = "منشور جديد"; "Scene.Compose.Title.NewReply" = "رد جديد"; -"Scene.Compose.Visibility.Direct" = "Only people I mention"; -"Scene.Compose.Visibility.Private" = "Followers only"; -"Scene.Compose.Visibility.Public" = "Public"; -"Scene.Compose.Visibility.Unlisted" = "Unlisted"; -"Scene.ConfirmEmail.Button.DontReceiveEmail" = "I never got an email"; -"Scene.ConfirmEmail.Button.OpenEmailApp" = "Open Email App"; -"Scene.ConfirmEmail.DontReceiveEmail.Description" = "Check if your email address is correct as well as your junk folder if you haven’t."; -"Scene.ConfirmEmail.DontReceiveEmail.ResendEmail" = "Resend Email"; -"Scene.ConfirmEmail.DontReceiveEmail.Title" = "Check your email"; -"Scene.ConfirmEmail.OpenEmailApp.Description" = "We just sent you an email. Check your junk folder if you haven’t."; +"Scene.Compose.Visibility.Direct" = "ففط للأشخاص المشار إليهم"; +"Scene.Compose.Visibility.Private" = "لمتابعيك فقط"; +"Scene.Compose.Visibility.Public" = "للعامة"; +"Scene.Compose.Visibility.Unlisted" = "غير مُدرَج"; +"Scene.ConfirmEmail.Button.DontReceiveEmail" = "لم أستلم أبدًا بريدا إلكترونيا"; +"Scene.ConfirmEmail.Button.OpenEmailApp" = "افتح تطبيق البريد الإلكتروني"; +"Scene.ConfirmEmail.DontReceiveEmail.Description" = "تحقق ممَّ إذا كان عنوان بريدك الإلكتروني صحيحًا وكذلك تأكد مِن مجلد البريد غير الهام إذا لم تكن قد فعلت ذلك."; +"Scene.ConfirmEmail.DontReceiveEmail.ResendEmail" = "إعادة إرسال البريد الإلكتروني"; +"Scene.ConfirmEmail.DontReceiveEmail.Title" = "تحقق من بريدك الإلكتروني"; +"Scene.ConfirmEmail.OpenEmailApp.Description" = "لقد أرسلنا لك بريدًا إلكترونيًا للتو. تحقق من مجلد البريد غير الهام الخاص بك إذا لم تكن قد فعلت ذلك."; "Scene.ConfirmEmail.OpenEmailApp.Mail" = "البريد"; -"Scene.ConfirmEmail.OpenEmailApp.OpenEmailClient" = "Open Email Client"; -"Scene.ConfirmEmail.OpenEmailApp.Title" = "Check your inbox."; -"Scene.ConfirmEmail.Subtitle" = "We just sent an email to %@, -tap the link to confirm your account."; +"Scene.ConfirmEmail.OpenEmailApp.OpenEmailClient" = "فتح عميل البريد الإلكتروني"; +"Scene.ConfirmEmail.OpenEmailApp.Title" = "تحقَّق من بريدك الوارِد."; +"Scene.ConfirmEmail.Subtitle" = "لقد أرسلنا للتو رسالة بريد إلكتروني إلى %@، +اضغط على الرابط لتأكيد حسابك."; "Scene.ConfirmEmail.Title" = "شيء واحد أخير."; -"Scene.Favorite.Title" = "Your Favorites"; -"Scene.HomeTimeline.NavigationBarState.NewPosts" = "See new posts"; +"Scene.Favorite.Title" = "مفضلتك"; +"Scene.Follower.Footer" = "لا يُمكِن عَرض المُتابِعين مِنَ الخوادم الأُخرى."; +"Scene.Following.Footer" = "لا يُمكِن عَرض المُتابَعات مِنَ الخوادم الأُخرى."; +"Scene.HomeTimeline.NavigationBarState.NewPosts" = "إظهار منشورات جديدة"; "Scene.HomeTimeline.NavigationBarState.Offline" = "غير متصل"; -"Scene.HomeTimeline.NavigationBarState.Published" = "Published!"; -"Scene.HomeTimeline.NavigationBarState.Publishing" = "Publishing post..."; +"Scene.HomeTimeline.NavigationBarState.Published" = "تم نشره!"; +"Scene.HomeTimeline.NavigationBarState.Publishing" = "جارٍ نشر المشاركة…"; "Scene.HomeTimeline.Title" = "الخيط الرئيسي"; -"Scene.Notification.Keyobard.ShowEverything" = "Show Everything"; -"Scene.Notification.Keyobard.ShowMentions" = "Show Mentions"; -"Scene.Notification.Title.Everything" = "Everything"; -"Scene.Notification.Title.Mentions" = "Mentions"; -"Scene.Notification.UserFavorited Your Post" = "%@ favorited your post"; -"Scene.Notification.UserFollowedYou" = "%@ followed you"; -"Scene.Notification.UserMentionedYou" = "%@ mentioned you"; -"Scene.Notification.UserRebloggedYourPost" = "%@ reblogged your post"; -"Scene.Notification.UserRequestedToFollowYou" = "%@ requested to follow you"; -"Scene.Notification.UserYourPollHasEnded" = "%@ Your poll has ended"; -"Scene.Preview.Keyboard.ClosePreview" = "إغلاق المعاينة"; +"Scene.Notification.Keyobard.ShowEverything" = "إظهار كل شيء"; +"Scene.Notification.Keyobard.ShowMentions" = "إظهار الإشارات"; +"Scene.Notification.Title.Everything" = "الكل"; +"Scene.Notification.Title.Mentions" = "الإشارات"; +"Scene.Notification.UserFavorited Your Post" = "أضاف %@ منشورك إلى مفضلته"; +"Scene.Notification.UserFollowedYou" = "يتابعك %@"; +"Scene.Notification.UserMentionedYou" = "أشار إليك %@"; +"Scene.Notification.UserRebloggedYourPost" = "أعاد %@ تدوين مشاركتك"; +"Scene.Notification.UserRequestedToFollowYou" = "طلب %@ متابعتك"; +"Scene.Notification.UserYourPollHasEnded" = "%@ اِنتهى استطلاعُكَ للرأي"; +"Scene.Preview.Keyboard.ClosePreview" = "إغلاق المُعايَنَة"; "Scene.Preview.Keyboard.ShowNext" = "إظهار التالي"; "Scene.Preview.Keyboard.ShowPrevious" = "إظهار السابق"; "Scene.Profile.Dashboard.Followers" = "متابِع"; @@ -210,10 +215,10 @@ tap the link to confirm your account."; "Scene.Profile.Dashboard.Posts" = "منشورات"; "Scene.Profile.Fields.AddRow" = "إضافة صف"; "Scene.Profile.Fields.Placeholder.Content" = "المحتوى"; -"Scene.Profile.Fields.Placeholder.Label" = "Label"; -"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Message" = "Confirm to unblock %@"; +"Scene.Profile.Fields.Placeholder.Label" = "التسمية"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Message" = "أكِّد لرفع حظر %@"; "Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Title" = "إلغاء حظر الحساب"; -"Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Message" = "Confirm to unmute %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Message" = "أكِّد لرفع كتمْ %@"; "Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Title" = "إلغاء كتم الحساب"; "Scene.Profile.SegmentedControl.Media" = "وسائط"; "Scene.Profile.SegmentedControl.Posts" = "منشورات"; @@ -224,20 +229,20 @@ tap the link to confirm your account."; "Scene.Register.Error.Item.Password" = "الكلمة السرية"; "Scene.Register.Error.Item.Reason" = "السبب"; "Scene.Register.Error.Item.Username" = "اسم المستخدم"; -"Scene.Register.Error.Reason.Accepted" = "%@ must be accepted"; -"Scene.Register.Error.Reason.Blank" = "%@ is required"; -"Scene.Register.Error.Reason.Blocked" = "%@ contains a disallowed email provider"; -"Scene.Register.Error.Reason.Inclusion" = "%@ is not a supported value"; -"Scene.Register.Error.Reason.Invalid" = "%@ is invalid"; -"Scene.Register.Error.Reason.Reserved" = "%@ is a reserved keyword"; -"Scene.Register.Error.Reason.Taken" = "%@ is already in use"; -"Scene.Register.Error.Reason.TooLong" = "%@ is too long"; -"Scene.Register.Error.Reason.TooShort" = "%@ is too short"; -"Scene.Register.Error.Reason.Unreachable" = "%@ does not seem to exist"; -"Scene.Register.Error.Special.EmailInvalid" = "This is not a valid email address"; -"Scene.Register.Error.Special.PasswordTooShort" = "Password is too short (must be at least 8 characters)"; -"Scene.Register.Error.Special.UsernameInvalid" = "Username must only contain alphanumeric characters and underscores"; -"Scene.Register.Error.Special.UsernameTooLong" = "Username is too long (can’t be longer than 30 characters)"; +"Scene.Register.Error.Reason.Accepted" = "يجب أن يُقبل %@"; +"Scene.Register.Error.Reason.Blank" = "%@ مطلوب"; +"Scene.Register.Error.Reason.Blocked" = "يحتوي %@ على موفِّر خدمة بريد إلكتروني غير مسموح به"; +"Scene.Register.Error.Reason.Inclusion" = "إنَّ %@ قيمة غير مدعومة"; +"Scene.Register.Error.Reason.Invalid" = "%@ غير صالح"; +"Scene.Register.Error.Reason.Reserved" = "إنَّ %@ عبارة عن كلمة مفتاحيَّة محجوزة"; +"Scene.Register.Error.Reason.Taken" = "إنَّ %@ مُستخدَمٌ بالفعل"; +"Scene.Register.Error.Reason.TooLong" = "%@ طويل جداً"; +"Scene.Register.Error.Reason.TooShort" = "%@ قصير جدا"; +"Scene.Register.Error.Reason.Unreachable" = "يبدوا أنَّ %@ غير موجود"; +"Scene.Register.Error.Special.EmailInvalid" = "هذا عنوان بريد إلكتروني غير صالح"; +"Scene.Register.Error.Special.PasswordTooShort" = "كلمة المرور قصيرة جداً (يجب أن تكون 8 أحرف على الأقل)"; +"Scene.Register.Error.Special.UsernameInvalid" = "يُمكِن أن يحتوي اسم المستخدم على أحرف أبجدية، أرقام وشرطات سفلية فقط"; +"Scene.Register.Error.Special.UsernameTooLong" = "اسم المستخدم طويل جداً (يجب ألّا يكون أطول من 30 رمز)"; "Scene.Register.Input.Avatar.Delete" = "احذف"; "Scene.Register.Input.DisplayName.Placeholder" = "الاسم المعروض"; "Scene.Register.Input.Email.Placeholder" = "البريد الإلكتروني"; @@ -247,32 +252,32 @@ tap the link to confirm your account."; "Scene.Register.Input.Username.DuplicatePrompt" = "اسم المستخدم هذا غير متوفر."; "Scene.Register.Input.Username.Placeholder" = "اسم المستخدم"; "Scene.Register.Title" = "أخبرنا عنك."; -"Scene.Report.Content1" = "Are there any other posts you’d like to add to the report?"; -"Scene.Report.Content2" = "Is there anything the moderators should know about this report?"; -"Scene.Report.Send" = "Send Report"; -"Scene.Report.SkipToSend" = "Send without comment"; +"Scene.Report.Content1" = "هل ترغب في إضافة أي مشاركات أُخرى إلى الشكوى؟"; +"Scene.Report.Content2" = "هل هناك أي شيء يجب أن يعرفه المُراقبين حول هذه الشكوى؟"; +"Scene.Report.Send" = "إرسال الشكوى"; +"Scene.Report.SkipToSend" = "إرسال بدون تعليق"; "Scene.Report.Step1" = "الخطوة 1 من 2"; "Scene.Report.Step2" = "الخطوة 2 من 2"; -"Scene.Report.TextPlaceholder" = "Type or paste additional comments"; +"Scene.Report.TextPlaceholder" = "اكتب أو الصق تعليقات إضافيَّة"; "Scene.Report.Title" = "ابلغ عن %@"; -"Scene.Search.Recommend.Accounts.Description" = "You may like to follow these accounts"; +"Scene.Search.Recommend.Accounts.Description" = "قد ترغب في متابعة هذه الحسابات"; "Scene.Search.Recommend.Accounts.Follow" = "تابع"; -"Scene.Search.Recommend.Accounts.Title" = "Accounts you might like"; +"Scene.Search.Recommend.Accounts.Title" = "حسابات قد تعجبك"; "Scene.Search.Recommend.ButtonText" = "طالع الكل"; -"Scene.Search.Recommend.HashTag.Description" = "Hashtags that are getting quite a bit of attention"; -"Scene.Search.Recommend.HashTag.PeopleTalking" = "%@ people are talking"; +"Scene.Search.Recommend.HashTag.Description" = "الوسوم التي تحظى بقدر كبير من الاهتمام"; +"Scene.Search.Recommend.HashTag.PeopleTalking" = "%@ أشخاص يتحدَّثوا"; "Scene.Search.Recommend.HashTag.Title" = "ذات شعبية على ماستدون"; "Scene.Search.SearchBar.Cancel" = "إلغاء"; "Scene.Search.SearchBar.Placeholder" = "البحث عن وسوم أو مستخدمين·ات"; -"Scene.Search.Searching.Clear" = "Clear"; -"Scene.Search.Searching.EmptyState.NoResults" = "No results"; -"Scene.Search.Searching.RecentSearch" = "Recent searches"; +"Scene.Search.Searching.Clear" = "مَحو"; +"Scene.Search.Searching.EmptyState.NoResults" = "ليس هناك أية نتيجة"; +"Scene.Search.Searching.RecentSearch" = "عمليات البحث الأخيرة"; "Scene.Search.Searching.Segment.All" = "الكل"; "Scene.Search.Searching.Segment.Hashtags" = "الوسوم"; "Scene.Search.Searching.Segment.People" = "الأشخاص"; "Scene.Search.Searching.Segment.Posts" = "المنشورات"; "Scene.Search.Title" = "بحث"; -"Scene.ServerPicker.Button.Category.Academia" = "academia"; +"Scene.ServerPicker.Button.Category.Academia" = "أكاديمي"; "Scene.ServerPicker.Button.Category.Activism" = "للنشطاء"; "Scene.ServerPicker.Button.Category.All" = "الكل"; "Scene.ServerPicker.Button.Category.AllAccessiblityDescription" = "الفئة: الكل"; @@ -282,59 +287,62 @@ tap the link to confirm your account."; "Scene.ServerPicker.Button.Category.Games" = "ألعاب"; "Scene.ServerPicker.Button.Category.General" = "عام"; "Scene.ServerPicker.Button.Category.Journalism" = "صحافة"; -"Scene.ServerPicker.Button.Category.Lgbt" = "lgbt"; +"Scene.ServerPicker.Button.Category.Lgbt" = "مجتمع الشواذ"; "Scene.ServerPicker.Button.Category.Music" = "موسيقى"; "Scene.ServerPicker.Button.Category.Regional" = "اقليمي"; "Scene.ServerPicker.Button.Category.Tech" = "تكنولوجيا"; "Scene.ServerPicker.Button.SeeLess" = "اعرض أقل"; "Scene.ServerPicker.Button.SeeMore" = "اعرض المزيد"; -"Scene.ServerPicker.EmptyState.BadNetwork" = "Something went wrong while loading the data. Check your internet connection."; +"Scene.ServerPicker.EmptyState.BadNetwork" = "حدث خطأٌ ما أثناء تحميل البيانات. تحقَّق من اتصالك بالإنترنت."; "Scene.ServerPicker.EmptyState.FindingServers" = "البحث عن خوادم متوفرة..."; "Scene.ServerPicker.EmptyState.NoResults" = "لا توجد نتائج"; "Scene.ServerPicker.Input.Placeholder" = "ابحث عن خادم أو انضم إلى سيرفر خاص بك..."; "Scene.ServerPicker.Label.Category" = "الفئة"; "Scene.ServerPicker.Label.Language" = "اللغة"; "Scene.ServerPicker.Label.Users" = "مستخدمون·ات"; -"Scene.ServerPicker.Title" = "Pick a server, -any server."; +"Scene.ServerPicker.Title" = "اِختر خادِم، +أي خادِم."; "Scene.ServerRules.Button.Confirm" = "انا أوافق"; "Scene.ServerRules.PrivacyPolicy" = "سياسة الخصوصية"; -"Scene.ServerRules.Prompt" = "By continuing, you’re subject to the terms of service and privacy policy for %@."; -"Scene.ServerRules.Subtitle" = "These rules are set by the admins of %@."; +"Scene.ServerRules.Prompt" = "إن اخترت المواصلة، فإنك تخضع لشروط الخدمة وسياسة الخصوصية لـ %@."; +"Scene.ServerRules.Subtitle" = "تم سنّ هذه القواعد من قبل مشرفي %@."; "Scene.ServerRules.TermsOfService" = "شروط الخدمة"; -"Scene.ServerRules.Title" = "Some ground rules."; -"Scene.Settings.Footer.MastodonDescription" = "ماستدون برنامج مفتوح المصدر. يمكنك المساهمة، أو الإبلاغ عن تقارير الأخطاء، على غيت هب %@ (%@)"; +"Scene.ServerRules.Title" = "بعض القواعد الأساسية."; +"Scene.Settings.Footer.MastodonDescription" = "ماستدون برنامج مفتوح المصدر. يمكنك المساهمة، أو الإبلاغ عن تقارير الأخطاء على GitHub في %@ (%@)"; "Scene.Settings.Keyboard.CloseSettingsWindow" = "إغلاق نافذة الإعدادات"; "Scene.Settings.Section.Appearance.Automatic" = "تلقائي"; -"Scene.Settings.Section.Appearance.Dark" = "Always Dark"; -"Scene.Settings.Section.Appearance.Light" = "Always Light"; +"Scene.Settings.Section.Appearance.Dark" = "مظلمٌ دائِمًا"; +"Scene.Settings.Section.Appearance.Light" = "مضيءٌ دائمًا"; "Scene.Settings.Section.Appearance.Title" = "المظهر"; "Scene.Settings.Section.BoringZone.AccountSettings" = "إعدادات الحساب"; "Scene.Settings.Section.BoringZone.Privacy" = "سياسة الخصوصية"; "Scene.Settings.Section.BoringZone.Terms" = "شروط الخدمة"; -"Scene.Settings.Section.BoringZone.Title" = "The Boring Zone"; -"Scene.Settings.Section.Notifications.Boosts" = "Reblogs my post"; -"Scene.Settings.Section.Notifications.Favorites" = "Favorites my post"; -"Scene.Settings.Section.Notifications.Follows" = "Follows me"; -"Scene.Settings.Section.Notifications.Mentions" = "Mentions me"; +"Scene.Settings.Section.BoringZone.Title" = "المنطقة المملة"; +"Scene.Settings.Section.Notifications.Boosts" = "إعادة تدوين منشوراتي"; +"Scene.Settings.Section.Notifications.Favorites" = "الإعجاب بِمنشوراتي"; +"Scene.Settings.Section.Notifications.Follows" = "يتابعني"; +"Scene.Settings.Section.Notifications.Mentions" = "الإشارة لي"; "Scene.Settings.Section.Notifications.Title" = "الإشعارات"; -"Scene.Settings.Section.Notifications.Trigger.Anyone" = "anyone"; -"Scene.Settings.Section.Notifications.Trigger.Follow" = "anyone I follow"; -"Scene.Settings.Section.Notifications.Trigger.Follower" = "a follower"; -"Scene.Settings.Section.Notifications.Trigger.Noone" = "no one"; -"Scene.Settings.Section.Notifications.Trigger.Title" = "Notify me when"; -"Scene.Settings.Section.Preference.DisableAvatarAnimation" = "Disable animated avatars"; -"Scene.Settings.Section.Preference.DisableEmojiAnimation" = "Disable animated emojis"; +"Scene.Settings.Section.Notifications.Trigger.Anyone" = "أي شخص"; +"Scene.Settings.Section.Notifications.Trigger.Follow" = "أي شخص أُتابِعُه"; +"Scene.Settings.Section.Notifications.Trigger.Follower" = "مشترِك"; +"Scene.Settings.Section.Notifications.Trigger.Noone" = "لا أحد"; +"Scene.Settings.Section.Notifications.Trigger.Title" = "إشعاري عِندَ"; +"Scene.Settings.Section.Preference.DisableAvatarAnimation" = "تعطيل الصور الرمزية المتحرِّكة"; +"Scene.Settings.Section.Preference.DisableEmojiAnimation" = "تعطيل الرموز التعبيرية المتحرِّكَة"; "Scene.Settings.Section.Preference.Title" = "التفضيلات"; -"Scene.Settings.Section.Preference.TrueBlackDarkMode" = "True black dark mode"; -"Scene.Settings.Section.Preference.UsingDefaultBrowser" = "Use default browser to open links"; -"Scene.Settings.Section.SpicyZone.Clear" = "Clear Media Cache"; +"Scene.Settings.Section.Preference.TrueBlackDarkMode" = "النمط الأسود الداكِن الحقيقي"; +"Scene.Settings.Section.Preference.UsingDefaultBrowser" = "اِستخدام المتصفح الافتراضي لفتح الروابط"; +"Scene.Settings.Section.SpicyZone.Clear" = "مسح ذاكرة التخزين المؤقت للوسائط"; "Scene.Settings.Section.SpicyZone.Signout" = "تسجيل الخروج"; -"Scene.Settings.Section.SpicyZone.Title" = "The Spicy Zone"; +"Scene.Settings.Section.SpicyZone.Title" = "المنطقة الحارة"; "Scene.Settings.Title" = "الإعدادات"; -"Scene.SuggestionAccount.FollowExplain" = "When you follow someone, you’ll see their posts in your home feed."; -"Scene.SuggestionAccount.Title" = "Find People to Follow"; -"Scene.Thread.BackTitle" = "Post"; -"Scene.Thread.Title" = "Post from %@"; -"Scene.Welcome.Slogan" = "Social networking -back in your hands."; \ No newline at end of file +"Scene.SuggestionAccount.FollowExplain" = "عِندَ مُتابَعَتِكَ لأحدِهِم، سَوف تَرى مَنشوراته في تغذيَتِكَ الرئيسة."; +"Scene.SuggestionAccount.Title" = "ابحث عن أشخاص لمتابعتهم"; +"Scene.Thread.BackTitle" = "منشور"; +"Scene.Thread.Title" = "مَنشور مِن %@"; +"Scene.Welcome.Slogan" = "شبكات التواصل الاجتماعي +مرة أُخرى بين يديك."; +"Scene.Wizard.AccessibilityHint" = "انقر نقرًا مزدوجًا لتجاهل النافذة المنبثقة"; +"Scene.Wizard.MultipleAccountSwitchIntroDescription" = "بدِّل بين حسابات متعددة عبر الاستمرار بالضغط على زر الملف الشخصي."; +"Scene.Wizard.NewInMastodon" = "جديد في ماستودون"; \ No newline at end of file diff --git a/Mastodon/Resources/ar.lproj/Localizable.stringsdict b/Mastodon/Resources/ar.lproj/Localizable.stringsdict index 537064efb..0b28c577a 100644 --- a/Mastodon/Resources/ar.lproj/Localizable.stringsdict +++ b/Mastodon/Resources/ar.lproj/Localizable.stringsdict @@ -2,10 +2,34 @@ + a11y.plural.count.unread.notification + + NSStringLocalizedFormatKey + %#@notification_count_unread_notification@ + notification_count_unread_notification + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + zero + لا إشعار غير مقروء + one + إشعار واحِد غير مقروء + two + إشعاران غير مقروءان + few + %ld إشعارات غير مقروءة + many + %ld إشعارًا غيرَ مقروء + other + %ld إشعار غير مقروء + + a11y.plural.count.input_limit_exceeds NSStringLocalizedFormatKey - Input limit exceeds %#@character_count@ + تمَّ تجاوز حدّ الإدخال %#@character_count@ character_count NSStringFormatSpecTypeKey @@ -13,23 +37,23 @@ NSStringFormatValueTypeKey ld zero - %ld characters + لا حرف one - 1 character + حرفٌ واحِد two - %ld characters + حرفان اثنان few - %ld characters + %ld حُرُوف many - %ld characters + %ld حرفًا other - %ld characters + %ld حَرف a11y.plural.count.input_limit_remains NSStringLocalizedFormatKey - Input limit remains %#@character_count@ + يتبقَّى على حدّ الإدخال %#@character_count@ character_count NSStringFormatSpecTypeKey @@ -37,17 +61,17 @@ NSStringFormatValueTypeKey ld zero - %ld characters + لا حرف one - 1 character + حرفٌ واحِد two - %ld characters + حرفان اثنان few - %ld characters + %ld حُرُوف many - %ld characters + %ld حرفًا other - %ld characters + %ld حَرف plural.count.metric_formatted.post @@ -61,17 +85,17 @@ NSStringFormatValueTypeKey ld zero - posts + لا منشور one - post + منشور two - posts + منشوران few - posts + منشورات many - posts + منشورًا other - posts + منشور plural.count.post @@ -85,17 +109,17 @@ NSStringFormatValueTypeKey ld zero - %ld posts + لا منشور one - 1 post + منشورٌ واحِد two - %ld posts + منشورانِ اثنان few - %ld posts + %ld منشورات many - %ld posts + %ld منشورًا other - %ld posts + %ld منشور plural.count.favorite @@ -109,17 +133,17 @@ NSStringFormatValueTypeKey ld zero - %ld favorites + لا إعجاب one - 1 favorite + إعجابٌ واحِد two - %ld favorites + إعجابانِ اثنان few - %ld favorites + %ld إعجابات many - %ld favorites + %ld إعجابًا other - %ld favorites + %ld إعجاب plural.count.reblog @@ -133,17 +157,17 @@ NSStringFormatValueTypeKey ld zero - %ld reblogs + لا إعاد تدوين one - 1 reblog + إعادةُ تدوينٍ واحِدة two - %ld reblogs + إعادتا تدوين few - %ld reblogs + %ld إعاداتِ تدوين many - %ld reblogs + %ld إعادةٍ للتدوين other - %ld reblogs + %ld إعادة تدوين plural.count.vote @@ -157,17 +181,17 @@ NSStringFormatValueTypeKey ld zero - %ld votes + لا صوت one - 1 vote + صوتٌ واحِد two - %ld votes + صوتانِ اثنان few - %ld votes + %ld أصوات many - %ld votes + %ld صوتًا other - %ld votes + %ld صوت plural.count.voter @@ -181,17 +205,17 @@ NSStringFormatValueTypeKey ld zero - %ld voters + لا مُصوِّتون one - 1 voter + مُصوِّتٌ واحِد two - %ld voters + مُصوِّتانِ اثنان few - %ld voters + %ld مُصوِّتين many - %ld voters + %ld مُصوِّتًا other - %ld voters + %ld مُصوِّت plural.people_talking @@ -205,17 +229,17 @@ NSStringFormatValueTypeKey ld zero - %ld people talking + لا أحَدَ يتحدَّث one - 1 people talking + شخصٌ واحدٌ يتحدَّث two - %ld people talking + شخصانِ اثنان يتحدَّثا few - %ld people talking + %ld أشخاصٍ يتحدَّثون many - %ld people talking + %ld شخصًا يتحدَّثون other - %ld people talking + %ld شخصٍ يتحدَّثون plural.count.following @@ -229,17 +253,17 @@ NSStringFormatValueTypeKey ld zero - %ld following + لا مُتابَع one - 1 following + مُتابَعٌ واحد two - %ld following + مُتابَعانِ few - %ld following + %ld مُتابَعين many - %ld following + %ld مُتابَعًا other - %ld following + %ld مُتابَع plural.count.follower @@ -253,17 +277,17 @@ NSStringFormatValueTypeKey ld zero - %ld followers + لا مُتابِع one - 1 follower + مُتابِعٌ واحد two - %ld followers + مُتابِعانِ اثنان few - %ld followers + %ld مُتابِعين many - %ld followers + %ld مُتابِعًا other - %ld followers + %ld مُتابِع date.year.left @@ -277,17 +301,17 @@ NSStringFormatValueTypeKey ld zero - %ld years left + تتبقى لَحظة one - 1 year left + تتبقى سنة two - %ld years left + تتبقى سنتين few - %ld years left + تتبقى %ld سنوات many - %ld years left + تتبقى %ld سنةً other - %ld years left + تتبقى %ld سنة date.month.left @@ -301,17 +325,17 @@ NSStringFormatValueTypeKey ld zero - %ld months left + تتبقى لَحظة one - 1 months left + يتبقى شهر two - %ld months left + يتبقى شهرين few - %ld months left + يتبقى %ld أشهر many - %ld months left + يتبقى %ld شهرًا other - %ld months left + يتبقى %ld شهر date.day.left @@ -325,17 +349,17 @@ NSStringFormatValueTypeKey ld zero - %ld days left + تتبقى لحظة one - 1 day left + يتبقى يوم two - %ld days left + يتبقى يومين few - %ld days left + يتبقى %ld أيام many - %ld days left + يتبقى %ld يومًا other - %ld days left + يتبقى %ld يوم date.hour.left @@ -349,17 +373,17 @@ NSStringFormatValueTypeKey ld zero - %ld hours left + تتبقى لَحظة one - 1 hour left + تتبقى ساعة two - %ld hours left + تتبقى ساعتين few - %ld hours left + تتبقى %ld ساعات many - %ld hours left + تتبقى %ld ساعةً other - %ld hours left + تتبقى %ld ساعة date.minute.left @@ -373,17 +397,17 @@ NSStringFormatValueTypeKey ld zero - %ld minutes left + تتبقى لَحظة one - 1 minute left + تتبقى دقيقة two - %ld minutes left + تتبقى دقيقتين few - %ld minutes left + تتبقى %ld دقائق many - %ld minutes left + تتبقى %ld دقيقةً other - %ld minutes left + تتبقى %ld دقيقة date.second.left @@ -397,17 +421,17 @@ NSStringFormatValueTypeKey ld zero - %ld seconds left + تتبقى لَحظة one - 1 second left + تتبقى ثانية two - %ld seconds left + تتبقى ثانيتين few - %ld seconds left + تتبقى %ld ثوان many - %ld seconds left + تتبقى %ld ثانيةً other - %ld seconds left + تتبقى %ld ثانية date.year.ago.abbr @@ -421,17 +445,17 @@ NSStringFormatValueTypeKey ld zero - %ldy ago + مُنذُ لَحظة one - 1y ago + مُنذُ سنة two - %ldy ago + مُنذُ سنتين few - %ldy ago + مُنذُ %ld سنين many - %ldy ago + مُنذُ %ld سنةً other - %ldy ago + مُنذُ %ld سنة date.month.ago.abbr @@ -445,17 +469,17 @@ NSStringFormatValueTypeKey ld zero - %ldM ago + مُنذُ لَحظة one - 1M ago + مُنذُ شهر two - %ldM ago + مُنذُ شهرين few - %ldM ago + مُنذُ %ld أشهُر many - %ldM ago + مُنذُ %ld شهرًا other - %ldM ago + مُنذُ %ld شهر date.day.ago.abbr @@ -469,17 +493,17 @@ NSStringFormatValueTypeKey ld zero - %ldd ago + مُنذُ لَحظة one - 1d ago + مُنذُ يوم two - %ldd ago + مُنذُ يومين few - %ldd ago + مُنذُ %ld أيام many - %ldd ago + مُنذُ %ld يومًا other - %ldd ago + مُنذُ %ld يوم date.hour.ago.abbr @@ -493,17 +517,17 @@ NSStringFormatValueTypeKey ld zero - %ldh ago + مُنذُ لَحظة one - 1h ago + مُنذُ ساعة two - %ldh ago + مُنذُ ساعتين few - %ldh ago + مُنذُ %ld ساعات many - %ldh ago + مُنذُ %ld ساعةًَ other - %ldh ago + مُنذُ %ld ساعة date.minute.ago.abbr @@ -517,17 +541,17 @@ NSStringFormatValueTypeKey ld zero - %ldm ago + مُنذُ لَحظة one - 1m ago + مُنذُ دقيقة two - %ldm ago + مُنذُ دقيقتان few - %ldm ago + مُنذُ %ld دقائق many - %ldm ago + مُنذُ %ld دقيقةً other - %ldm ago + مُنذُ %ld دقيقة date.second.ago.abbr @@ -541,17 +565,17 @@ NSStringFormatValueTypeKey ld zero - %lds ago + مُنذُ لَحظة one - 1s ago + مُنذُ ثانية two - %lds ago + مُنذُ ثانيتين few - %lds ago + مُنذُ %ld ثوان many - %lds ago + مُنذُ %ld ثانية other - %lds ago + مُنذُ %ld ثانية diff --git a/Mastodon/Resources/ca.lproj/Localizable.strings b/Mastodon/Resources/ca.lproj/Localizable.strings index fc0168abc..1642fc8a5 100644 --- a/Mastodon/Resources/ca.lproj/Localizable.strings +++ b/Mastodon/Resources/ca.lproj/Localizable.strings @@ -28,6 +28,7 @@ Comprova la teva connexió a Internet."; "Common.Controls.Actions.Back" = "Enrere"; "Common.Controls.Actions.BlockDomain" = "Bloqueja %@"; "Common.Controls.Actions.Cancel" = "Cancel·la"; +"Common.Controls.Actions.Compose" = "Composa"; "Common.Controls.Actions.Confirm" = "Confirma"; "Common.Controls.Actions.Continue" = "Continua"; "Common.Controls.Actions.CopyPhoto" = "Copia la foto"; @@ -133,6 +134,9 @@ El teu perfil els sembla així."; "Common.Controls.Timeline.Loader.LoadingMissingPosts" = "Carregant les publicacions que falten..."; "Common.Controls.Timeline.Loader.ShowMoreReplies" = "Mostra més respostes"; "Common.Controls.Timeline.Timestamp.Now" = "Ara"; +"Scene.AccountList.AddAccount" = "Afegir compte"; +"Scene.AccountList.DismissAccountSwitcher" = "Descartar el commutador de comptes"; +"Scene.AccountList.TabBarHint" = "Perfil actual seleccionat: %@. Toca dues vegades i manté el dit per a mostrar el commutador de comptes"; "Scene.Compose.Accessibility.AppendAttachment" = "Afegeix Adjunt"; "Scene.Compose.Accessibility.AppendPoll" = "Afegir enquesta"; "Scene.Compose.Accessibility.CustomEmojiPicker" = "Selector d'Emoji Personalitzat"; @@ -187,6 +191,8 @@ carregat a Mastodon."; toca l'enllaç per a confirmar el teu compte."; "Scene.ConfirmEmail.Title" = "Una última cosa."; "Scene.Favorite.Title" = "Els teus Favorits"; +"Scene.Follower.Footer" = "Els seguidors d'altres servidors no son mostrats."; +"Scene.Following.Footer" = "Els seguits d'altres servidors no son mostrats."; "Scene.HomeTimeline.NavigationBarState.NewPosts" = "Veure noves publicacions"; "Scene.HomeTimeline.NavigationBarState.Offline" = "Fora de línia"; "Scene.HomeTimeline.NavigationBarState.Published" = "Publicat!"; @@ -337,4 +343,7 @@ qualsevol servidor."; "Scene.Thread.BackTitle" = "Publicació"; "Scene.Thread.Title" = "Publicació de %@"; "Scene.Welcome.Slogan" = "Xarxa social -de nou a les teves mans."; \ No newline at end of file +de nou a les teves mans."; +"Scene.Wizard.AccessibilityHint" = "Toca dues vegades per descartar l'assistent"; +"Scene.Wizard.MultipleAccountSwitchIntroDescription" = "Commuta entre diversos comptes mantenint premut el botó del perfil."; +"Scene.Wizard.NewInMastodon" = "Nou a Mastodon"; \ No newline at end of file diff --git a/Mastodon/Resources/ca.lproj/Localizable.stringsdict b/Mastodon/Resources/ca.lproj/Localizable.stringsdict index b76b0a921..140185bad 100644 --- a/Mastodon/Resources/ca.lproj/Localizable.stringsdict +++ b/Mastodon/Resources/ca.lproj/Localizable.stringsdict @@ -2,10 +2,26 @@ + a11y.plural.count.unread.notification + + NSStringLocalizedFormatKey + %#@notification_count_unread_notification@ + notification_count_unread_notification + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 notificació per llegir + other + %ld notificacions per llegir + + a11y.plural.count.input_limit_exceeds NSStringLocalizedFormatKey - El límit d’entrada supera a %#@character_count@ + El límit de la entrada supera a %#@character_count@ character_count NSStringFormatSpecTypeKey @@ -21,7 +37,7 @@ a11y.plural.count.input_limit_remains NSStringLocalizedFormatKey - El límit d’entrada continua sent %#@character_count@ + El límit de la entrada continua sent %#@character_count@ character_count NSStringFormatSpecTypeKey @@ -95,7 +111,7 @@ one 1 impuls other - %ld impuls + %ld impulsos plural.count.vote @@ -285,9 +301,9 @@ NSStringFormatValueTypeKey ld one - fa 1a + fa 1 any other - fa %ldy anys + fa %ld anys date.month.ago.abbr @@ -301,9 +317,9 @@ NSStringFormatValueTypeKey ld one - fa 1M + fa 1 mes other - fa %ldM mesos + fa %ld mesos date.day.ago.abbr @@ -317,9 +333,9 @@ NSStringFormatValueTypeKey ld one - fa 1d + fa 1 día other - fa %ldd dies + fa %ld dies date.hour.ago.abbr @@ -335,7 +351,7 @@ one fa 1h other - fa %ldh hores + fa %ld hores date.minute.ago.abbr @@ -349,9 +365,9 @@ NSStringFormatValueTypeKey ld one - fa 1m + fa 1 minut other - fa %ldm minuts + fa %ld minuts date.second.ago.abbr @@ -365,9 +381,9 @@ NSStringFormatValueTypeKey ld one - fa 1s + fa 1 segon other - fa %lds seg + fa %ld segons diff --git a/Mastodon/Resources/de.lproj/Localizable.strings b/Mastodon/Resources/de.lproj/Localizable.strings index 353cf1433..12fba5387 100644 --- a/Mastodon/Resources/de.lproj/Localizable.strings +++ b/Mastodon/Resources/de.lproj/Localizable.strings @@ -28,6 +28,7 @@ Bitte überprüfe deine Internetverbindung."; "Common.Controls.Actions.Back" = "Zurück"; "Common.Controls.Actions.BlockDomain" = "%@ blockieren"; "Common.Controls.Actions.Cancel" = "Abbrechen"; +"Common.Controls.Actions.Compose" = "Compose"; "Common.Controls.Actions.Confirm" = "Bestätigen"; "Common.Controls.Actions.Continue" = "Fortfahren"; "Common.Controls.Actions.CopyPhoto" = "Foto kopieren"; @@ -133,6 +134,9 @@ Dein Profil sieht für diesen Benutzer auch so aus."; "Common.Controls.Timeline.Loader.LoadingMissingPosts" = "Lade fehlende Beiträge..."; "Common.Controls.Timeline.Loader.ShowMoreReplies" = "Weitere Antworten anzeigen"; "Common.Controls.Timeline.Timestamp.Now" = "Gerade"; +"Scene.AccountList.AddAccount" = "Konto hinzufügen"; +"Scene.AccountList.DismissAccountSwitcher" = "Dismiss Account Switcher"; +"Scene.AccountList.TabBarHint" = "Aktuell ausgewähltes Profil: %@. Doppeltippen dann gedrückt halten, um den Kontoschalter anzuzeigen"; "Scene.Compose.Accessibility.AppendAttachment" = "Anhang hinzufügen"; "Scene.Compose.Accessibility.AppendPoll" = "Umfrage hinzufügen"; "Scene.Compose.Accessibility.CustomEmojiPicker" = "Benutzerdefinierter Emojiwähler"; @@ -187,6 +191,8 @@ kann nicht auf Mastodon hochgeladen werden."; tippe darin auf den Link, um Dein Konto zu bestätigen."; "Scene.ConfirmEmail.Title" = "Noch eine letzte Sache."; "Scene.Favorite.Title" = "Deine Favoriten"; +"Scene.Follower.Footer" = "Followers from other servers are not displayed."; +"Scene.Following.Footer" = "Follows from other servers are not displayed."; "Scene.HomeTimeline.NavigationBarState.NewPosts" = "Neue Beiträge anzeigen"; "Scene.HomeTimeline.NavigationBarState.Offline" = "Offline"; "Scene.HomeTimeline.NavigationBarState.Published" = "Veröffentlicht!"; @@ -336,4 +342,7 @@ beliebigen Server."; "Scene.SuggestionAccount.Title" = "Finde Personen zum Folgen"; "Scene.Thread.BackTitle" = "Beitrag"; "Scene.Thread.Title" = "Beitrag von %@"; -"Scene.Welcome.Slogan" = "Soziale Netzwerke wieder in deinen Händen."; \ No newline at end of file +"Scene.Welcome.Slogan" = "Soziale Netzwerke wieder in deinen Händen."; +"Scene.Wizard.AccessibilityHint" = "Doppeltippen, um diesen Assistenten zu schließen"; +"Scene.Wizard.MultipleAccountSwitchIntroDescription" = "Wechsel zwischen mehreren Konten durch drücken der Profil-Schaltfläche."; +"Scene.Wizard.NewInMastodon" = "Neu in Mastodon"; \ No newline at end of file diff --git a/Mastodon/Resources/de.lproj/Localizable.stringsdict b/Mastodon/Resources/de.lproj/Localizable.stringsdict index e89bdb074..66b7f2a2d 100644 --- a/Mastodon/Resources/de.lproj/Localizable.stringsdict +++ b/Mastodon/Resources/de.lproj/Localizable.stringsdict @@ -2,6 +2,22 @@ + a11y.plural.count.unread.notification + + NSStringLocalizedFormatKey + %#@notification_count_unread_notification@ + notification_count_unread_notification + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 ungelesene Benachrichtigung + other + %ld ungelesene Benachrichtigungen + + a11y.plural.count.input_limit_exceeds NSStringLocalizedFormatKey diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index ff8bb12d8..0f3ed66ae 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -28,6 +28,7 @@ Please check your internet connection."; "Common.Controls.Actions.Back" = "Back"; "Common.Controls.Actions.BlockDomain" = "Block %@"; "Common.Controls.Actions.Cancel" = "Cancel"; +"Common.Controls.Actions.Compose" = "Compose"; "Common.Controls.Actions.Confirm" = "Confirm"; "Common.Controls.Actions.Continue" = "Continue"; "Common.Controls.Actions.CopyPhoto" = "Copy Photo"; @@ -133,6 +134,9 @@ Your profile looks like this to them."; "Common.Controls.Timeline.Loader.LoadingMissingPosts" = "Loading missing posts..."; "Common.Controls.Timeline.Loader.ShowMoreReplies" = "Show more replies"; "Common.Controls.Timeline.Timestamp.Now" = "Now"; +"Scene.AccountList.AddAccount" = "Add Account"; +"Scene.AccountList.DismissAccountSwitcher" = "Dismiss Account Switcher"; +"Scene.AccountList.TabBarHint" = "Current selected profile: %@. Double tap then hold to show account switcher"; "Scene.Compose.Accessibility.AppendAttachment" = "Add Attachment"; "Scene.Compose.Accessibility.AppendPoll" = "Add Poll"; "Scene.Compose.Accessibility.CustomEmojiPicker" = "Custom Emoji Picker"; @@ -187,6 +191,8 @@ uploaded to Mastodon."; tap the link to confirm your account."; "Scene.ConfirmEmail.Title" = "One last thing."; "Scene.Favorite.Title" = "Your Favorites"; +"Scene.Follower.Footer" = "Followers from other servers are not displayed."; +"Scene.Following.Footer" = "Follows from other servers are not displayed."; "Scene.HomeTimeline.NavigationBarState.NewPosts" = "See new posts"; "Scene.HomeTimeline.NavigationBarState.Offline" = "Offline"; "Scene.HomeTimeline.NavigationBarState.Published" = "Published!"; @@ -337,4 +343,7 @@ any server."; "Scene.Thread.BackTitle" = "Post"; "Scene.Thread.Title" = "Post from %@"; "Scene.Welcome.Slogan" = "Social networking -back in your hands."; \ No newline at end of file +back in your hands."; +"Scene.Wizard.AccessibilityHint" = "Double tap to dismiss this wizard"; +"Scene.Wizard.MultipleAccountSwitchIntroDescription" = "Switch between multiple accounts by holding the profile button."; +"Scene.Wizard.NewInMastodon" = "New in Mastodon"; \ No newline at end of file diff --git a/Mastodon/Resources/en.lproj/Localizable.stringsdict b/Mastodon/Resources/en.lproj/Localizable.stringsdict index c7c84d074..730e2902a 100644 --- a/Mastodon/Resources/en.lproj/Localizable.stringsdict +++ b/Mastodon/Resources/en.lproj/Localizable.stringsdict @@ -2,6 +2,22 @@ + a11y.plural.count.unread.notification + + NSStringLocalizedFormatKey + %#@notification_count_unread_notification@ + notification_count_unread_notification + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 unread notification + other + %ld unread notification + + a11y.plural.count.input_limit_exceeds NSStringLocalizedFormatKey diff --git a/Mastodon/Resources/es-419.lproj/Localizable.strings b/Mastodon/Resources/es-419.lproj/Localizable.strings index 7189f282a..cf97fe803 100644 --- a/Mastodon/Resources/es-419.lproj/Localizable.strings +++ b/Mastodon/Resources/es-419.lproj/Localizable.strings @@ -28,6 +28,7 @@ Por favor, revisá tu conexión a Internet."; "Common.Controls.Actions.Back" = "Volver"; "Common.Controls.Actions.BlockDomain" = "Bloquear a %@"; "Common.Controls.Actions.Cancel" = "Cancelar"; +"Common.Controls.Actions.Compose" = "Redactar"; "Common.Controls.Actions.Confirm" = "Confirmar"; "Common.Controls.Actions.Continue" = "Continuar"; "Common.Controls.Actions.CopyPhoto" = "Copiar foto"; @@ -133,6 +134,9 @@ Tu perfil le aparece así a este usuario."; "Common.Controls.Timeline.Loader.LoadingMissingPosts" = "Cargando mensajes faltantes…"; "Common.Controls.Timeline.Loader.ShowMoreReplies" = "Mostrar más respuestas"; "Common.Controls.Timeline.Timestamp.Now" = "Ahora"; +"Scene.AccountList.AddAccount" = "Agregar cuenta"; +"Scene.AccountList.DismissAccountSwitcher" = "Descartar cambio de cuenta"; +"Scene.AccountList.TabBarHint" = "Perfil seleccionado actualmente: %@. Tocá dos veces y mantenelo presionado para cambiar de cuenta"; "Scene.Compose.Accessibility.AppendAttachment" = "Agregar archivo adjunto"; "Scene.Compose.Accessibility.AppendPoll" = "Agregar encuesta"; "Scene.Compose.Accessibility.CustomEmojiPicker" = "Selector de emoji personalizado"; @@ -187,6 +191,8 @@ y no se puede subir a Mastodon."; pulsá en el enlace para confirmar tu cuenta."; "Scene.ConfirmEmail.Title" = "Una última cosa."; "Scene.Favorite.Title" = "Tus favoritos"; +"Scene.Follower.Footer" = "No se muestran los seguidores de otros servidores."; +"Scene.Following.Footer" = "No se muestran las cuentas de otros servidores que seguís."; "Scene.HomeTimeline.NavigationBarState.NewPosts" = "Ver nuevos mensajes"; "Scene.HomeTimeline.NavigationBarState.Offline" = "Desconectado"; "Scene.HomeTimeline.NavigationBarState.Published" = "¡Enviado!"; @@ -337,4 +343,7 @@ el que quieras."; "Scene.Thread.BackTitle" = "Mensaje"; "Scene.Thread.Title" = "Mensaje de %@"; "Scene.Welcome.Slogan" = "La red social, -nuevamente en tu poder."; \ No newline at end of file +nuevamente en tu poder."; +"Scene.Wizard.AccessibilityHint" = "Tocá dos veces para descartar este asistente"; +"Scene.Wizard.MultipleAccountSwitchIntroDescription" = "Cambiá entre varias cuentas manteniendo presionado el botón del perfil."; +"Scene.Wizard.NewInMastodon" = "Novedad en Mastodon"; \ No newline at end of file diff --git a/Mastodon/Resources/es-419.lproj/Localizable.stringsdict b/Mastodon/Resources/es-419.lproj/Localizable.stringsdict index f98962ccb..f4f0097eb 100644 --- a/Mastodon/Resources/es-419.lproj/Localizable.stringsdict +++ b/Mastodon/Resources/es-419.lproj/Localizable.stringsdict @@ -2,6 +2,22 @@ + a11y.plural.count.unread.notification + + NSStringLocalizedFormatKey + %#@notification_count_unread_notification@ + notification_count_unread_notification + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 notificación sin leer + other + %ld notificaciones sin leer + + a11y.plural.count.input_limit_exceeds NSStringLocalizedFormatKey diff --git a/Mastodon/Resources/es.lproj/Localizable.strings b/Mastodon/Resources/es.lproj/Localizable.strings index db1680d43..dcf12dfe2 100644 --- a/Mastodon/Resources/es.lproj/Localizable.strings +++ b/Mastodon/Resources/es.lproj/Localizable.strings @@ -28,6 +28,7 @@ Por favor, revise su conexión a internet."; "Common.Controls.Actions.Back" = "Atrás"; "Common.Controls.Actions.BlockDomain" = "Bloquear %@"; "Common.Controls.Actions.Cancel" = "Cancelar"; +"Common.Controls.Actions.Compose" = "Redactar"; "Common.Controls.Actions.Confirm" = "Confirmar"; "Common.Controls.Actions.Continue" = "Continuar"; "Common.Controls.Actions.CopyPhoto" = "Copiar foto"; @@ -133,6 +134,9 @@ Tu perfil se ve así para él."; "Common.Controls.Timeline.Loader.LoadingMissingPosts" = "Cargando publicaciones faltantes..."; "Common.Controls.Timeline.Loader.ShowMoreReplies" = "Mostrar más respuestas"; "Common.Controls.Timeline.Timestamp.Now" = "Ahora"; +"Scene.AccountList.AddAccount" = "Add Account"; +"Scene.AccountList.DismissAccountSwitcher" = "Dismiss Account Switcher"; +"Scene.AccountList.TabBarHint" = "Current selected profile: %@. Double tap then hold to show account switcher"; "Scene.Compose.Accessibility.AppendAttachment" = "Añadir Adjunto"; "Scene.Compose.Accessibility.AppendPoll" = "Añadir Encuesta"; "Scene.Compose.Accessibility.CustomEmojiPicker" = "Selector de Emojis Personalizados"; @@ -187,6 +191,8 @@ subirse a Mastodon."; pulsa en el enlace para confirmar tu cuenta."; "Scene.ConfirmEmail.Title" = "Una última cosa."; "Scene.Favorite.Title" = "Tus Favoritos"; +"Scene.Follower.Footer" = "No se muestran los seguidores de otros servidores."; +"Scene.Following.Footer" = "No se muestran los seguidos de otros servidores."; "Scene.HomeTimeline.NavigationBarState.NewPosts" = "Ver nuevas publicaciones"; "Scene.HomeTimeline.NavigationBarState.Offline" = "Sin Conexión"; "Scene.HomeTimeline.NavigationBarState.Published" = "¡Publicado!"; @@ -337,4 +343,7 @@ cualquier servidor."; "Scene.Thread.BackTitle" = "Publicación"; "Scene.Thread.Title" = "Publicación de %@"; "Scene.Welcome.Slogan" = "Las redes sociales -de nuevo en tus manos."; \ No newline at end of file +de nuevo en tus manos."; +"Scene.Wizard.AccessibilityHint" = "Double tap to dismiss this wizard"; +"Scene.Wizard.MultipleAccountSwitchIntroDescription" = "Switch between multiple accounts by holding the profile button."; +"Scene.Wizard.NewInMastodon" = "New in Mastodon"; \ No newline at end of file diff --git a/Mastodon/Resources/es.lproj/Localizable.stringsdict b/Mastodon/Resources/es.lproj/Localizable.stringsdict index bf493c1e8..d31d8825b 100644 --- a/Mastodon/Resources/es.lproj/Localizable.stringsdict +++ b/Mastodon/Resources/es.lproj/Localizable.stringsdict @@ -2,6 +2,22 @@ + a11y.plural.count.unread.notification + + NSStringLocalizedFormatKey + %#@notification_count_unread_notification@ + notification_count_unread_notification + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 unread notification + other + %ld unread notification + + a11y.plural.count.input_limit_exceeds NSStringLocalizedFormatKey diff --git a/Mastodon/Resources/fr.lproj/Localizable.strings b/Mastodon/Resources/fr.lproj/Localizable.strings index f7810e3fd..a4dbfdb6f 100644 --- a/Mastodon/Resources/fr.lproj/Localizable.strings +++ b/Mastodon/Resources/fr.lproj/Localizable.strings @@ -28,6 +28,7 @@ Veuillez vérifier votre accès à Internet."; "Common.Controls.Actions.Back" = "Retour"; "Common.Controls.Actions.BlockDomain" = "Bloquer %@"; "Common.Controls.Actions.Cancel" = "Annuler"; +"Common.Controls.Actions.Compose" = "Compose"; "Common.Controls.Actions.Confirm" = "Confirmer"; "Common.Controls.Actions.Continue" = "Continuer"; "Common.Controls.Actions.CopyPhoto" = "Copier la photo"; @@ -81,12 +82,12 @@ Veuillez vérifier votre accès à Internet."; "Common.Controls.Keyboard.Common.SwitchToTab" = "Basculer vers %@"; "Common.Controls.Keyboard.SegmentedControl.NextSection" = "Prochaine section"; "Common.Controls.Keyboard.SegmentedControl.PreviousSection" = "Section précédente"; -"Common.Controls.Keyboard.Timeline.NextStatus" = "Article suivant"; -"Common.Controls.Keyboard.Timeline.OpenAuthorProfile" = "Ouvrir le profil de l'auteur"; +"Common.Controls.Keyboard.Timeline.NextStatus" = "Publication suivante"; +"Common.Controls.Keyboard.Timeline.OpenAuthorProfile" = "Ouvrir le profil de l’auteur·rice"; "Common.Controls.Keyboard.Timeline.OpenRebloggerProfile" = "Ouvrir le profil du rebloggeur"; "Common.Controls.Keyboard.Timeline.OpenStatus" = "Ouvrir la publication"; "Common.Controls.Keyboard.Timeline.PreviewImage" = "Prévisualiser l’image"; -"Common.Controls.Keyboard.Timeline.PreviousStatus" = "Article précédent"; +"Common.Controls.Keyboard.Timeline.PreviousStatus" = "Publication précédente"; "Common.Controls.Keyboard.Timeline.ReplyStatus" = "Répondre à la publication"; "Common.Controls.Keyboard.Timeline.ToggleContentWarning" = "Basculer l’avertissement de contenu"; "Common.Controls.Keyboard.Timeline.ToggleFavorite" = "Basculer le favori lors de la publication"; @@ -102,14 +103,14 @@ Veuillez vérifier votre accès à Internet."; "Common.Controls.Status.Poll.Closed" = "Fermé"; "Common.Controls.Status.Poll.Vote" = "Voter"; "Common.Controls.Status.ShowPost" = "Montrer la publication"; -"Common.Controls.Status.ShowUserProfile" = "Montrer le profil de l’utilisateur"; +"Common.Controls.Status.ShowUserProfile" = "Montrer le profil de l’utilisateur·rice"; "Common.Controls.Status.Tag.Email" = "Courriel"; "Common.Controls.Status.Tag.Emoji" = "Émoji"; "Common.Controls.Status.Tag.Hashtag" = "Hashtag"; "Common.Controls.Status.Tag.Link" = "Lien"; "Common.Controls.Status.Tag.Mention" = "Mention"; "Common.Controls.Status.Tag.Url" = "URL"; -"Common.Controls.Status.UserReblogged" = "%@ à reblogué"; +"Common.Controls.Status.UserReblogged" = "%@ a reblogué"; "Common.Controls.Status.UserRepliedTo" = "À répondu à %@"; "Common.Controls.Tabs.Home" = "Accueil"; "Common.Controls.Tabs.Notification" = "Notification"; @@ -124,17 +125,21 @@ Votre profil ressemble à ça pour lui."; "Common.Controls.Timeline.Header.NoStatusFound" = "Aucune publication trouvée"; "Common.Controls.Timeline.Header.SuspendedWarning" = "Cet utilisateur a été suspendu."; "Common.Controls.Timeline.Header.UserBlockedWarning" = "Vous ne pouvez pas voir le profil de %@ - tant qu'il ne vous aura pas débloqué."; -"Common.Controls.Timeline.Header.UserBlockingWarning" = "Vous ne pouvez pas voir le profil de %@ tant que vous ne l’avez pas débloqué +tant qu’il ne vous aura pas débloqué."; +"Common.Controls.Timeline.Header.UserBlockingWarning" = "Vous ne pouvez pas voir le profil de %@ +tant que vous ne l’avez pas débloqué Votre profil ressemble à ça pour lui."; "Common.Controls.Timeline.Header.UserSuspendedWarning" = "Le compte de %@ à été suspendu."; "Common.Controls.Timeline.Loader.LoadMissingPosts" = "Charger les messages manquants"; "Common.Controls.Timeline.Loader.LoadingMissingPosts" = "Chargement des publications manquantes..."; "Common.Controls.Timeline.Loader.ShowMoreReplies" = "Charger plus de réponses"; "Common.Controls.Timeline.Timestamp.Now" = "À l’instant"; +"Scene.AccountList.AddAccount" = "Ajouter un compte"; +"Scene.AccountList.DismissAccountSwitcher" = "Dismiss Account Switcher"; +"Scene.AccountList.TabBarHint" = "Current selected profile: %@. Double tap then hold to show account switcher"; "Scene.Compose.Accessibility.AppendAttachment" = "Joindre un document"; "Scene.Compose.Accessibility.AppendPoll" = "Ajouter un Sondage"; -"Scene.Compose.Accessibility.CustomEmojiPicker" = "Sélecteur d’émojis personnalisé"; +"Scene.Compose.Accessibility.CustomEmojiPicker" = "Sélecteur d’émojis personnalisés"; "Scene.Compose.Accessibility.DisableContentWarning" = "Désactiver l'avertissement de contenu"; "Scene.Compose.Accessibility.EnableContentWarning" = "Basculer l’avertissement de contenu"; "Scene.Compose.Accessibility.PostVisibilityMenu" = "Menu de Visibilité de la publication"; @@ -186,6 +191,8 @@ téléversé sur Mastodon."; tapotez le lien pour confirmer votre compte."; "Scene.ConfirmEmail.Title" = "Une dernière chose."; "Scene.Favorite.Title" = "Vos favoris"; +"Scene.Follower.Footer" = "Les abonné·e·s issus des autres serveurs ne sont pas affiché·e·s."; +"Scene.Following.Footer" = "Follows from other servers are not displayed."; "Scene.HomeTimeline.NavigationBarState.NewPosts" = "Voir les nouvelles publications"; "Scene.HomeTimeline.NavigationBarState.Offline" = "Hors ligne"; "Scene.HomeTimeline.NavigationBarState.Published" = "Publié!"; @@ -195,12 +202,12 @@ tapotez le lien pour confirmer votre compte."; "Scene.Notification.Keyobard.ShowMentions" = "Afficher les mentions"; "Scene.Notification.Title.Everything" = "Tout"; "Scene.Notification.Title.Mentions" = "Mentions"; -"Scene.Notification.UserFavorited Your Post" = "%@ favorited your post"; -"Scene.Notification.UserFollowedYou" = "%@ followed you"; -"Scene.Notification.UserMentionedYou" = "%@ mentioned you"; -"Scene.Notification.UserRebloggedYourPost" = "%@ reblogged your post"; -"Scene.Notification.UserRequestedToFollowYou" = "%@ requested to follow you"; -"Scene.Notification.UserYourPollHasEnded" = "%@ Your poll has ended"; +"Scene.Notification.UserFavorited Your Post" = "%@ a mis votre pouet en favori"; +"Scene.Notification.UserFollowedYou" = "%@ s’est abonné à vous"; +"Scene.Notification.UserMentionedYou" = "%@ vous a mentionné"; +"Scene.Notification.UserRebloggedYourPost" = "%@ a partagé votre publication"; +"Scene.Notification.UserRequestedToFollowYou" = "%@ a demandé à vous suivre"; +"Scene.Notification.UserYourPollHasEnded" = "%@ votre sondage est terminé"; "Scene.Preview.Keyboard.ClosePreview" = "Fermer l'aperçu"; "Scene.Preview.Keyboard.ShowNext" = "Afficher le suivant"; "Scene.Preview.Keyboard.ShowPrevious" = "Afficher le précédent"; @@ -212,7 +219,7 @@ tapotez le lien pour confirmer votre compte."; "Scene.Profile.Fields.Placeholder.Label" = "Étiquette"; "Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Message" = "Confirmer le déblocage de %@"; "Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Title" = "Débloquer le compte"; -"Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Message" = "Êtes-vous sûr de vouloir mettre en sourdine %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Message" = "Êtes-vous sûr de vouloir désactiver la sourdine de %@"; "Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Title" = "Ne plus mettre en sourdine ce compte"; "Scene.Profile.SegmentedControl.Media" = "Média"; "Scene.Profile.SegmentedControl.Posts" = "Publications"; @@ -222,7 +229,7 @@ tapotez le lien pour confirmer votre compte."; "Scene.Register.Error.Item.Locale" = "Lieu"; "Scene.Register.Error.Item.Password" = "Mot de passe"; "Scene.Register.Error.Item.Reason" = "Raison"; -"Scene.Register.Error.Item.Username" = "Nom d'utilisateur"; +"Scene.Register.Error.Item.Username" = "Nom d’utilisateur"; "Scene.Register.Error.Reason.Accepted" = "%@ doit être accepté"; "Scene.Register.Error.Reason.Blank" = "%@ est requis"; "Scene.Register.Error.Reason.Blocked" = "%@ contient un fournisseur courriel proscrit"; @@ -293,7 +300,7 @@ tapotez le lien pour confirmer votre compte."; "Scene.ServerPicker.Input.Placeholder" = "Trouvez un serveur ou rejoignez le vôtre..."; "Scene.ServerPicker.Label.Category" = "CATÉGORIE"; "Scene.ServerPicker.Label.Language" = "LANGUE"; -"Scene.ServerPicker.Label.Users" = "UTILISATEURS"; +"Scene.ServerPicker.Label.Users" = "UTILISATEUR·RICE·S"; "Scene.ServerPicker.Title" = "Choisissez un serveur, n'importe quel serveur."; "Scene.ServerRules.Button.Confirm" = "J’accepte"; @@ -323,7 +330,7 @@ n'importe quel serveur."; "Scene.Settings.Section.Notifications.Trigger.Noone" = "personne"; "Scene.Settings.Section.Notifications.Trigger.Title" = "Me notifier lorsque"; "Scene.Settings.Section.Preference.DisableAvatarAnimation" = "Désactiver les avatars animés"; -"Scene.Settings.Section.Preference.DisableEmojiAnimation" = "Désactiver les émoticônes animées"; +"Scene.Settings.Section.Preference.DisableEmojiAnimation" = "Désactiver les émojis animées"; "Scene.Settings.Section.Preference.Title" = "Préférences"; "Scene.Settings.Section.Preference.TrueBlackDarkMode" = "Vrai mode sombre"; "Scene.Settings.Section.Preference.UsingDefaultBrowser" = "Utiliser le navigateur par défaut pour ouvrir les liens"; @@ -335,4 +342,7 @@ n'importe quel serveur."; "Scene.SuggestionAccount.Title" = "Trouver des personnes à suivre"; "Scene.Thread.BackTitle" = "Publication"; "Scene.Thread.Title" = "Publication de %@"; -"Scene.Welcome.Slogan" = "Le réseau social qui vous rend le contrôle."; \ No newline at end of file +"Scene.Welcome.Slogan" = "Le réseau social qui vous rend le contrôle."; +"Scene.Wizard.AccessibilityHint" = "Tapotez deux fois pour fermer cet assistant"; +"Scene.Wizard.MultipleAccountSwitchIntroDescription" = "Basculez entre plusieurs comptes en appuyant de maniere prolongée sur le bouton profil."; +"Scene.Wizard.NewInMastodon" = "Nouveau dans Mastodon"; \ No newline at end of file diff --git a/Mastodon/Resources/fr.lproj/Localizable.stringsdict b/Mastodon/Resources/fr.lproj/Localizable.stringsdict index d6fb911f3..4a912e4b3 100644 --- a/Mastodon/Resources/fr.lproj/Localizable.stringsdict +++ b/Mastodon/Resources/fr.lproj/Localizable.stringsdict @@ -2,10 +2,26 @@ + a11y.plural.count.unread.notification + + NSStringLocalizedFormatKey + %#@notification_count_unread_notification@ + notification_count_unread_notification + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 notification non lue + other + %ld notifications non lues + + a11y.plural.count.input_limit_exceeds NSStringLocalizedFormatKey - Input limit exceeds %#@character_count@ + La limite d’entrée dépasse %#@character_count@ character_count NSStringFormatSpecTypeKey @@ -13,9 +29,9 @@ NSStringFormatValueTypeKey ld one - 1 character + 1 caractère other - %ld characters + %ld caractères a11y.plural.count.input_limit_remains @@ -29,9 +45,9 @@ NSStringFormatValueTypeKey ld one - 1 character + 1 caractère other - %ld characters + %ld caractères plural.count.metric_formatted.post @@ -61,9 +77,9 @@ NSStringFormatValueTypeKey ld one - 1 post + 1 publication other - %ld posts + %ld publications plural.count.favorite @@ -157,9 +173,9 @@ NSStringFormatValueTypeKey ld one - 1 following + 1 abonnement other - %ld following + %ld abonnements plural.count.follower @@ -173,9 +189,9 @@ NSStringFormatValueTypeKey ld one - 1 follower + 1 abonné·e other - %ld followers + %ld abonné·e·s date.year.left @@ -189,9 +205,9 @@ NSStringFormatValueTypeKey ld one - 1 year left + Il reste 1 an other - %ld years left + %ld ans restants date.month.left @@ -205,9 +221,9 @@ NSStringFormatValueTypeKey ld one - 1 months left + 1 mois restant other - %ld months left + %ld mois restants date.day.left @@ -221,9 +237,9 @@ NSStringFormatValueTypeKey ld one - 1 day left + Il reste 1 jour other - %ld days left + il reste %ld jours date.hour.left @@ -237,9 +253,9 @@ NSStringFormatValueTypeKey ld one - 1 hour left + 1 heure restante other - %ld hours left + %ld heures restantes date.minute.left @@ -253,9 +269,9 @@ NSStringFormatValueTypeKey ld one - 1 minute left + 1 minute restante other - %ld minutes left + %ld minutes restantes date.second.left @@ -269,9 +285,9 @@ NSStringFormatValueTypeKey ld one - 1 second left + Il reste 1 seconde other - %ld seconds left + %ld secondes restantes date.year.ago.abbr @@ -285,9 +301,9 @@ NSStringFormatValueTypeKey ld one - 1y ago + il y a 1 année other - %ldy ago + il y a %ld ans date.month.ago.abbr @@ -301,9 +317,9 @@ NSStringFormatValueTypeKey ld one - 1M ago + il y a 1 mois other - %ldM ago + il y a %ld mois date.day.ago.abbr @@ -317,9 +333,9 @@ NSStringFormatValueTypeKey ld one - 1d ago + il y a 1j other - %ldd ago + il y a %ldj date.hour.ago.abbr @@ -333,9 +349,9 @@ NSStringFormatValueTypeKey ld one - 1h ago + il y a 1h other - %ldh ago + il y a %ldh date.minute.ago.abbr @@ -349,9 +365,9 @@ NSStringFormatValueTypeKey ld one - 1m ago + Il y a 1 m other - %ldm ago + il y a %ld m date.second.ago.abbr @@ -365,9 +381,9 @@ NSStringFormatValueTypeKey ld one - 1s ago + Il y a 1 s other - %lds ago + il y a %ld s diff --git a/Mastodon/Resources/gd-GB.lproj/Localizable.strings b/Mastodon/Resources/gd-GB.lproj/Localizable.strings index 985819610..3f80f6411 100644 --- a/Mastodon/Resources/gd-GB.lproj/Localizable.strings +++ b/Mastodon/Resources/gd-GB.lproj/Localizable.strings @@ -28,6 +28,7 @@ Thoir sùil air a’ cheangal agad ris an eadar-lìon."; "Common.Controls.Actions.Back" = "Air ais"; "Common.Controls.Actions.BlockDomain" = "Bac %@"; "Common.Controls.Actions.Cancel" = "Sguir dheth"; +"Common.Controls.Actions.Compose" = "Sgrìobh"; "Common.Controls.Actions.Confirm" = "Dearbh"; "Common.Controls.Actions.Continue" = "Lean air adhart"; "Common.Controls.Actions.CopyPhoto" = "Dèan lethbhreac dhen dealbh"; @@ -133,6 +134,9 @@ Seo an coltas a th’ air a’ phròifil agad dhaibh-san."; "Common.Controls.Timeline.Loader.LoadingMissingPosts" = "A’ luchdadh nam post a tha a dhìth…"; "Common.Controls.Timeline.Loader.ShowMoreReplies" = "Seall barrachd freagairtean"; "Common.Controls.Timeline.Timestamp.Now" = "An-dràsta"; +"Scene.AccountList.AddAccount" = "Cuir cunntas ris"; +"Scene.AccountList.DismissAccountSwitcher" = "Leig seachad taghadh a’ chunntais"; +"Scene.AccountList.TabBarHint" = "A’ phròifil air a taghadh: %@. Thoir gnogag dhùbailte is cùm sìos a ghearradh leum gu cunntas eile"; "Scene.Compose.Accessibility.AppendAttachment" = "Cuir ceanglachan ris"; "Scene.Compose.Accessibility.AppendPoll" = "Cuir cunntas-bheachd ris"; "Scene.Compose.Accessibility.CustomEmojiPicker" = "Roghnaichear nan Emoji gnàthaichte"; @@ -187,6 +191,8 @@ a luchdadh suas gu Mastodon."; thoir gnogag air a’ chunntas a dhearbhadh a’ chunntais agad."; "Scene.ConfirmEmail.Title" = "Aon rud eile."; "Scene.Favorite.Title" = "Na h-annsachdan agad"; +"Scene.Follower.Footer" = "Cha dèid luchd-leantainn o fhrithealaichean eile a shealltainn."; +"Scene.Following.Footer" = "Cha dèid cò air a leanas tu air frithealaichean eile a shealltainn."; "Scene.HomeTimeline.NavigationBarState.NewPosts" = "Seall na postaichean ùra"; "Scene.HomeTimeline.NavigationBarState.Offline" = "Far loidhne"; "Scene.HomeTimeline.NavigationBarState.Published" = "Chaidh fhoillseachadh!"; @@ -336,4 +342,7 @@ thoir gnogag air a’ chunntas a dhearbhadh a’ chunntais agad."; "Scene.Thread.BackTitle" = "Post"; "Scene.Thread.Title" = "Post le %@"; "Scene.Welcome.Slogan" = "A’ cur nan lìonraidhean sòisealta -’nad làmhan fhèin."; \ No newline at end of file +’nad làmhan fhèin."; +"Scene.Wizard.AccessibilityHint" = "Thoir gnogag dhùbailte a’ leigeil seachad an draoidh seo"; +"Scene.Wizard.MultipleAccountSwitchIntroDescription" = "Geàrr leum eadar iomadh cunntas le cumail sìos putan na pròifil."; +"Scene.Wizard.NewInMastodon" = "Na tha ùr ann am Mastodon"; \ No newline at end of file diff --git a/Mastodon/Resources/gd-GB.lproj/Localizable.stringsdict b/Mastodon/Resources/gd-GB.lproj/Localizable.stringsdict index 63a998c6e..7a54f553e 100644 --- a/Mastodon/Resources/gd-GB.lproj/Localizable.stringsdict +++ b/Mastodon/Resources/gd-GB.lproj/Localizable.stringsdict @@ -2,6 +2,26 @@ + a11y.plural.count.unread.notification + + NSStringLocalizedFormatKey + %#@notification_count_unread_notification@ + notification_count_unread_notification + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + %ld bhrath nach deach a leughadh + two + %ld bhrath nach deach a leughadh + few + %ld brathan nach deach a leughadh + other + %ld brath nach deach a leughadh + + a11y.plural.count.input_limit_exceeds NSStringLocalizedFormatKey diff --git a/Mastodon/Resources/ja.lproj/Localizable.strings b/Mastodon/Resources/ja.lproj/Localizable.strings index 681461212..98bf71639 100644 --- a/Mastodon/Resources/ja.lproj/Localizable.strings +++ b/Mastodon/Resources/ja.lproj/Localizable.strings @@ -28,6 +28,7 @@ "Common.Controls.Actions.Back" = "戻る"; "Common.Controls.Actions.BlockDomain" = "%@をブロック"; "Common.Controls.Actions.Cancel" = "キャンセル"; +"Common.Controls.Actions.Compose" = "Compose"; "Common.Controls.Actions.Confirm" = "確認"; "Common.Controls.Actions.Continue" = "続ける"; "Common.Controls.Actions.CopyPhoto" = "写真をコピー"; @@ -129,6 +130,9 @@ "Common.Controls.Timeline.Loader.LoadingMissingPosts" = "読込中..."; "Common.Controls.Timeline.Loader.ShowMoreReplies" = "リプライをもっとみる"; "Common.Controls.Timeline.Timestamp.Now" = "今"; +"Scene.AccountList.AddAccount" = "アカウントを追加"; +"Scene.AccountList.DismissAccountSwitcher" = "Dismiss Account Switcher"; +"Scene.AccountList.TabBarHint" = "Current selected profile: %@. Double tap then hold to show account switcher"; "Scene.Compose.Accessibility.AppendAttachment" = "アタッチメントの追加"; "Scene.Compose.Accessibility.AppendPoll" = "投票を追加"; "Scene.Compose.Accessibility.CustomEmojiPicker" = "カスタム絵文字ピッカー"; @@ -181,6 +185,8 @@ "Scene.ConfirmEmail.Subtitle" = "先程 %@ にメールを送信しました。リンクをタップしてアカウントを確認してください。"; "Scene.ConfirmEmail.Title" = "さいごにもうひとつ。"; "Scene.Favorite.Title" = "お気に入り"; +"Scene.Follower.Footer" = "Followers from other servers are not displayed."; +"Scene.Following.Footer" = "Follows from other servers are not displayed."; "Scene.HomeTimeline.NavigationBarState.NewPosts" = "新しい投稿を見る"; "Scene.HomeTimeline.NavigationBarState.Offline" = "オフライン"; "Scene.HomeTimeline.NavigationBarState.Published" = "投稿しました!"; @@ -329,5 +335,7 @@ "Scene.SuggestionAccount.Title" = "フォローする人を探す"; "Scene.Thread.BackTitle" = "投稿"; "Scene.Thread.Title" = "%@の投稿"; -"Scene.Welcome.Slogan" = "Social networking -back in your hands."; \ No newline at end of file +"Scene.Welcome.Slogan" = "ソーシャルネットワーキングを、あなたの手の中に."; +"Scene.Wizard.AccessibilityHint" = "Double tap to dismiss this wizard"; +"Scene.Wizard.MultipleAccountSwitchIntroDescription" = "プロフィールボタンを押して複数のアカウントを切り替えます。"; +"Scene.Wizard.NewInMastodon" = "Mastodon の新機能"; \ No newline at end of file diff --git a/Mastodon/Resources/ja.lproj/Localizable.stringsdict b/Mastodon/Resources/ja.lproj/Localizable.stringsdict index ac37d9a39..c51a9a29d 100644 --- a/Mastodon/Resources/ja.lproj/Localizable.stringsdict +++ b/Mastodon/Resources/ja.lproj/Localizable.stringsdict @@ -2,6 +2,20 @@ + a11y.plural.count.unread.notification + + NSStringLocalizedFormatKey + %#@notification_count_unread_notification@ + notification_count_unread_notification + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + other + %ld 件の未読通知 + + a11y.plural.count.input_limit_exceeds NSStringLocalizedFormatKey @@ -13,7 +27,7 @@ NSStringFormatValueTypeKey ld other - %ld characters + %ld 文字 a11y.plural.count.input_limit_remains @@ -27,7 +41,7 @@ NSStringFormatValueTypeKey ld other - %ld characters + %ld 文字 plural.count.metric_formatted.post @@ -97,7 +111,7 @@ NSStringFormatValueTypeKey ld other - %ld votes + %ld票 plural.count.voter @@ -181,7 +195,7 @@ NSStringFormatValueTypeKey ld other - %ld months left + %ldか月前 date.day.left @@ -265,7 +279,7 @@ NSStringFormatValueTypeKey ld other - %ldM ago + %ld分前 date.day.ago.abbr diff --git a/Mastodon/Resources/ku-TR.lproj/InfoPlist.strings b/Mastodon/Resources/ku-TR.lproj/InfoPlist.strings new file mode 100644 index 000000000..669ecfacf --- /dev/null +++ b/Mastodon/Resources/ku-TR.lproj/InfoPlist.strings @@ -0,0 +1,4 @@ +"NSCameraUsageDescription" = "Bo kişandina wêneyê ji bo rewşa şandiyan tê bikaranîn"; +"NSPhotoLibraryAddUsageDescription" = "Ji bo tomarkirina wêneyê di pirtûkxaneya wêneyan de tê bikaranîn"; +"NewPostShortcutItemTitle" = "Şandiya nû"; +"SearchShortcutItemTitle" = "Bigere"; \ No newline at end of file diff --git a/Mastodon/Resources/ku-TR.lproj/Localizable.strings b/Mastodon/Resources/ku-TR.lproj/Localizable.strings new file mode 100644 index 000000000..d0d0f294e --- /dev/null +++ b/Mastodon/Resources/ku-TR.lproj/Localizable.strings @@ -0,0 +1,349 @@ +"Common.Alerts.BlockDomain.BlockEntireDomain" = "Navperê asteng bike"; +"Common.Alerts.BlockDomain.Title" = "Tu ji xwe bawerî, bi rastî tu dixwazî hemû %@ asteng bikî? Di gelek rewşan de asteng kirin an jî bêdeng kirin têrê dike û tê tercîh kirin. Tu nikarî naveroka vê navperê di demnameyê an jî agahdariyên xwe de bibînî. Şopînerên te yê di vê navperê were jêbirin."; +"Common.Alerts.CleanCache.Message" = "Pêşbîra %@ biserketî hate pakkirin."; +"Common.Alerts.CleanCache.Title" = "Pêşbîrê pak bike"; +"Common.Alerts.Common.PleaseTryAgain" = "Ji kerema xwe dîsa biceribîne."; +"Common.Alerts.Common.PleaseTryAgainLater" = "Ji kerema xwe paşê dîsa biceribîne."; +"Common.Alerts.DeletePost.Delete" = "Jê bibe"; +"Common.Alerts.DeletePost.Title" = "Ma tu dixwazî vê şandiyê jê bibî?"; +"Common.Alerts.DiscardPostContent.Message" = "Bipejrîne ku naveroka şandiyê ya hatiye nivîsandin paşguh bikî."; +"Common.Alerts.DiscardPostContent.Title" = "Reşnivîsê paşguh bike"; +"Common.Alerts.EditProfileFailure.Message" = "Nikare profîlê serrast bike. Jkx dîsa biceribîne."; +"Common.Alerts.EditProfileFailure.Title" = "Di serrastkirina profîlê çewtî"; +"Common.Alerts.PublishPostFailure.AttachmentsMessage.MoreThanOneVideo" = "Nikare ji bêtirî yek vîdyoyekê tevlî şandiyê bike."; +"Common.Alerts.PublishPostFailure.AttachmentsMessage.VideoAttachWithPhoto" = "Nikare vîdyoyekê tevlî şandiyê ku berê wêne tê de heye bike."; +"Common.Alerts.PublishPostFailure.Message" = "Weşandina şandiyê têkçû. +Jkx girêdana înternetê xwe kontrol bike."; +"Common.Alerts.PublishPostFailure.Title" = "Weşandin têkçû"; +"Common.Alerts.SavePhotoFailure.Message" = "Ji kerema xwe mafê bide gihîştina wênegehê çalak bike da ku wêne werin tomarkirin."; +"Common.Alerts.SavePhotoFailure.Title" = "Tomarkirina wêneyê têkçû"; +"Common.Alerts.ServerError.Title" = "Çewtiya rajekar"; +"Common.Alerts.SignOut.Confirm" = "Derkeve"; +"Common.Alerts.SignOut.Message" = "Ma tu dixwazî ku derkevî?"; +"Common.Alerts.SignOut.Title" = "Derkeve"; +"Common.Alerts.SignUpFailure.Title" = "Tomarkirin têkçû"; +"Common.Alerts.VoteFailure.PollEnded" = "Rapirsîya qediya"; +"Common.Alerts.VoteFailure.Title" = "Dengdayîn têkçû"; +"Common.Controls.Actions.Add" = "Tevlî bike"; +"Common.Controls.Actions.Back" = "Vegere"; +"Common.Controls.Actions.BlockDomain" = "%@ asteng bike"; +"Common.Controls.Actions.Cancel" = "Dev jê berde"; +"Common.Controls.Actions.Compose" = "Binivîsîne"; +"Common.Controls.Actions.Confirm" = "Bipejirîne"; +"Common.Controls.Actions.Continue" = "Bidomîne"; +"Common.Controls.Actions.CopyPhoto" = "Wêneyê jê bigire"; +"Common.Controls.Actions.Delete" = "Jê bibe"; +"Common.Controls.Actions.Discard" = "Biavêje"; +"Common.Controls.Actions.Done" = "Qediya"; +"Common.Controls.Actions.Edit" = "Serrast bike"; +"Common.Controls.Actions.FindPeople" = "Mirovan bo şopandinê bibîne"; +"Common.Controls.Actions.ManuallySearch" = "Ji devlê bi destan lêgerînê bike"; +"Common.Controls.Actions.Next" = "Pêş"; +"Common.Controls.Actions.Ok" = "BAŞ E"; +"Common.Controls.Actions.Open" = "Veke"; +"Common.Controls.Actions.OpenInSafari" = "Di Safariyê de veke"; +"Common.Controls.Actions.Preview" = "Pêşdîtin"; +"Common.Controls.Actions.Previous" = "Paş"; +"Common.Controls.Actions.Remove" = "Rake"; +"Common.Controls.Actions.Reply" = "Bersivê bide"; +"Common.Controls.Actions.ReportUser" = "%@ ragihîne"; +"Common.Controls.Actions.Save" = "Tomar bike"; +"Common.Controls.Actions.SavePhoto" = "Wêneyê tomar bike"; +"Common.Controls.Actions.SeeMore" = "Bêtir bibîne"; +"Common.Controls.Actions.Settings" = "Sazkarî"; +"Common.Controls.Actions.Share" = "Parve bike"; +"Common.Controls.Actions.SharePost" = "Şandiyê parve bike"; +"Common.Controls.Actions.ShareUser" = "%@ parve bike"; +"Common.Controls.Actions.SignIn" = "Têkeve"; +"Common.Controls.Actions.SignUp" = "Tomar bibe"; +"Common.Controls.Actions.Skip" = "Derbas bike"; +"Common.Controls.Actions.TakePhoto" = "Wêne bikişîne"; +"Common.Controls.Actions.TryAgain" = "Dîsa biceribîne"; +"Common.Controls.Actions.UnblockDomain" = "%@ asteng neke"; +"Common.Controls.Friendship.Block" = "Asteng bike"; +"Common.Controls.Friendship.BlockDomain" = "%@ asteng bike"; +"Common.Controls.Friendship.BlockUser" = "%@ asteng bike"; +"Common.Controls.Friendship.Blocked" = "Astengkirî"; +"Common.Controls.Friendship.EditInfo" = "Zanyariyan serrast bike"; +"Common.Controls.Friendship.Follow" = "Bişopîne"; +"Common.Controls.Friendship.Following" = "Dişopîne"; +"Common.Controls.Friendship.Mute" = "Bêdeng bike"; +"Common.Controls.Friendship.MuteUser" = "%@ bêdeng bike"; +"Common.Controls.Friendship.Muted" = "Bêdengkirî"; +"Common.Controls.Friendship.Pending" = "Tê nirxandin"; +"Common.Controls.Friendship.Request" = "Daxwaz bike"; +"Common.Controls.Friendship.Unblock" = "Astengiyê rake"; +"Common.Controls.Friendship.UnblockUser" = "%@ asteng neke"; +"Common.Controls.Friendship.Unmute" = "Bêdeng neke"; +"Common.Controls.Friendship.UnmuteUser" = "%@ bêdeng neke"; +"Common.Controls.Keyboard.Common.ComposeNewPost" = "Şandiyeke nû binivsîne"; +"Common.Controls.Keyboard.Common.OpenSettings" = "Sazkariyan Veke"; +"Common.Controls.Keyboard.Common.ShowFavorites" = "Bijarteyan nîşan bide"; +"Common.Controls.Keyboard.Common.SwitchToTab" = "Biguherîne bo %@"; +"Common.Controls.Keyboard.SegmentedControl.NextSection" = "Beşa pêş"; +"Common.Controls.Keyboard.SegmentedControl.PreviousSection" = "Beşa paş"; +"Common.Controls.Keyboard.Timeline.NextStatus" = "Şandiya pêş"; +"Common.Controls.Keyboard.Timeline.OpenAuthorProfile" = "Profîla nivîskaran veke"; +"Common.Controls.Keyboard.Timeline.OpenRebloggerProfile" = "Profîla nivîskaran veke"; +"Common.Controls.Keyboard.Timeline.OpenStatus" = "Şandiyê veke"; +"Common.Controls.Keyboard.Timeline.PreviewImage" = "Pêşdîtina wêneyê"; +"Common.Controls.Keyboard.Timeline.PreviousStatus" = "Şandeya paş"; +"Common.Controls.Keyboard.Timeline.ReplyStatus" = "Bersivê bide şandiyê"; +"Common.Controls.Keyboard.Timeline.ToggleContentWarning" = "Hişyariya naverokê biguherîne"; +"Common.Controls.Keyboard.Timeline.ToggleFavorite" = "Li ser şandiyê bijarte biguherîne"; +"Common.Controls.Keyboard.Timeline.ToggleReblog" = "Ji vû nivîsandin di şandiyê de biguherîne"; +"Common.Controls.Status.Actions.Favorite" = "Bijarte"; +"Common.Controls.Status.Actions.Menu" = "Kulîn"; +"Common.Controls.Status.Actions.Reblog" = "Ji nû ve nivîsandin"; +"Common.Controls.Status.Actions.Reply" = "Bersivê bide"; +"Common.Controls.Status.Actions.Unfavorite" = "Nebijarte"; +"Common.Controls.Status.Actions.Unreblog" = "Ji nû ve nivîsandinê vegere"; +"Common.Controls.Status.ContentWarning" = "Hişyariya naverokê"; +"Common.Controls.Status.MediaContentWarning" = "Ji bo eşkerekirinê li derekî bitikîne"; +"Common.Controls.Status.Poll.Closed" = "Girtî"; +"Common.Controls.Status.Poll.Vote" = "Deng bide"; +"Common.Controls.Status.ShowPost" = "Şandiyê nîşan bide"; +"Common.Controls.Status.ShowUserProfile" = "Profîla bikarhêner nîşan bide"; +"Common.Controls.Status.Tag.Email" = "E-name"; +"Common.Controls.Status.Tag.Emoji" = "Emojî"; +"Common.Controls.Status.Tag.Hashtag" = "Hashtag"; +"Common.Controls.Status.Tag.Link" = "Girêdan"; +"Common.Controls.Status.Tag.Mention" = "Qalkirin"; +"Common.Controls.Status.Tag.Url" = "URL"; +"Common.Controls.Status.UserReblogged" = "%@ ji nû ve hate nivîsandin"; +"Common.Controls.Status.UserRepliedTo" = "Bersiv da %@"; +"Common.Controls.Tabs.Home" = "Serrûpel"; +"Common.Controls.Tabs.Notification" = "Agahdarî"; +"Common.Controls.Tabs.Profile" = "Profîl"; +"Common.Controls.Tabs.Search" = "Bigere"; +"Common.Controls.Timeline.Filtered" = "Parzûnkirî"; +"Common.Controls.Timeline.Header.BlockedWarning" = "Tu nikarî profîla vî/ê bikarhênerî bibînî +heya ku ew astengiyê li ser te rakin."; +"Common.Controls.Timeline.Header.BlockingWarning" = "Tu nikarî profîla vî/ê bikarhênerî bibînî +Heya ku tu astengiyê li ser wî/ê ranekî. +Profîla te ji wan ra wiha xuya dike."; +"Common.Controls.Timeline.Header.NoStatusFound" = "Tu şandî nehate dîtin"; +"Common.Controls.Timeline.Header.SuspendedWarning" = "Ev bikarhêner hatiye rawestandin."; +"Common.Controls.Timeline.Header.UserBlockedWarning" = "Tu nikarî profîla %@ bibînî +Heta ku astengîya te rakin."; +"Common.Controls.Timeline.Header.UserBlockingWarning" = "Tu nikarî profîla %@ bibînî +Heya ku tu astengiyê li ser wî/ê ranekî. +Profîla te ji wan ra wiha xuya dike."; +"Common.Controls.Timeline.Header.UserSuspendedWarning" = "Ajimêra %@ hatiye rawestandin."; +"Common.Controls.Timeline.Loader.LoadMissingPosts" = "Şandiyên wendayî bar bike"; +"Common.Controls.Timeline.Loader.LoadingMissingPosts" = "Şandiyên wendayî tên barkirin..."; +"Common.Controls.Timeline.Loader.ShowMoreReplies" = "Bêtir bersivan nîşan bide"; +"Common.Controls.Timeline.Timestamp.Now" = "Niha"; +"Scene.AccountList.AddAccount" = "Ajimêr tevlî bike"; +"Scene.AccountList.DismissAccountSwitcher" = "Guherkera ajimêrê paş guh bike"; +"Scene.AccountList.TabBarHint" = "Profîla hilbijartî ya niha: %@. Du caran bitikîne û paşê dest bide ser da ku guhêrbara ajimêr were nîşandan"; +"Scene.Compose.Accessibility.AppendAttachment" = "Pêvek tevlî bike"; +"Scene.Compose.Accessibility.AppendPoll" = "Rapirsî tevlî bike"; +"Scene.Compose.Accessibility.CustomEmojiPicker" = "Hilbijêrê emojî yên kesanekirî"; +"Scene.Compose.Accessibility.DisableContentWarning" = "Hişyariya naverokê neçalak bike"; +"Scene.Compose.Accessibility.EnableContentWarning" = "Hişyariya naverokê çalak bike"; +"Scene.Compose.Accessibility.PostVisibilityMenu" = "Kulîna xuyabûna şandiyê"; +"Scene.Compose.Accessibility.RemovePoll" = "Rapirsî rake"; +"Scene.Compose.Attachment.AttachmentBroken" = "Ev %@ naxebite û nayê barkirin + li ser Mastodon."; +"Scene.Compose.Attachment.DescriptionPhoto" = "Wêneyê ji bo kêmbînên dîtbar bide nasîn..."; +"Scene.Compose.Attachment.DescriptionVideo" = "Vîdyoyê ji bo kêmbînên dîtbar bide nasîn..."; +"Scene.Compose.Attachment.Photo" = "wêne"; +"Scene.Compose.Attachment.Video" = "vîdyo"; +"Scene.Compose.AutoComplete.SpaceToAdd" = "Bicîhkirinê tevlî bike"; +"Scene.Compose.ComposeAction" = "Biweşîne"; +"Scene.Compose.ContentInputPlaceholder" = "Tiştê ku di hişê te de ye binivîsin an jî pêve bike"; +"Scene.Compose.ContentWarning.Placeholder" = "Li vir hişyariyek hûrgilî binivîsine..."; +"Scene.Compose.Keyboard.AppendAttachmentEntry" = "Pêvek tevlî bike - %@"; +"Scene.Compose.Keyboard.DiscardPost" = "Şandî paşguh bike"; +"Scene.Compose.Keyboard.PublishPost" = "Şandiyê biweşîne"; +"Scene.Compose.Keyboard.SelectVisibilityEntry" = "Xuyabûnê hilbijêre - %@"; +"Scene.Compose.Keyboard.ToggleContentWarning" = "Hişyariya naverokê biguherîne"; +"Scene.Compose.Keyboard.TogglePoll" = "Rapirsiyê biguherîne"; +"Scene.Compose.MediaSelection.Browse" = "Bigere"; +"Scene.Compose.MediaSelection.Camera" = "Wêne bikişîne"; +"Scene.Compose.MediaSelection.PhotoLibrary" = "Wênegeh"; +"Scene.Compose.Poll.DurationTime" = "Dirêjî: %@"; +"Scene.Compose.Poll.OneDay" = "1 Roj"; +"Scene.Compose.Poll.OneHour" = "1 Demjimêr"; +"Scene.Compose.Poll.OptionNumber" = "Vebijêrk %ld"; +"Scene.Compose.Poll.SevenDays" = "7 Roj"; +"Scene.Compose.Poll.SixHours" = "6 Demjimêr"; +"Scene.Compose.Poll.ThirtyMinutes" = "30 xulek"; +"Scene.Compose.Poll.ThreeDays" = "3 Roj"; +"Scene.Compose.ReplyingToUser" = "bersiv bide %@"; +"Scene.Compose.Title.NewPost" = "Şandiya nû"; +"Scene.Compose.Title.NewReply" = "Bersiva nû"; +"Scene.Compose.Visibility.Direct" = "Tenê mirovên ku min qalkirî"; +"Scene.Compose.Visibility.Private" = "Tenê şopîneran"; +"Scene.Compose.Visibility.Public" = "Gelemperî"; +"Scene.Compose.Visibility.Unlisted" = "Nerêzokkirî"; +"Scene.ConfirmEmail.Button.DontReceiveEmail" = "Min hîç e-nameyeke nesitand"; +"Scene.ConfirmEmail.Button.OpenEmailApp" = "Sepana e-nameyê veke"; +"Scene.ConfirmEmail.DontReceiveEmail.Description" = "Kontrol bike ka navnîşana e-nameya te rast e û her wiha peldanka xwe ya spam."; +"Scene.ConfirmEmail.DontReceiveEmail.ResendEmail" = "E-namyê yê dîsa bişîne"; +"Scene.ConfirmEmail.DontReceiveEmail.Title" = "E-nameyê xwe kontrol bike"; +"Scene.ConfirmEmail.OpenEmailApp.Description" = "Me tenê ji te re e-nameyek şand. Heke nehatiye peldanka xwe ya spamê kontrol bike."; +"Scene.ConfirmEmail.OpenEmailApp.Mail" = "E-name"; +"Scene.ConfirmEmail.OpenEmailApp.OpenEmailClient" = "Rajegirê e-nameyê veke"; +"Scene.ConfirmEmail.OpenEmailApp.Title" = "Nameyên xwe yên wergirtî kontrol bike."; +"Scene.ConfirmEmail.Subtitle" = "Me tenê e-nameyek ji %@ re şand, +girêdanê bitikne da ku ajimêra xwe bidî piştrastkirin."; +"Scene.ConfirmEmail.Title" = "Tiştekî dawî."; +"Scene.Favorite.Title" = "Bijarteyên te"; +"Scene.Follower.Footer" = "Şopîner ji rajekerên din nayê dîtin."; +"Scene.Following.Footer" = "Şopandin ji rajekerên din nayê dîtin."; +"Scene.HomeTimeline.NavigationBarState.NewPosts" = "Şandiyên nû bibîne"; +"Scene.HomeTimeline.NavigationBarState.Offline" = "Derhêl"; +"Scene.HomeTimeline.NavigationBarState.Published" = "Hate weşandin!"; +"Scene.HomeTimeline.NavigationBarState.Publishing" = "Şandî tê weşandin..."; +"Scene.HomeTimeline.Title" = "Serrûpel"; +"Scene.Notification.Keyobard.ShowEverything" = "Her tiştî nîşan bide"; +"Scene.Notification.Keyobard.ShowMentions" = "Qalkirinan nîşan bike"; +"Scene.Notification.Title.Everything" = "Her tişt"; +"Scene.Notification.Title.Mentions" = "Qalkirin"; +"Scene.Notification.UserFavorited Your Post" = "%@ şandiya te hez kir"; +"Scene.Notification.UserFollowedYou" = "%@ te şopand"; +"Scene.Notification.UserMentionedYou" = "%@ qale te kir"; +"Scene.Notification.UserRebloggedYourPost" = "%@ posta we ji nû ve tomar kir"; +"Scene.Notification.UserRequestedToFollowYou" = "%@ dixwazê te bişopîne"; +"Scene.Notification.UserYourPollHasEnded" = "Rapirsîya te qediya"; +"Scene.Preview.Keyboard.ClosePreview" = "Pêşdîtin bigire"; +"Scene.Preview.Keyboard.ShowNext" = "A pêş nîşan bide"; +"Scene.Preview.Keyboard.ShowPrevious" = "A paş nîşan bide"; +"Scene.Profile.Dashboard.Followers" = "şopîner"; +"Scene.Profile.Dashboard.Following" = "dişopîne"; +"Scene.Profile.Dashboard.Posts" = "şandî"; +"Scene.Profile.Fields.AddRow" = "Rêzê tevlî bike"; +"Scene.Profile.Fields.Placeholder.Content" = "Naverok"; +"Scene.Profile.Fields.Placeholder.Label" = "Nîşan"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Message" = "Ji bo rakirina astengkirinê bipejirîne %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Title" = "Astengiyê li ser ajimêr rake"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Message" = "Ji bo vekirina bêdengkirinê bipejirîne %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Title" = "Ajimêrê bêdeng neke"; +"Scene.Profile.SegmentedControl.Media" = "Medya"; +"Scene.Profile.SegmentedControl.Posts" = "Şandî"; +"Scene.Profile.SegmentedControl.Replies" = "Bersiv"; +"Scene.Register.Error.Item.Agreement" = "Peyman"; +"Scene.Register.Error.Item.Email" = "E-name"; +"Scene.Register.Error.Item.Locale" = "Zimanê navrûyê"; +"Scene.Register.Error.Item.Password" = "Pêborîn"; +"Scene.Register.Error.Item.Reason" = "Sedem"; +"Scene.Register.Error.Item.Username" = "Navê bikarhêner"; +"Scene.Register.Error.Reason.Accepted" = "%@ divê were pejirandin"; +"Scene.Register.Error.Reason.Blank" = "%@ pêwist e"; +"Scene.Register.Error.Reason.Blocked" = "%@ peydekerê e-peyamê yê qedexekirî dihewîne"; +"Scene.Register.Error.Reason.Inclusion" = "%@ ne nirxek piştgirî ye"; +"Scene.Register.Error.Reason.Invalid" = "%@ ne derbasdar e"; +"Scene.Register.Error.Reason.Reserved" = "%@ peyveke parastî ye"; +"Scene.Register.Error.Reason.Taken" = "%@ jixwe tê bikaranîn"; +"Scene.Register.Error.Reason.TooLong" = "%@ pir dirêj e"; +"Scene.Register.Error.Reason.TooShort" = "%@ pir kurt e"; +"Scene.Register.Error.Reason.Unreachable" = "%@ xuya ye ku tune ye"; +"Scene.Register.Error.Special.EmailInvalid" = "Ev navnîşaneke e-nameyê ne derbasdar e"; +"Scene.Register.Error.Special.PasswordTooShort" = "Pêborîn pir kurt e (divê herî kêm 8 tîp be)"; +"Scene.Register.Error.Special.UsernameInvalid" = "Navê bikarhêner divê tenê ji tîpên alfajimarî û binxêz pêk be"; +"Scene.Register.Error.Special.UsernameTooLong" = "Navê bikarhêner pir dirêj e (ji 30 tîpan dirêjtir nabe)"; +"Scene.Register.Input.Avatar.Delete" = "Jê bibe"; +"Scene.Register.Input.DisplayName.Placeholder" = "navê nîşanê"; +"Scene.Register.Input.Email.Placeholder" = "e-name"; +"Scene.Register.Input.Invite.RegistrationUserInviteRequest" = "Tu çima dixwazî beşdar bibî?"; +"Scene.Register.Input.Password.Hint" = "Pêborîna te herî kêm divê ji 8 tîpan pêk bê"; +"Scene.Register.Input.Password.Placeholder" = "pêborîn"; +"Scene.Register.Input.Username.DuplicatePrompt" = "Navê vê bikarhêner tê girtin."; +"Scene.Register.Input.Username.Placeholder" = "navê bikarhêner"; +"Scene.Register.Title" = "Ji me re hinekî qala xwe bike."; +"Scene.Report.Content1" = "Şandiyên din hene ku tu dixwazî tevlî ragihandinê bikî?"; +"Scene.Report.Content2" = "Derbarê vê ragihandinê de tiştek heye ku divê çavdêr bizanin?"; +"Scene.Report.Send" = "Ragihandinê bişîne"; +"Scene.Report.SkipToSend" = "Bêyî şirove bişîne"; +"Scene.Report.Step1" = "Gav 1 ji 2"; +"Scene.Report.Step2" = "Gav 2 ji 2"; +"Scene.Report.TextPlaceholder" = "Şiroveyên daxwazkirê binivîsine an jî pê ve bike"; +"Scene.Report.Title" = "%@ ragihîne"; +"Scene.Search.Recommend.Accounts.Description" = "Dibe ku tu bixwazî van ajimêran bişopînî"; +"Scene.Search.Recommend.Accounts.Follow" = "Bişopîne"; +"Scene.Search.Recommend.Accounts.Title" = "Ajimêrên ku belkî tu jê hez bikî"; +"Scene.Search.Recommend.ButtonText" = "Hemûyan bibîne"; +"Scene.Search.Recommend.HashTag.Description" = "Hashtag ên ku pir balê dikişînin"; +"Scene.Search.Recommend.HashTag.PeopleTalking" = "%@ kes diaxivin"; +"Scene.Search.Recommend.HashTag.Title" = "Rojev li ser Mastodon"; +"Scene.Search.SearchBar.Cancel" = "Dev jê berde"; +"Scene.Search.SearchBar.Placeholder" = "Li hashtag û bikarhêneran bigere"; +"Scene.Search.Searching.Clear" = "Pak bike"; +"Scene.Search.Searching.EmptyState.NoResults" = "Encam tune"; +"Scene.Search.Searching.RecentSearch" = "Lêgerînên dawî"; +"Scene.Search.Searching.Segment.All" = "Hemû"; +"Scene.Search.Searching.Segment.Hashtags" = "Hashtag"; +"Scene.Search.Searching.Segment.People" = "Mirov"; +"Scene.Search.Searching.Segment.Posts" = "Şandî"; +"Scene.Search.Title" = "Bigere"; +"Scene.ServerPicker.Button.Category.Academia" = "akademî"; +"Scene.ServerPicker.Button.Category.Activism" = "çalakî"; +"Scene.ServerPicker.Button.Category.All" = "Hemû"; +"Scene.ServerPicker.Button.Category.AllAccessiblityDescription" = "Beş: Hemû"; +"Scene.ServerPicker.Button.Category.Art" = "huner"; +"Scene.ServerPicker.Button.Category.Food" = "xwarin"; +"Scene.ServerPicker.Button.Category.Furry" = "furry"; +"Scene.ServerPicker.Button.Category.Games" = "lîsk"; +"Scene.ServerPicker.Button.Category.General" = "giştî"; +"Scene.ServerPicker.Button.Category.Journalism" = "rojnamevanî"; +"Scene.ServerPicker.Button.Category.Lgbt" = "lgbt"; +"Scene.ServerPicker.Button.Category.Music" = "muzîk"; +"Scene.ServerPicker.Button.Category.Regional" = "herêmî"; +"Scene.ServerPicker.Button.Category.Tech" = "teknolojî"; +"Scene.ServerPicker.Button.SeeLess" = "Kêmtir bibîne"; +"Scene.ServerPicker.Button.SeeMore" = "Bêtir bibîne"; +"Scene.ServerPicker.EmptyState.BadNetwork" = "Di dema barkirina daneyan da çewtî derket. Girêdana xwe ya înternetê kontrol bike."; +"Scene.ServerPicker.EmptyState.FindingServers" = "Peydakirina rajekarên berdest..."; +"Scene.ServerPicker.EmptyState.NoResults" = "Encam tune"; +"Scene.ServerPicker.Input.Placeholder" = "Rajekarekî bibîne an jî beşdarî ya xwe bibe..."; +"Scene.ServerPicker.Label.Category" = "BEŞ"; +"Scene.ServerPicker.Label.Language" = "ZIMAN"; +"Scene.ServerPicker.Label.Users" = "BIKARHÊNER"; +"Scene.ServerPicker.Title" = "Rajekarekê hilbijêre, +Her kîjan rajekar be."; +"Scene.ServerRules.Button.Confirm" = "Ez dipejirînim"; +"Scene.ServerRules.PrivacyPolicy" = "polîtikaya nihêniyê"; +"Scene.ServerRules.Prompt" = "Bi domandinê, tu ji bo %@ di bin mercên bikaranînê û polîtîkaya nepenîtiyê dipejirînî."; +"Scene.ServerRules.Subtitle" = "Ev rêzik ji aliyê rêvebirên %@ ve tên sazkirin."; +"Scene.ServerRules.TermsOfService" = "mercên bikaranînê"; +"Scene.ServerRules.Title" = "Hinek rêzikên bingehîn."; +"Scene.Settings.Footer.MastodonDescription" = "Mastodon nermalava çavkaniya vekirî ye. Tu dikarî pirsgirêkan li ser GitHub-ê ragihînî di %@ (%@) de"; +"Scene.Settings.Keyboard.CloseSettingsWindow" = "Sazkariyên çarçoveyê bigire"; +"Scene.Settings.Section.Appearance.Automatic" = "Xweber"; +"Scene.Settings.Section.Appearance.Dark" = "Her dem tarî"; +"Scene.Settings.Section.Appearance.Light" = "Her dem ronî"; +"Scene.Settings.Section.Appearance.Title" = "Xuyang"; +"Scene.Settings.Section.BoringZone.AccountSettings" = "Sazkariyên ajimêr"; +"Scene.Settings.Section.BoringZone.Privacy" = "Polîtikaya nihêniyê"; +"Scene.Settings.Section.BoringZone.Terms" = "Mercên bikaranînê"; +"Scene.Settings.Section.BoringZone.Title" = "Devera acizker"; +"Scene.Settings.Section.Notifications.Boosts" = "Şandiya min ji nû ve nivîsand"; +"Scene.Settings.Section.Notifications.Favorites" = "Şandiyên min hez kir"; +"Scene.Settings.Section.Notifications.Follows" = "Min dişopîne"; +"Scene.Settings.Section.Notifications.Mentions" = "Qale min kir"; +"Scene.Settings.Section.Notifications.Title" = "Agahdarî"; +"Scene.Settings.Section.Notifications.Trigger.Anyone" = "her kes"; +"Scene.Settings.Section.Notifications.Trigger.Follow" = "her kesê ku dişopînim"; +"Scene.Settings.Section.Notifications.Trigger.Follower" = "şopînerek"; +"Scene.Settings.Section.Notifications.Trigger.Noone" = "ne yek"; +"Scene.Settings.Section.Notifications.Trigger.Title" = "Min agahdar bike gava"; +"Scene.Settings.Section.Preference.DisableAvatarAnimation" = "Avatarên anîmasyonî neçalak bike"; +"Scene.Settings.Section.Preference.DisableEmojiAnimation" = "Emojiyên anîmasyonî neçalak bike"; +"Scene.Settings.Section.Preference.Title" = "Hilbijarte"; +"Scene.Settings.Section.Preference.TrueBlackDarkMode" = "Moda tarî ya reş a rastîn"; +"Scene.Settings.Section.Preference.UsingDefaultBrowser" = "Ji bo vekirina girêdanan geroka berdest bi kar bîne"; +"Scene.Settings.Section.SpicyZone.Clear" = "Pêşbîra medyayê pak bike"; +"Scene.Settings.Section.SpicyZone.Signout" = "Derkeve"; +"Scene.Settings.Section.SpicyZone.Title" = "Devera germ"; +"Scene.Settings.Title" = "Sazkarî"; +"Scene.SuggestionAccount.FollowExplain" = "Gava tu kesekî dişopînî, tu yê şandiyê wan di serrûpelê de bibîne."; +"Scene.SuggestionAccount.Title" = "Kesên bo ku bişopînî bibîne"; +"Scene.Thread.BackTitle" = "Şandî"; +"Scene.Thread.Title" = "Şandî ji %@"; +"Scene.Welcome.Slogan" = "Torên civakî +di destên te de."; +"Scene.Wizard.AccessibilityHint" = "Du caran bitikîne da ku çarçoveyahilpekok ji holê rakî"; +"Scene.Wizard.MultipleAccountSwitchIntroDescription" = "Dest bide ser bişkoja profîlê da ku di navbera gelek ajimêrann de biguherînî."; +"Scene.Wizard.NewInMastodon" = "Nû di Mastodon de"; \ No newline at end of file diff --git a/Mastodon/Resources/ku-TR.lproj/Localizable.stringsdict b/Mastodon/Resources/ku-TR.lproj/Localizable.stringsdict new file mode 100644 index 000000000..8ae1b812a --- /dev/null +++ b/Mastodon/Resources/ku-TR.lproj/Localizable.stringsdict @@ -0,0 +1,390 @@ + + + + + a11y.plural.count.unread.notification + + NSStringLocalizedFormatKey + %#@notification_count_unread_notification@ + notification_count_unread_notification + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 agahdariya nexwendî + other + %ld agahdariyên nexwendî + + + a11y.plural.count.input_limit_exceeds + + NSStringLocalizedFormatKey + Sînorê têketinê derbas kir %#@character_count@ + character_count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 tîp + other + %ld tîp + + + a11y.plural.count.input_limit_remains + + NSStringLocalizedFormatKey + Sînorê têketinê %#@character_count@ maye + character_count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 tîp + other + %ld tîp + + + plural.count.metric_formatted.post + + NSStringLocalizedFormatKey + %@ %#@post_count@ + post_count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + şandî + other + şandî + + + plural.count.post + + NSStringLocalizedFormatKey + %#@post_count@ + post_count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 şandî + other + %ld şandî + + + plural.count.favorite + + NSStringLocalizedFormatKey + %#@favorite_count@ + favorite_count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 hezkirin + other + %ld hezkirin + + + plural.count.reblog + + NSStringLocalizedFormatKey + %#@reblog_count@ + reblog_count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 ji nû ve nivîsandin + other + %ld ji nû ve nivîsandin + + + plural.count.vote + + NSStringLocalizedFormatKey + %#@vote_count@ + vote_count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 deng + other + %ld deng + + + plural.count.voter + + NSStringLocalizedFormatKey + %#@voter_count@ + voter_count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 hilbijêr + other + %ld hilbijêr + + + plural.people_talking + + NSStringLocalizedFormatKey + %#@count_people_talking@ + count_people_talking + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 mirov diaxive + other + %ld mirov diaxive + + + plural.count.following + + NSStringLocalizedFormatKey + %#@count_following@ + count_following + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 dişopîne + other + %ld dişopîne + + + plural.count.follower + + NSStringLocalizedFormatKey + %#@count_follower@ + count_follower + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 şopîner + other + %ld şopîner + + + date.year.left + + NSStringLocalizedFormatKey + %#@count_year_left@ + count_year_left + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 sal berê + other + %ld sal berê + + + date.month.left + + NSStringLocalizedFormatKey + %#@count_month_left@ + count_month_left + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 meh berê + other + %ld meh berê + + + date.day.left + + NSStringLocalizedFormatKey + %#@count_day_left@ + count_day_left + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 roj berê + other + %ld roj berê + + + date.hour.left + + NSStringLocalizedFormatKey + %#@count_hour_left@ + count_hour_left + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 demjimêr berê + other + %ld demjimêr berê + + + date.minute.left + + NSStringLocalizedFormatKey + %#@count_minute_left@ + count_minute_left + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 xulek berê + other + %ld xulek berê + + + date.second.left + + NSStringLocalizedFormatKey + %#@count_second_left@ + count_second_left + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 çirke berê + other + %ld çirke berê + + + date.year.ago.abbr + + NSStringLocalizedFormatKey + %#@count_year_ago_abbr@ + count_year_ago_abbr + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 sal berê + other + %ld sal berê + + + date.month.ago.abbr + + NSStringLocalizedFormatKey + %#@count_month_ago_abbr@ + count_month_ago_abbr + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 xulek berê + other + %ld xulek berê + + + date.day.ago.abbr + + NSStringLocalizedFormatKey + %#@count_day_ago_abbr@ + count_day_ago_abbr + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 roj berê + other + %ld roj berê + + + date.hour.ago.abbr + + NSStringLocalizedFormatKey + %#@count_hour_ago_abbr@ + count_hour_ago_abbr + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 demjimêr berê + other + %ld demjimêr berê + + + date.minute.ago.abbr + + NSStringLocalizedFormatKey + %#@count_minute_ago_abbr@ + count_minute_ago_abbr + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 xulek berê + other + %ld xulek berê + + + date.second.ago.abbr + + NSStringLocalizedFormatKey + %#@count_second_ago_abbr@ + count_second_ago_abbr + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 çirke berê + other + %ld çirke berê + + + + diff --git a/Mastodon/Resources/local-codes.json b/Mastodon/Resources/local-codes.json new file mode 100644 index 000000000..949649791 --- /dev/null +++ b/Mastodon/Resources/local-codes.json @@ -0,0 +1,80 @@ +{ + "af": "Afrikaans", + "ar": "العربية", + "ast": "Asturianu", + "bg": "Български", + "bn": "বাংলা", + "br": "Breton", + "ca": "Català", + "co": "Corsu", + "cs": "Čeština", + "cy": "Cymraeg", + "da": "Dansk", + "de": "Deutsch", + "el": "Ελληνικά", + "en": "English", + "eo": "Esperanto", + "es-AR": "Español (Argentina)", + "es": "Español", + "et": "Eesti", + "eu": "Euskara", + "fa": "فارسی", + "fi": "Suomi", + "fr": "Français", + "ga": "Gaeilge", + "gd": "Gàidhlig", + "gl": "Galego", + "he": "עברית", + "hi": "हिन्दी", + "hr": "Hrvatski", + "hu": "Magyar", + "hy": "Հայերեն", + "id": "Bahasa Indonesia", + "io": "Ido", + "is": "Íslenska", + "it": "Italiano", + "ja": "日本語", + "ka": "ქართული", + "kab": "Taqbaylit", + "kk": "Қазақша", + "kn": "ಕನ್ನಡ", + "ko": "한국어", + "ku": "سۆرانی", + "lt": "Lietuvių", + "lv": "Latviešu", + "mk": "Македонски", + "ml": "മലയാളം", + "mr": "मराठी", + "ms": "Bahasa Melayu", + "nl": "Nederlands", + "nn": "Nynorsk", + "no": "Norsk", + "oc": "Occitan", + "pl": "Polski", + "pt-BR": "Português (Brasil)", + "pt-PT": "Português (Portugal)", + "pt": "Português", + "ro": "Română", + "ru": "Русский", + "sa": "संस्कृतम्", + "sc": "Sardu", + "si": "සිංහල", + "sk": "Slovenčina", + "sl": "Slovenščina", + "sq": "Shqip", + "sr-Latn": "Srpski (latinica)", + "sr": "Српски", + "sv": "Svenska", + "ta": "தமிழ்", + "te": "తెలుగు", + "th": "ไทย", + "tr": "Türkçe", + "uk": "Українська", + "ur": "اُردُو", + "vi": "Tiếng Việt", + "zgh": "ⵜⴰⵎⴰⵣⵉⵖⵜ", + "zh-CN": "简体中文", + "zh-HK": "繁體中文(香港)", + "zh-TW": "繁體中文(臺灣)", + "zh": "中文" +} \ No newline at end of file diff --git a/Mastodon/Resources/nl.lproj/Localizable.strings b/Mastodon/Resources/nl.lproj/Localizable.strings index 6f4c33134..9c84e138f 100644 --- a/Mastodon/Resources/nl.lproj/Localizable.strings +++ b/Mastodon/Resources/nl.lproj/Localizable.strings @@ -27,6 +27,7 @@ "Common.Controls.Actions.Back" = "Terug"; "Common.Controls.Actions.BlockDomain" = "Blokkeer %@"; "Common.Controls.Actions.Cancel" = "Annuleren"; +"Common.Controls.Actions.Compose" = "Compose"; "Common.Controls.Actions.Confirm" = "Bevestigen"; "Common.Controls.Actions.Continue" = "Doorgaan"; "Common.Controls.Actions.CopyPhoto" = "Foto kopiëren"; @@ -128,6 +129,9 @@ Uw profiel ziet er zo uit voor hen."; "Common.Controls.Timeline.Loader.LoadingMissingPosts" = "Resterende berichten laden..."; "Common.Controls.Timeline.Loader.ShowMoreReplies" = "Toon meer reacties"; "Common.Controls.Timeline.Timestamp.Now" = "Nu"; +"Scene.AccountList.AddAccount" = "Add Account"; +"Scene.AccountList.DismissAccountSwitcher" = "Dismiss Account Switcher"; +"Scene.AccountList.TabBarHint" = "Current selected profile: %@. Double tap then hold to show account switcher"; "Scene.Compose.Accessibility.AppendAttachment" = "Bijlage Toevoegen"; "Scene.Compose.Accessibility.AppendPoll" = "Peiling Toevoegen"; "Scene.Compose.Accessibility.CustomEmojiPicker" = "Eigen Emojikiezer"; @@ -181,6 +185,8 @@ Uw profiel ziet er zo uit voor hen."; klik op de link om uw account te bevestigen."; "Scene.ConfirmEmail.Title" = "Nog één ding."; "Scene.Favorite.Title" = "Uw favorieten"; +"Scene.Follower.Footer" = "Followers from other servers are not displayed."; +"Scene.Following.Footer" = "Follows from other servers are not displayed."; "Scene.HomeTimeline.NavigationBarState.NewPosts" = "Bekijk nieuwe berichten"; "Scene.HomeTimeline.NavigationBarState.Offline" = "Offline"; "Scene.HomeTimeline.NavigationBarState.Published" = "Gepubliceerd!"; @@ -329,4 +335,7 @@ klik op de link om uw account te bevestigen."; "Scene.SuggestionAccount.Title" = "Zoek Mensen om te Volgen"; "Scene.Thread.BackTitle" = "Bericht"; "Scene.Thread.Title" = "Bericht van %@"; -"Scene.Welcome.Slogan" = "Sociale media terug in uw handen."; \ No newline at end of file +"Scene.Welcome.Slogan" = "Sociale media terug in uw handen."; +"Scene.Wizard.AccessibilityHint" = "Double tap to dismiss this wizard"; +"Scene.Wizard.MultipleAccountSwitchIntroDescription" = "Switch between multiple accounts by holding the profile button."; +"Scene.Wizard.NewInMastodon" = "New in Mastodon"; \ No newline at end of file diff --git a/Mastodon/Resources/nl.lproj/Localizable.stringsdict b/Mastodon/Resources/nl.lproj/Localizable.stringsdict index 1726606b4..8b6ab05ca 100644 --- a/Mastodon/Resources/nl.lproj/Localizable.stringsdict +++ b/Mastodon/Resources/nl.lproj/Localizable.stringsdict @@ -2,6 +2,22 @@ + a11y.plural.count.unread.notification + + NSStringLocalizedFormatKey + %#@notification_count_unread_notification@ + notification_count_unread_notification + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 unread notification + other + %ld unread notification + + a11y.plural.count.input_limit_exceeds NSStringLocalizedFormatKey diff --git a/Mastodon/Resources/ru.lproj/InfoPlist.strings b/Mastodon/Resources/ru.lproj/InfoPlist.strings index 710865573..ccb7ca685 100644 --- a/Mastodon/Resources/ru.lproj/InfoPlist.strings +++ b/Mastodon/Resources/ru.lproj/InfoPlist.strings @@ -1,4 +1,4 @@ "NSCameraUsageDescription" = "Used to take photo for post status"; "NSPhotoLibraryAddUsageDescription" = "Used to save photo into the Photo Library"; "NewPostShortcutItemTitle" = "New Post"; -"SearchShortcutItemTitle" = "Search"; \ No newline at end of file +"SearchShortcutItemTitle" = "Поиск"; \ No newline at end of file diff --git a/Mastodon/Resources/ru.lproj/Localizable.strings b/Mastodon/Resources/ru.lproj/Localizable.strings index 4c0e5eafa..1a4f92fc6 100644 --- a/Mastodon/Resources/ru.lproj/Localizable.strings +++ b/Mastodon/Resources/ru.lproj/Localizable.strings @@ -28,6 +28,7 @@ "Common.Controls.Actions.Back" = "Назад"; "Common.Controls.Actions.BlockDomain" = "Заблокировать %@"; "Common.Controls.Actions.Cancel" = "Отмена"; +"Common.Controls.Actions.Compose" = "Compose"; "Common.Controls.Actions.Confirm" = "Подтвердить"; "Common.Controls.Actions.Continue" = "Продолжить"; "Common.Controls.Actions.CopyPhoto" = "Скопировать изображение"; @@ -141,6 +142,9 @@ "Common.Controls.Timeline.Loader.LoadingMissingPosts" = "Загрузка недостающих постов..."; "Common.Controls.Timeline.Loader.ShowMoreReplies" = "Показать больше ответов"; "Common.Controls.Timeline.Timestamp.Now" = "Только что"; +"Scene.AccountList.AddAccount" = "Add Account"; +"Scene.AccountList.DismissAccountSwitcher" = "Dismiss Account Switcher"; +"Scene.AccountList.TabBarHint" = "Current selected profile: %@. Double tap then hold to show account switcher"; "Scene.Compose.Accessibility.AppendAttachment" = "Прикрепить файл"; "Scene.Compose.Accessibility.AppendPoll" = "Добавить опрос"; "Scene.Compose.Accessibility.CustomEmojiPicker" = "Меню пользовательских эмодзи"; @@ -197,6 +201,8 @@ подтвердить свою учётную запись."; "Scene.ConfirmEmail.Title" = "И ещё кое-что."; "Scene.Favorite.Title" = "Ваше избранное"; +"Scene.Follower.Footer" = "Followers from other servers are not displayed."; +"Scene.Following.Footer" = "Follows from other servers are not displayed."; "Scene.HomeTimeline.NavigationBarState.NewPosts" = "Показать новые"; "Scene.HomeTimeline.NavigationBarState.Offline" = "Не в сети"; "Scene.HomeTimeline.NavigationBarState.Published" = "Опубликовано!"; @@ -347,4 +353,7 @@ "Scene.Thread.BackTitle" = "Пост"; "Scene.Thread.Title" = "Пост %@"; "Scene.Welcome.Slogan" = "Социальная сеть -под вашим контролем."; \ No newline at end of file +под вашим контролем."; +"Scene.Wizard.AccessibilityHint" = "Double tap to dismiss this wizard"; +"Scene.Wizard.MultipleAccountSwitchIntroDescription" = "Switch between multiple accounts by holding the profile button."; +"Scene.Wizard.NewInMastodon" = "New in Mastodon"; \ No newline at end of file diff --git a/Mastodon/Resources/ru.lproj/Localizable.stringsdict b/Mastodon/Resources/ru.lproj/Localizable.stringsdict index 1a9a44a0f..96afce4ed 100644 --- a/Mastodon/Resources/ru.lproj/Localizable.stringsdict +++ b/Mastodon/Resources/ru.lproj/Localizable.stringsdict @@ -2,10 +2,30 @@ + a11y.plural.count.unread.notification + + NSStringLocalizedFormatKey + %#@notification_count_unread_notification@ + notification_count_unread_notification + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 unread notification + few + %ld unread notification + many + %ld unread notification + other + %ld unread notification + + a11y.plural.count.input_limit_exceeds NSStringLocalizedFormatKey - Input limit exceeds %#@character_count@ + Лимит превышен на %#@character_count@ character_count NSStringFormatSpecTypeKey @@ -13,19 +33,19 @@ NSStringFormatValueTypeKey ld one - 1 character + %ld символ few - %ld characters + %ld символа many - %ld characters + %ld символов other - %ld characters + %ld символа a11y.plural.count.input_limit_remains NSStringLocalizedFormatKey - Input limit remains %#@character_count@ + %#@character_count@ character_count NSStringFormatSpecTypeKey @@ -33,13 +53,13 @@ NSStringFormatValueTypeKey ld one - 1 character + %ld символ остался few - %ld characters + %ld символа осталось many - %ld characters + %ld символов осталось other - %ld characters + %ld символа осталось plural.count.metric_formatted.post @@ -53,13 +73,13 @@ NSStringFormatValueTypeKey ld one - post + пост few - posts + поста many - posts + постов other - posts + поста plural.count.post @@ -133,13 +153,13 @@ NSStringFormatValueTypeKey ld one - 1 vote + %ld голос few - %ld votes + %ld голоса many - %ld votes + %ld голосов other - %ld votes + %ld голоса plural.count.voter diff --git a/Mastodon/Resources/th.lproj/Localizable.strings b/Mastodon/Resources/th.lproj/Localizable.strings index e3afe433f..1bd954fe5 100644 --- a/Mastodon/Resources/th.lproj/Localizable.strings +++ b/Mastodon/Resources/th.lproj/Localizable.strings @@ -28,6 +28,7 @@ "Common.Controls.Actions.Back" = "ย้อนกลับ"; "Common.Controls.Actions.BlockDomain" = "ปิดกั้น %@"; "Common.Controls.Actions.Cancel" = "ยกเลิก"; +"Common.Controls.Actions.Compose" = "เขียน"; "Common.Controls.Actions.Confirm" = "ยืนยัน"; "Common.Controls.Actions.Continue" = "ดำเนินการต่อ"; "Common.Controls.Actions.CopyPhoto" = "คัดลอกรูปภาพ"; @@ -116,23 +117,26 @@ "Common.Controls.Tabs.Profile" = "โปรไฟล์"; "Common.Controls.Tabs.Search" = "ค้นหา"; "Common.Controls.Timeline.Filtered" = "กรองอยู่"; -"Common.Controls.Timeline.Header.BlockedWarning" = "You can’t view this user’s profile -until they unblock you."; -"Common.Controls.Timeline.Header.BlockingWarning" = "You can’t view this user's profile -until you unblock them. -Your profile looks like this to them."; +"Common.Controls.Timeline.Header.BlockedWarning" = "คุณไม่สามารถดูโปรไฟล์ของผู้ใช้นี้ +จนกว่าผู้ใช้นี้จะเลิกปิดกั้นคุณ"; +"Common.Controls.Timeline.Header.BlockingWarning" = "คุณไม่สามารถดูโปรไฟล์ของผู้ใช้นี้ +จนกว่าคุณจะเลิกปิดกั้นผู้ใช้นี้ +ผู้ใช้นี้เห็นโปรไฟล์ของคุณเหมือนกับที่คุณเห็น"; "Common.Controls.Timeline.Header.NoStatusFound" = "ไม่พบโพสต์"; "Common.Controls.Timeline.Header.SuspendedWarning" = "ผู้ใช้นี้ถูกระงับการใช้งาน"; -"Common.Controls.Timeline.Header.UserBlockedWarning" = "You can’t view %@’s profile -until they unblock you."; -"Common.Controls.Timeline.Header.UserBlockingWarning" = "You can’t view %@’s profile -until you unblock them. -Your profile looks like this to them."; +"Common.Controls.Timeline.Header.UserBlockedWarning" = "คุณไม่สามารถดูโปรไฟล์ของ %@ +จนกว่าผู้ใช้นี้จะเลิกปิดกั้นคุณ"; +"Common.Controls.Timeline.Header.UserBlockingWarning" = "คุณไม่สามารถดูโปรไฟล์ของ %@ +จนกว่าคุณจะเลิกปิดกั้นผู้ใช้นี้ +ผู้ใช้นี้เห็นโปรไฟล์ของคุณเหมือนกับที่คุณเห็น"; "Common.Controls.Timeline.Header.UserSuspendedWarning" = "บัญชีของ %@ ถูกระงับการใช้งาน"; "Common.Controls.Timeline.Loader.LoadMissingPosts" = "โหลดโพสต์ที่ขาดหายไป"; "Common.Controls.Timeline.Loader.LoadingMissingPosts" = "กำลังโหลดโพสต์ที่ขาดหายไป..."; "Common.Controls.Timeline.Loader.ShowMoreReplies" = "แสดงการตอบกลับเพิ่มเติม"; "Common.Controls.Timeline.Timestamp.Now" = "ตอนนี้"; +"Scene.AccountList.AddAccount" = "เพิ่มบัญชี"; +"Scene.AccountList.DismissAccountSwitcher" = "ปิดตัวสลับบัญชี"; +"Scene.AccountList.TabBarHint" = "โปรไฟล์ที่เลือกในปัจจุบัน: %@ แตะสองครั้งแล้วกดค้างไว้เพื่อแสดงตัวสลับบัญชี"; "Scene.Compose.Accessibility.AppendAttachment" = "เพิ่มไฟล์แนบ"; "Scene.Compose.Accessibility.AppendPoll" = "เพิ่มการสำรวจความคิดเห็น"; "Scene.Compose.Accessibility.CustomEmojiPicker" = "ตัวเลือกอีโมจิที่กำหนดเอง"; @@ -187,6 +191,8 @@ Your profile looks like this to them."; แตะที่ลิงก์เพื่อยืนยันบัญชีของคุณ"; "Scene.ConfirmEmail.Title" = "หนึ่งสิ่งสุดท้าย"; "Scene.Favorite.Title" = "รายการโปรดของคุณ"; +"Scene.Follower.Footer" = "ไม่ได้แสดงผู้ติดตามจากเซิร์ฟเวอร์อื่น ๆ"; +"Scene.Following.Footer" = "ไม่ได้แสดงการติดตามจากเซิร์ฟเวอร์อื่น ๆ"; "Scene.HomeTimeline.NavigationBarState.NewPosts" = "ดูโพสต์ใหม่"; "Scene.HomeTimeline.NavigationBarState.Offline" = "ออฟไลน์"; "Scene.HomeTimeline.NavigationBarState.Published" = "เผยแพร่แล้ว!"; @@ -285,7 +291,7 @@ Your profile looks like this to them."; "Scene.ServerPicker.Button.Category.Lgbt" = "lgbt"; "Scene.ServerPicker.Button.Category.Music" = "ดนตรี"; "Scene.ServerPicker.Button.Category.Regional" = "ภูมิภาค"; -"Scene.ServerPicker.Button.Category.Tech" = "tech"; +"Scene.ServerPicker.Button.Category.Tech" = "เทคโนโลยี"; "Scene.ServerPicker.Button.SeeLess" = "ดูน้อยลง"; "Scene.ServerPicker.Button.SeeMore" = "ดูเพิ่มเติม"; "Scene.ServerPicker.EmptyState.BadNetwork" = "มีบางอย่างผิดพลาดขณะโหลดข้อมูล ตรวจสอบการเชื่อมต่ออินเทอร์เน็ตของคุณ"; @@ -299,7 +305,7 @@ Your profile looks like this to them."; อันไหนก็ได้"; "Scene.ServerRules.Button.Confirm" = "ฉันเห็นด้วย"; "Scene.ServerRules.PrivacyPolicy" = "นโยบายความเป็นส่วนตัว"; -"Scene.ServerRules.Prompt" = "By continuing, you’re subject to the terms of service and privacy policy for %@."; +"Scene.ServerRules.Prompt" = "เมื่อคุณดำเนินการต่อ คุณอยู่ภายใต้เงื่อนไขการให้บริการและนโยบายความเป็นส่วนตัวสำหรับ %@"; "Scene.ServerRules.Subtitle" = "กฎเหล่านี้ถูกตั้งโดยผู้ดูแลของ %@"; "Scene.ServerRules.TermsOfService" = "เงื่อนไขการให้บริการ"; "Scene.ServerRules.Title" = "กฎพื้นฐานบางประการ"; @@ -337,4 +343,7 @@ Your profile looks like this to them."; "Scene.Thread.BackTitle" = "โพสต์"; "Scene.Thread.Title" = "โพสต์จาก %@"; "Scene.Welcome.Slogan" = "ให้เครือข่ายสังคม -กลับมาอยู่ในมือของคุณ"; \ No newline at end of file +กลับมาอยู่ในมือของคุณ"; +"Scene.Wizard.AccessibilityHint" = "แตะสองครั้งเพื่อปิดตัวช่วยสร้างนี้"; +"Scene.Wizard.MultipleAccountSwitchIntroDescription" = "สลับระหว่างหลายบัญชีโดยกดปุ่มโปรไฟล์ค้างไว้"; +"Scene.Wizard.NewInMastodon" = "มาใหม่ใน Mastodon"; \ No newline at end of file diff --git a/Mastodon/Resources/th.lproj/Localizable.stringsdict b/Mastodon/Resources/th.lproj/Localizable.stringsdict index dc114db41..8971821f6 100644 --- a/Mastodon/Resources/th.lproj/Localizable.stringsdict +++ b/Mastodon/Resources/th.lproj/Localizable.stringsdict @@ -2,6 +2,20 @@ + a11y.plural.count.unread.notification + + NSStringLocalizedFormatKey + %#@notification_count_unread_notification@ + notification_count_unread_notification + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + other + %ld การแจ้งเตือนที่ยังไม่ได้อ่าน + + a11y.plural.count.input_limit_exceeds NSStringLocalizedFormatKey diff --git a/Mastodon/Resources/zh-Hans.lproj/Localizable.strings b/Mastodon/Resources/zh-Hans.lproj/Localizable.strings index 946502c93..7a6b02032 100644 --- a/Mastodon/Resources/zh-Hans.lproj/Localizable.strings +++ b/Mastodon/Resources/zh-Hans.lproj/Localizable.strings @@ -28,6 +28,7 @@ "Common.Controls.Actions.Back" = "返回"; "Common.Controls.Actions.BlockDomain" = "屏蔽 %@"; "Common.Controls.Actions.Cancel" = "取消"; +"Common.Controls.Actions.Compose" = "撰写"; "Common.Controls.Actions.Confirm" = "确认"; "Common.Controls.Actions.Continue" = "继续"; "Common.Controls.Actions.CopyPhoto" = "拷贝照片"; @@ -133,6 +134,9 @@ "Common.Controls.Timeline.Loader.LoadingMissingPosts" = "正在加载帖子..."; "Common.Controls.Timeline.Loader.ShowMoreReplies" = "显示更多回复"; "Common.Controls.Timeline.Timestamp.Now" = "现在"; +"Scene.AccountList.AddAccount" = "添加账户"; +"Scene.AccountList.DismissAccountSwitcher" = "关闭账户切换页面"; +"Scene.AccountList.TabBarHint" = "当前账户:%@。 双击并按住来打开账户切换页面"; "Scene.Compose.Accessibility.AppendAttachment" = "添加附件"; "Scene.Compose.Accessibility.AppendPoll" = "添加投票"; "Scene.Compose.Accessibility.CustomEmojiPicker" = "自定义表情选择器"; @@ -187,6 +191,8 @@ 点击链接确认你的帐户。"; "Scene.ConfirmEmail.Title" = "最后一件事。"; "Scene.Favorite.Title" = "你的喜欢"; +"Scene.Follower.Footer" = "不会显示来自其它服务器的关注者"; +"Scene.Following.Footer" = "不会显示来自其它服务器的关注"; "Scene.HomeTimeline.NavigationBarState.NewPosts" = "查看新帖子"; "Scene.HomeTimeline.NavigationBarState.Offline" = "离线"; "Scene.HomeTimeline.NavigationBarState.Published" = "已发送"; @@ -337,4 +343,7 @@ "Scene.Thread.BackTitle" = "帖子"; "Scene.Thread.Title" = "来自 %@ 的帖子"; "Scene.Welcome.Slogan" = "社交网络 -回到你的手中。"; \ No newline at end of file +回到你的手中。"; +"Scene.Wizard.AccessibilityHint" = "双击关闭此向导"; +"Scene.Wizard.MultipleAccountSwitchIntroDescription" = "按住个人资料标签按钮,即可在多个账户之间进行切换。"; +"Scene.Wizard.NewInMastodon" = "新功能"; \ No newline at end of file diff --git a/Mastodon/Resources/zh-Hans.lproj/Localizable.stringsdict b/Mastodon/Resources/zh-Hans.lproj/Localizable.stringsdict index c28637620..12b8b5f6e 100644 --- a/Mastodon/Resources/zh-Hans.lproj/Localizable.stringsdict +++ b/Mastodon/Resources/zh-Hans.lproj/Localizable.stringsdict @@ -2,6 +2,20 @@ + a11y.plural.count.unread.notification + + NSStringLocalizedFormatKey + %#@notification_count_unread_notification@ + notification_count_unread_notification + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + other + %ld 条未读通知 + + a11y.plural.count.input_limit_exceeds NSStringLocalizedFormatKey diff --git a/Mastodon/Scene/Account/AccountListViewModel.swift b/Mastodon/Scene/Account/AccountListViewModel.swift new file mode 100644 index 000000000..1977b90ec --- /dev/null +++ b/Mastodon/Scene/Account/AccountListViewModel.swift @@ -0,0 +1,159 @@ +// +// AccountListViewModel.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-9-13. +// + +import UIKit +import Combine +import CoreData +import CoreDataStack +import MastodonSDK +import MastodonMeta + +final class AccountListViewModel { + + var disposeBag = Set() + + // input + let context: AppContext + + // output + let authentications = CurrentValueSubject<[Item], Never>([]) + let activeMastodonUserObjectID = CurrentValueSubject(nil) + let dataSourceDidUpdate = PassthroughSubject() + var diffableDataSource: UITableViewDiffableDataSource! + + init(context: AppContext) { + self.context = context + + Publishers.CombineLatest( + context.authenticationService.mastodonAuthentications, + context.authenticationService.activeMastodonAuthentication + ) + .sink { [weak self] authentications, activeAuthentication in + guard let self = self else { return } + var items: [Item] = [] + var activeMastodonUserObjectID: NSManagedObjectID? + for authentication in authentications { + let item = Item.authentication(objectID: authentication.objectID) + items.append(item) + if authentication === activeAuthentication { + activeMastodonUserObjectID = authentication.user.objectID + } + } + self.authentications.value = items + self.activeMastodonUserObjectID.value = activeMastodonUserObjectID + } + .store(in: &disposeBag) + + authentications + .receive(on: DispatchQueue.main) + .sink { [weak self] authentications in + guard let self = self else { return } + guard let diffableDataSource = self.diffableDataSource else { return } + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(authentications, toSection: .main) + snapshot.appendItems([.addAccount], toSection: .main) + + diffableDataSource.apply(snapshot) { + self.dataSourceDidUpdate.send() + } + } + .store(in: &disposeBag) + } + +} + +extension AccountListViewModel { + enum Section: Hashable { + case main + } + + enum Item: Hashable { + case authentication(objectID: NSManagedObjectID) + case addAccount + } + + func setupDiffableDataSource( + tableView: UITableView, + managedObjectContext: NSManagedObjectContext + ) { + diffableDataSource = UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item in + switch item { + case .authentication(let objectID): + let authentication = managedObjectContext.object(with: objectID) as! MastodonAuthentication + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: AccountListTableViewCell.self), for: indexPath) as! AccountListTableViewCell + AccountListViewModel.configure( + cell: cell, + authentication: authentication, + activeMastodonUserObjectID: self.activeMastodonUserObjectID.eraseToAnyPublisher() + ) + return cell + case .addAccount: + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: AddAccountTableViewCell.self), for: indexPath) as! AddAccountTableViewCell + return cell + } + } + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + diffableDataSource?.apply(snapshot) + } + + static func configure( + cell: AccountListTableViewCell, + authentication: MastodonAuthentication, + activeMastodonUserObjectID: AnyPublisher + ) { + let user = authentication.user + + // avatar + cell.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: user.avatarImageURL())) + + // name + do { + let content = MastodonContent(content: user.displayNameWithFallback, emojis: user.emojiMeta) + let metaContent = try MastodonMetaContent.convert(document: content) + cell.nameLabel.configure(content: metaContent) + } catch { + assertionFailure() + cell.nameLabel.configure(content: PlaintextMetaContent(string: user.displayNameWithFallback)) + } + + // username + let usernameMetaContent = PlaintextMetaContent(string: "@" + user.acctWithDomain) + cell.usernameLabel.configure(content: usernameMetaContent) + + // badge + let accessToken = authentication.userAccessToken + let count = UserDefaults.shared.getNotificationCountWithAccessToken(accessToken: accessToken) + cell.badgeButton.setBadge(number: count) + + // checkmark + activeMastodonUserObjectID + .receive(on: DispatchQueue.main) + .sink { objectID in + let isCurrentUser = user.objectID == objectID + cell.tintColor = .label + cell.checkmarkImageView.isHidden = !isCurrentUser + if isCurrentUser { + cell.accessibilityTraits.insert(.selected) + } else { + cell.accessibilityTraits.remove(.selected) + } + } + .store(in: &cell.disposeBag) + + cell.accessibilityLabel = [ + cell.nameLabel.text, + cell.usernameLabel.text, + cell.badgeButton.accessibilityLabel + ] + .compactMap { $0 } + .joined(separator: " ") + } +} diff --git a/Mastodon/Scene/Account/AccountViewController.swift b/Mastodon/Scene/Account/AccountViewController.swift new file mode 100644 index 000000000..fce9c7320 --- /dev/null +++ b/Mastodon/Scene/Account/AccountViewController.swift @@ -0,0 +1,185 @@ +// +// AccountViewController.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-9-13. +// + +import os.log +import UIKit +import Combine +import CoreDataStack +import PanModal + +final class AccountListViewController: UIViewController, NeedsDependency { + + let logger = Logger(subsystem: "AccountListViewController", category: "UI") + + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + var disposeBag = Set() + private(set) lazy var viewModel = AccountListViewModel(context: context) + + private(set) lazy var addBarButtonItem: UIBarButtonItem = { + let barButtonItem = UIBarButtonItem( + image: UIImage(systemName: "plus"), + style: .plain, + target: self, + action: #selector(AccountListViewController.addBarButtonItem(_:)) + ) + return barButtonItem + }() + + let dragIndicatorView = DragIndicatorView() + + var hasLoaded = false + private(set) lazy var tableView: UITableView = { + let tableView = UITableView() + tableView.register(AccountListTableViewCell.self, forCellReuseIdentifier: String(describing: AccountListTableViewCell.self)) + tableView.register(AddAccountTableViewCell.self, forCellReuseIdentifier: String(describing: AddAccountTableViewCell.self)) + tableView.backgroundColor = .clear + tableView.separatorStyle = .none + tableView.tableFooterView = UIView() + return tableView + }() + +} + +// MARK: - PanModalPresentable +extension AccountListViewController: PanModalPresentable { + var panScrollable: UIScrollView? { tableView } + var showDragIndicator: Bool { false } + + var shortFormHeight: PanModalHeight { + func calculateHeight(of numberOfItems: Int) -> CGFloat { + return CGFloat(numberOfItems * 60 + 64) + } + + if hasLoaded { + let height = calculateHeight(of: viewModel.diffableDataSource.snapshot().numberOfItems) + return .contentHeight(CGFloat(height)) + } + + let count = viewModel.context.authenticationService.mastodonAuthentications.value.count + 1 + let height = calculateHeight(of: count) + return .contentHeight(height) + } + + var longFormHeight: PanModalHeight { + return .maxHeightWithTopInset(0) + } +} + +extension AccountListViewController { + override func viewDidLoad() { + super.viewDidLoad() + + setupBackgroundColor(theme: ThemeService.shared.currentTheme.value) + ThemeService.shared.currentTheme + .receive(on: DispatchQueue.main) + .sink { [weak self] theme in + guard let self = self else { return } + self.setupBackgroundColor(theme: theme) + } + .store(in: &disposeBag) + navigationItem.rightBarButtonItem = addBarButtonItem + + dragIndicatorView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(dragIndicatorView) + NSLayoutConstraint.activate([ + dragIndicatorView.topAnchor.constraint(equalTo: view.topAnchor), + dragIndicatorView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + dragIndicatorView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + dragIndicatorView.heightAnchor.constraint(equalToConstant: DragIndicatorView.height).priority(.required - 1), + ]) + + tableView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(tableView) + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: dragIndicatorView.bottomAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + tableView.delegate = self + viewModel.setupDiffableDataSource( + tableView: tableView, + managedObjectContext: context.managedObjectContext + ) + + viewModel.dataSourceDidUpdate + .receive(on: DispatchQueue.main) + .sink { [weak self, weak presentingViewController] in + guard let self = self else { return } + // the presentingViewController may deinit + guard let _ = presentingViewController else { return } + self.hasLoaded = true + self.panModalSetNeedsLayoutUpdate() + self.panModalTransition(to: .shortForm) + } + .store(in: &disposeBag) + + if UIAccessibility.isVoiceOverRunning { + let dragIndicatorTapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer + dragIndicatorView.addGestureRecognizer(dragIndicatorTapGestureRecognizer) + dragIndicatorTapGestureRecognizer.addTarget(self, action: #selector(AccountListViewController.dragIndicatorTapGestureRecognizerHandler(_:))) + dragIndicatorView.isAccessibilityElement = true + dragIndicatorView.accessibilityLabel = L10n.Scene.AccountList.dismissAccountSwitcher + } + } + + private func setupBackgroundColor(theme: Theme) { + let backgroundColor = UIColor { traitCollection in + switch traitCollection.userInterfaceLevel { + case .elevated where traitCollection.userInterfaceStyle == .dark: + return theme.systemElevatedBackgroundColor + default: + return theme.systemBackgroundColor.withAlphaComponent(0.9) + } + } + view.backgroundColor = backgroundColor + } + +} + +extension AccountListViewController { + + @objc private func addBarButtonItem(_ sender: UIBarButtonItem) { + logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") + coordinator.present(scene: .welcome, from: self, transition: .modal(animated: true, completion: nil)) + } + + @objc private func dragIndicatorTapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") + dismiss(animated: true, completion: nil) + } + +} + +// MARK: - UITableViewDelegate +extension AccountListViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + + guard let diffableDataSource = viewModel.diffableDataSource else { return } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } + + switch item { + case .authentication(let objectID): + assert(Thread.isMainThread) + let authentication = context.managedObjectContext.object(with: objectID) as! MastodonAuthentication + context.authenticationService.activeMastodonUser(domain: authentication.domain, userID: authentication.userID) + .receive(on: DispatchQueue.main) + .sink { [weak self] result in + guard let self = self else { return } + self.coordinator.setup() + } + .store(in: &disposeBag) + case .addAccount: + // TODO: add dismiss entry for welcome scene + coordinator.present(scene: .welcome, from: self, transition: .modal(animated: true, completion: nil)) + } + } +} diff --git a/Mastodon/Scene/Account/Cell/AccountListTableViewCell.swift b/Mastodon/Scene/Account/Cell/AccountListTableViewCell.swift new file mode 100644 index 000000000..f6ab75877 --- /dev/null +++ b/Mastodon/Scene/Account/Cell/AccountListTableViewCell.swift @@ -0,0 +1,134 @@ +// +// AccountListTableViewCell.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-9-13. +// + +import UIKit +import Combine +import FLAnimatedImage +import MetaTextKit + +final class AccountListTableViewCell: UITableViewCell { + + private var _disposeBag = Set() + var disposeBag = Set() + + let avatarButton = CircleAvatarButton(frame: .zero) + let nameLabel = MetaLabel(style: .accountListName) + let usernameLabel = MetaLabel(style: .accountListUsername) + let badgeButton = BadgeButton() + let checkmarkImageView: UIImageView = { + let image = UIImage(systemName: "checkmark", withConfiguration: UIImage.SymbolConfiguration(pointSize: 17, weight: .semibold)) + let imageView = UIImageView(image: image) + imageView.tintColor = .label + return imageView + }() + let separatorLine = UIView.separatorLine + + override func prepareForReuse() { + super.prepareForReuse() + + disposeBag.removeAll() + } + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension AccountListTableViewCell { + + private func _init() { + backgroundColor = ThemeService.shared.currentTheme.value.secondarySystemGroupedBackgroundColor + ThemeService.shared.currentTheme + .receive(on: RunLoop.main) + .sink { [weak self] theme in + guard let self = self else { return } + self.backgroundColor = ThemeService.shared.currentTheme.value.secondarySystemGroupedBackgroundColor + } + .store(in: &_disposeBag) + + avatarButton.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(avatarButton) + NSLayoutConstraint.activate([ + avatarButton.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), + avatarButton.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + avatarButton.heightAnchor.constraint(equalTo: avatarButton.widthAnchor, multiplier: 1.0).priority(.required - 1), + avatarButton.heightAnchor.constraint(greaterThanOrEqualToConstant: 30).priority(.required - 1), + ]) + avatarButton.setContentHuggingPriority(.defaultLow, for: .horizontal) + avatarButton.setContentHuggingPriority(.defaultLow, for: .vertical) + + let labelContainerStackView = UIStackView() + labelContainerStackView.axis = .vertical + labelContainerStackView.distribution = .equalCentering + labelContainerStackView.spacing = 2 + labelContainerStackView.distribution = .fillProportionally + labelContainerStackView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(labelContainerStackView) + NSLayoutConstraint.activate([ + labelContainerStackView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8), + labelContainerStackView.leadingAnchor.constraint(equalTo: avatarButton.trailingAnchor, constant: 10), + contentView.bottomAnchor.constraint(equalTo: labelContainerStackView.bottomAnchor, constant: 10), + avatarButton.heightAnchor.constraint(equalTo: labelContainerStackView.heightAnchor, multiplier: 0.8).priority(.required - 10), + ]) + + labelContainerStackView.addArrangedSubview(nameLabel) + labelContainerStackView.addArrangedSubview(usernameLabel) + + badgeButton.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(badgeButton) + NSLayoutConstraint.activate([ + badgeButton.leadingAnchor.constraint(equalTo: labelContainerStackView.trailingAnchor, constant: 4), + badgeButton.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + badgeButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 16).priority(.required - 1), + badgeButton.widthAnchor.constraint(equalTo: badgeButton.heightAnchor, multiplier: 1.0).priority(.required - 1), + ]) + badgeButton.setContentHuggingPriority(.required - 10, for: .horizontal) + badgeButton.setContentCompressionResistancePriority(.required - 10, for: .horizontal) + + checkmarkImageView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(checkmarkImageView) + NSLayoutConstraint.activate([ + checkmarkImageView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + checkmarkImageView.leadingAnchor.constraint(equalTo: badgeButton.trailingAnchor, constant: 12), + checkmarkImageView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), + ]) + checkmarkImageView.setContentHuggingPriority(.required - 9, for: .horizontal) + checkmarkImageView.setContentCompressionResistancePriority(.required - 9, for: .horizontal) + + avatarButton.isUserInteractionEnabled = false + nameLabel.isUserInteractionEnabled = false + usernameLabel.isUserInteractionEnabled = false + badgeButton.isUserInteractionEnabled = false + + separatorLine.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(separatorLine) + NSLayoutConstraint.activate([ + separatorLine.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), + separatorLine.trailingAnchor.constraint(equalTo: trailingAnchor), // needs align to edge + separatorLine.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + separatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView)), + ]) + + badgeButton.setBadge(number: 0) + checkmarkImageView.isHidden = true + } + +} + +// MARK: - AvatarConfigurableView +extension AccountListTableViewCell: AvatarConfigurableView { + static var configurableAvatarImageSize: CGSize { CGSize(width: 30, height: 30) } + static var configurableAvatarImageCornerRadius: CGFloat { 0 } + var configurableAvatarImageView: FLAnimatedImageView? { avatarButton.avatarImageView } +} diff --git a/Mastodon/Scene/Account/Cell/AddAccountTableViewCell.swift b/Mastodon/Scene/Account/Cell/AddAccountTableViewCell.swift new file mode 100644 index 000000000..0873c1390 --- /dev/null +++ b/Mastodon/Scene/Account/Cell/AddAccountTableViewCell.swift @@ -0,0 +1,109 @@ +// +// AddAccountTableViewCell.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-9-14. +// + +import UIKit +import Combine +import MetaTextKit + +final class AddAccountTableViewCell: UITableViewCell { + + private var _disposeBag = Set() + + let iconImageView: UIImageView = { + let image = UIImage(systemName: "plus.circle.fill")! + let imageView = UIImageView(image: image) + imageView.tintColor = Asset.Colors.Label.primary.color + return imageView + }() + let titleLabel: UILabel = { + let label = UILabel() + label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .regular), maximumPointSize: 22) + label.textColor = Asset.Colors.Label.primary.color + label.text = L10n.Scene.AccountList.addAccount + return label + }() + let usernameLabel = MetaLabel(style: .accountListUsername) + let separatorLine = UIView.separatorLine + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension AddAccountTableViewCell { + + private func _init() { + backgroundColor = ThemeService.shared.currentTheme.value.secondarySystemGroupedBackgroundColor + ThemeService.shared.currentTheme + .receive(on: RunLoop.main) + .sink { [weak self] theme in + guard let self = self else { return } + self.backgroundColor = ThemeService.shared.currentTheme.value.secondarySystemGroupedBackgroundColor + } + .store(in: &_disposeBag) + + iconImageView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(iconImageView) + NSLayoutConstraint.activate([ + iconImageView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), + iconImageView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + iconImageView.heightAnchor.constraint(equalTo: iconImageView.widthAnchor, multiplier: 1.0).priority(.required - 1), + iconImageView.heightAnchor.constraint(greaterThanOrEqualToConstant: 30).priority(.required - 1), + ]) + iconImageView.setContentHuggingPriority(.defaultLow, for: .horizontal) + iconImageView.setContentHuggingPriority(.defaultLow, for: .vertical) + + // layout the same placeholder UI from `AccountListTableViewCell` + let placeholderLabelContainerStackView = UIStackView() + placeholderLabelContainerStackView.axis = .vertical + placeholderLabelContainerStackView.distribution = .equalCentering + placeholderLabelContainerStackView.spacing = 2 + placeholderLabelContainerStackView.distribution = .fillProportionally + placeholderLabelContainerStackView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(placeholderLabelContainerStackView) + NSLayoutConstraint.activate([ + placeholderLabelContainerStackView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8), + placeholderLabelContainerStackView.leadingAnchor.constraint(equalTo: iconImageView.trailingAnchor, constant: 10), + contentView.bottomAnchor.constraint(equalTo: placeholderLabelContainerStackView.bottomAnchor, constant: 10), + iconImageView.heightAnchor.constraint(equalTo: placeholderLabelContainerStackView.heightAnchor, multiplier: 0.8).priority(.required - 10), + ]) + let _nameLabel = MetaLabel(style: .accountListName) + _nameLabel.configure(content: PlaintextMetaContent(string: " ")) + let _usernameLabel = MetaLabel(style: .accountListUsername) + _usernameLabel.configure(content: PlaintextMetaContent(string: " ")) + placeholderLabelContainerStackView.addArrangedSubview(_nameLabel) + placeholderLabelContainerStackView.addArrangedSubview(_usernameLabel) + placeholderLabelContainerStackView.isHidden = true + + titleLabel.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(titleLabel) + NSLayoutConstraint.activate([ + titleLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 15), + titleLabel.leadingAnchor.constraint(equalTo: iconImageView.trailingAnchor, constant: 10), + contentView.bottomAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 15), + // iconImageView.heightAnchor.constraint(equalTo: titleLabel.heightAnchor, multiplier: 1.0).priority(.required - 10), + titleLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + ]) + + separatorLine.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(separatorLine) + NSLayoutConstraint.activate([ + separatorLine.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + separatorLine.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + separatorLine.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + separatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView)), + ]) + } + +} diff --git a/Mastodon/Scene/Account/View/BadgeButton.swift b/Mastodon/Scene/Account/View/BadgeButton.swift new file mode 100644 index 000000000..6d92a8471 --- /dev/null +++ b/Mastodon/Scene/Account/View/BadgeButton.swift @@ -0,0 +1,46 @@ +// +// BadgeButton.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-9-16. +// + +import UIKit + +final class BadgeButton: UIButton { + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension BadgeButton { + private func _init() { + titleLabel?.font = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .systemFont(ofSize: 13, weight: .medium)) + setBackgroundColor(Asset.Colors.badgeBackground.color, for: .normal) + setTitleColor(.white, for: .normal) + + contentEdgeInsets = UIEdgeInsets(top: 5, left: 5, bottom: 5, right: 5) + } + + override func layoutSubviews() { + super.layoutSubviews() + + layer.masksToBounds = true + layer.cornerRadius = frame.height * 0.5 + } + + func setBadge(number: Int) { + let number = min(99, max(0, number)) + setTitle("\(number)", for: .normal) + self.isHidden = number == 0 + accessibilityLabel = L10n.A11y.Plural.Count.Unread.notification(number) + } +} diff --git a/Mastodon/Scene/Account/View/DragIndicatorView.swift b/Mastodon/Scene/Account/View/DragIndicatorView.swift new file mode 100644 index 000000000..5efa141bc --- /dev/null +++ b/Mastodon/Scene/Account/View/DragIndicatorView.swift @@ -0,0 +1,55 @@ +// +// DragIndicatorView.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-9-14. +// + +import UIKit + +final class DragIndicatorView: UIView { + + static let height: CGFloat = 38 + + let barView = UIView() + let separatorLine = UIView.separatorLine + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension DragIndicatorView { + + private func _init() { + barView.backgroundColor = Asset.Colors.Label.secondary.color + barView.layer.masksToBounds = true + barView.layer.cornerRadius = 2.5 + + barView.translatesAutoresizingMaskIntoConstraints = false + addSubview(barView) + NSLayoutConstraint.activate([ + barView.centerXAnchor.constraint(equalTo: centerXAnchor), + barView.centerYAnchor.constraint(equalTo: centerYAnchor), + barView.heightAnchor.constraint(equalToConstant: 5).priority(.required - 1), + barView.widthAnchor.constraint(equalToConstant: 36).priority(.required - 1), + ]) + + separatorLine.translatesAutoresizingMaskIntoConstraints = false + addSubview(separatorLine) + NSLayoutConstraint.activate([ + separatorLine.leadingAnchor.constraint(equalTo: leadingAnchor), + separatorLine.trailingAnchor.constraint(equalTo: trailingAnchor), + separatorLine.bottomAnchor.constraint(equalTo: bottomAnchor), + separatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: self)), + ]) + } + +} diff --git a/Mastodon/Scene/Compose/AutoComplete/Cell/AutoCompleteTableViewCell.swift b/Mastodon/Scene/Compose/AutoComplete/Cell/AutoCompleteTableViewCell.swift index 7492753fe..c1e7ab6a4 100644 --- a/Mastodon/Scene/Compose/AutoComplete/Cell/AutoCompleteTableViewCell.swift +++ b/Mastodon/Scene/Compose/AutoComplete/Cell/AutoCompleteTableViewCell.swift @@ -33,6 +33,7 @@ final class AutoCompleteTableViewCell: UITableViewCell { let titleLabel: MetaLabel = { let label = MetaLabel(style: .autoCompletion) + label.isUserInteractionEnabled = false return label }() diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentTableViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentTableViewCell.swift index 885840110..f44d29a68 100644 --- a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentTableViewCell.swift +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentTableViewCell.swift @@ -11,12 +11,17 @@ import Combine import MetaTextKit import UITextView_Placeholder +protocol ComposeStatusContentTableViewCellDelegate: AnyObject { + func composeStatusContentTableViewCell(_ cell: ComposeStatusContentTableViewCell, textViewShouldBeginEditing textView: UITextView) -> Bool +} + final class ComposeStatusContentTableViewCell: UITableViewCell { let logger = Logger(subsystem: "ComposeStatusContentTableViewCell", category: "UI") var disposeBag = Set() - + weak var delegate: ComposeStatusContentTableViewCellDelegate? + let statusView = ReplicaStatusView() let statusContentWarningEditorView = StatusContentWarningEditorView() @@ -136,6 +141,10 @@ extension ComposeStatusContentTableViewCell { // MARK: - UITextViewDelegate extension ComposeStatusContentTableViewCell: UITextViewDelegate { + + func textViewShouldBeginEditing(_ textView: UITextView) -> Bool { + return delegate?.composeStatusContentTableViewCell(self, textViewShouldBeginEditing: textView) ?? true + } func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { switch textView { diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index 275c8e456..5968df428 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -18,7 +18,7 @@ import MastodonUI final class ComposeViewController: UIViewController, NeedsDependency { static let minAutoCompleteVisibleHeight: CGFloat = 100 - + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } @@ -26,9 +26,20 @@ final class ComposeViewController: UIViewController, NeedsDependency { var viewModel: ComposeViewModel! let logger = Logger(subsystem: "ComposeViewController", category: "logic") - - private var suffixedAttachmentViews: [UIView] = [] + private(set) lazy var cancelBarButtonItem = UIBarButtonItem(title: L10n.Common.Controls.Actions.cancel, style: .plain, target: self, action: #selector(ComposeViewController.cancelBarButtonItemPressed(_:))) + let characterCountLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 15, weight: .regular) + label.text = "500" + label.textColor = Asset.Colors.Label.secondary.color + label.accessibilityLabel = L10n.A11y.Plural.Count.inputLimitRemains(500) + return label + }() + private(set) lazy var characterCountBarButtonItem: UIBarButtonItem = { + let barButtonItem = UIBarButtonItem(customView: characterCountLabel) + return barButtonItem + }() let publishButton: UIButton = { let button = RoundedEdgesButton(type: .custom) button.setTitle(L10n.Scene.Compose.composeAction, for: .normal) @@ -41,8 +52,6 @@ final class ComposeViewController: UIViewController, NeedsDependency { button.adjustsImageWhenHighlighted = false return button }() - - private(set) lazy var cancelBarButtonItem = UIBarButtonItem(title: L10n.Common.Controls.Actions.cancel, style: .plain, target: self, action: #selector(ComposeViewController.cancelBarButtonItemPressed(_:))) private(set) lazy var publishBarButtonItem: UIBarButtonItem = { let barButtonItem = UIBarButtonItem(customView: publishButton) return barButtonItem @@ -136,6 +145,15 @@ extension ComposeViewController { override func viewDidLoad() { super.viewDidLoad() + + configureNavigationBarTitleStyle() + viewModel.traitCollectionDidChangePublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + self.configureNavigationBarTitleStyle() + } + .store(in: &disposeBag) viewModel.title .receive(on: DispatchQueue.main) @@ -154,6 +172,18 @@ extension ComposeViewController { .store(in: &disposeBag) navigationItem.leftBarButtonItem = cancelBarButtonItem navigationItem.rightBarButtonItem = publishBarButtonItem + viewModel.traitCollectionDidChangePublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + guard self.traitCollection.userInterfaceIdiom == .pad else { return } + var items = [self.publishBarButtonItem] + if self.traitCollection.horizontalSizeClass == .regular { + items.append(self.characterCountBarButtonItem) + } + self.navigationItem.rightBarButtonItems = items + } + .store(in: &disposeBag) publishButton.addTarget(self, action: #selector(ComposeViewController.publishBarButtonItemPressed(_:)), for: .touchUpInside) @@ -219,8 +249,12 @@ extension ComposeViewController { dependency: self ) + viewModel.composeStatusContentTableViewCell.delegate = self + // update layout when keyboard show/dismiss view.layoutIfNeeded() + + let keyboardHasShortcutBar = CurrentValueSubject(traitCollection.userInterfaceIdiom == .pad) // update default value later let keyboardEventPublishers = Publishers.CombineLatest3( KeyboardResponderService.shared.isShow, KeyboardResponderService.shared.state, @@ -233,8 +267,16 @@ extension ComposeViewController { ) .sink(receiveValue: { [weak self] keyboardEvents, isCustomEmojiComposing, autoCompleteInfo in guard let self = self else { return } - + let (isShow, state, endFrame) = keyboardEvents + + switch self.traitCollection.userInterfaceIdiom { + case .pad: + keyboardHasShortcutBar.value = state != .floating + default: + keyboardHasShortcutBar.value = false + } + let extraMargin: CGFloat = { var margin = self.composeToolbarView.frame.height if autoCompleteInfo != nil { @@ -330,13 +372,21 @@ extension ComposeViewController { // bind media button toolbar state viewModel.isMediaToolbarButtonEnabled .receive(on: DispatchQueue.main) - .assign(to: \.isEnabled, on: composeToolbarView.mediaButton) + .sink { [weak self] isMediaToolbarButtonEnabled in + guard let self = self else { return } + self.composeToolbarView.mediaBarButtonItem.isEnabled = isMediaToolbarButtonEnabled + self.composeToolbarView.mediaButton.isEnabled = isMediaToolbarButtonEnabled + } .store(in: &disposeBag) // bind poll button toolbar state viewModel.isPollToolbarButtonEnabled .receive(on: DispatchQueue.main) - .assign(to: \.isEnabled, on: composeToolbarView.pollButton) + .sink { [weak self] isPollToolbarButtonEnabled in + guard let self = self else { return } + self.composeToolbarView.pollBarButtonItem.isEnabled = isPollToolbarButtonEnabled + self.composeToolbarView.pollButton.isEnabled = isPollToolbarButtonEnabled + } .store(in: &disposeBag) Publishers.CombineLatest( @@ -347,10 +397,14 @@ extension ComposeViewController { .sink { [weak self] isPollComposing, isPollToolbarButtonEnabled in guard let self = self else { return } guard isPollToolbarButtonEnabled else { - self.composeToolbarView.pollButton.accessibilityLabel = L10n.Scene.Compose.Accessibility.appendPoll + let accessibilityLabel = L10n.Scene.Compose.Accessibility.appendPoll + self.composeToolbarView.pollBarButtonItem.accessibilityLabel = accessibilityLabel + self.composeToolbarView.pollButton.accessibilityLabel = accessibilityLabel return } - self.composeToolbarView.pollButton.accessibilityLabel = isPollComposing ? L10n.Scene.Compose.Accessibility.removePoll : L10n.Scene.Compose.Accessibility.appendPoll + let accessibilityLabel = isPollComposing ? L10n.Scene.Compose.Accessibility.removePoll : L10n.Scene.Compose.Accessibility.appendPoll + self.composeToolbarView.pollBarButtonItem.accessibilityLabel = accessibilityLabel + self.composeToolbarView.pollButton.accessibilityLabel = accessibilityLabel } .store(in: &disposeBag) @@ -359,7 +413,9 @@ extension ComposeViewController { .receive(on: DispatchQueue.main) .sink { [weak self] attachmentServices in guard let self = self else { return } - self.composeToolbarView.mediaButton.isEnabled = attachmentServices.count < 4 + let isEnabled = attachmentServices.count < self.viewModel.maxMediaAttachments + self.composeToolbarView.mediaBarButtonItem.isEnabled = isEnabled + self.composeToolbarView.mediaButton.isEnabled = isEnabled self.resetImagePicker() } .store(in: &disposeBag) @@ -369,7 +425,9 @@ extension ComposeViewController { .receive(on: DispatchQueue.main) .sink { [weak self] isContentWarningComposing in guard let self = self else { return } - self.composeToolbarView.contentWarningButton.accessibilityLabel = isContentWarningComposing ? L10n.Scene.Compose.Accessibility.disableContentWarning : L10n.Scene.Compose.Accessibility.enableContentWarning + let accessibilityLabel = isContentWarningComposing ? L10n.Scene.Compose.Accessibility.disableContentWarning : L10n.Scene.Compose.Accessibility.enableContentWarning + self.composeToolbarView.contentWarningBarButtonItem.accessibilityLabel = accessibilityLabel + self.composeToolbarView.contentWarningButton.accessibilityLabel = accessibilityLabel } .store(in: &disposeBag) @@ -382,6 +440,7 @@ extension ComposeViewController { .sink { [weak self] type, _ in guard let self = self else { return } let image = type.image(interfaceStyle: self.traitCollection.userInterfaceStyle) + self.composeToolbarView.visibilityBarButtonItem.image = image self.composeToolbarView.visibilityButton.setImage(image, for: .normal) self.composeToolbarView.activeVisibilityType.value = type } @@ -391,18 +450,29 @@ extension ComposeViewController { .receive(on: DispatchQueue.main) .sink { [weak self] characterCount in guard let self = self else { return } - let count = ComposeViewModel.composeContentLimit - characterCount + let count = self.viewModel.composeContentLimit - characterCount self.composeToolbarView.characterCountLabel.text = "\(count)" + self.characterCountLabel.text = "\(count)" + let font: UIFont + let textColor: UIColor + let accessibilityLabel: String switch count { case _ where count < 0: - self.composeToolbarView.characterCountLabel.font = .monospacedDigitSystemFont(ofSize: 24, weight: .bold) - self.composeToolbarView.characterCountLabel.textColor = Asset.Colors.danger.color - self.composeToolbarView.characterCountLabel.accessibilityLabel = L10n.A11y.Plural.Count.inputLimitExceeds(abs(count)) + font = .monospacedDigitSystemFont(ofSize: 24, weight: .bold) + textColor = Asset.Colors.danger.color + accessibilityLabel = L10n.A11y.Plural.Count.inputLimitExceeds(abs(count)) default: - self.composeToolbarView.characterCountLabel.font = .monospacedDigitSystemFont(ofSize: 15, weight: .regular) - self.composeToolbarView.characterCountLabel.textColor = Asset.Colors.Label.secondary.color - self.composeToolbarView.characterCountLabel.accessibilityLabel = L10n.A11y.Plural.Count.inputLimitRemains(count) + font = .monospacedDigitSystemFont(ofSize: 15, weight: .regular) + textColor = Asset.Colors.Label.secondary.color + accessibilityLabel = L10n.A11y.Plural.Count.inputLimitRemains(count) } + self.composeToolbarView.characterCountLabel.font = font + self.composeToolbarView.characterCountLabel.textColor = textColor + self.composeToolbarView.characterCountLabel.accessibilityLabel = accessibilityLabel + self.characterCountLabel.font = font + self.characterCountLabel.textColor = textColor + self.characterCountLabel.accessibilityLabel = accessibilityLabel + self.characterCountLabel.sizeToFit() } .store(in: &disposeBag) @@ -445,6 +515,18 @@ extension ComposeViewController { } } .store(in: &disposeBag) + + configureToolbarDisplay(keyboardHasShortcutBar: keyboardHasShortcutBar.value) + Publishers.CombineLatest( + keyboardHasShortcutBar, + viewModel.traitCollectionDidChangePublisher + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] keyboardHasShortcutBar, _ in + guard let self = self else { return } + self.configureToolbarDisplay(keyboardHasShortcutBar: keyboardHasShortcutBar) + } + .store(in: &disposeBag) } override func viewWillAppear(_ animated: Bool) { @@ -556,11 +638,7 @@ extension ComposeViewController { } private func showDismissConfirmAlertController() { - let alertController = UIAlertController( - title: L10n.Common.Alerts.DiscardPostContent.title, - message: L10n.Common.Alerts.DiscardPostContent.message, - preferredStyle: .alert - ) + let alertController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) let discardAction = UIAlertAction(title: L10n.Common.Controls.Actions.discard, style: .destructive) { [weak self] _ in guard let self = self else { return } self.dismiss(animated: true, completion: nil) @@ -568,11 +646,12 @@ extension ComposeViewController { alertController.addAction(discardAction) let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel) alertController.addAction(cancelAction) + alertController.popoverPresentationController?.barButtonItem = cancelBarButtonItem present(alertController, animated: true, completion: nil) } private func resetImagePicker() { - let selectionLimit = max(1, 4 - viewModel.attachmentServices.value.count) + let selectionLimit = max(1, viewModel.maxMediaAttachments - viewModel.attachmentServices.value.count) let configuration = ComposeViewController.createPhotoLibraryPickerConfiguration(selectionLimit: selectionLimit) photoLibraryPicker = createImagePicker(configuration: configuration) } @@ -588,6 +667,37 @@ extension ComposeViewController { tableView.backgroundColor = theme.systemElevatedBackgroundColor composeToolbarBackgroundView.backgroundColor = theme.composeToolbarBackgroundColor } + + private func setupInputAssistantItem(item: UITextInputAssistantItem) { + let groups = [UIBarButtonItemGroup(barButtonItems: [ + composeToolbarView.mediaBarButtonItem, + composeToolbarView.pollBarButtonItem, + composeToolbarView.contentWarningBarButtonItem, + composeToolbarView.visibilityBarButtonItem, + ], representativeItem: nil)] + + item.trailingBarButtonGroups = groups + } + + private func configureToolbarDisplay(keyboardHasShortcutBar: Bool) { + switch self.traitCollection.userInterfaceIdiom { + case .pad: + let shouldHideToolbar = keyboardHasShortcutBar && self.traitCollection.horizontalSizeClass == .regular + self.composeToolbarView.alpha = shouldHideToolbar ? 0 : 1 + self.composeToolbarBackgroundView.alpha = shouldHideToolbar ? 0 : 1 + default: + break + } + } + + private func configureNavigationBarTitleStyle() { + switch traitCollection.userInterfaceIdiom { + case .pad: + navigationController?.navigationBar.prefersLargeTitles = traitCollection.horizontalSizeClass == .regular + default: + break + } + } } @@ -639,6 +749,20 @@ extension ComposeViewController: MetaTextDelegate { // MARK: - UITextViewDelegate extension ComposeViewController: UITextViewDelegate { + + func textViewShouldBeginEditing(_ textView: UITextView) -> Bool { + setupInputAssistantItem(item: textView.inputAssistantItem) + return true + } +// func textViewDidBeginEditing(_ textView: UITextView) { +// switch textView { +// case textEditorView()?.textView: +// setupInputAssistantItem(item: textView.inputAssistantItem) +// default: +// assertionFailure() +// break +// } +// } func textViewDidChange(_ textView: UITextView) { if textEditorView()?.textView === textView { @@ -779,7 +903,7 @@ extension ComposeViewController: UITextViewDelegate { // MARK: - ComposeToolbarViewDelegate extension ComposeViewController: ComposeToolbarViewDelegate { - func composeToolbarView(_ composeToolbarView: ComposeToolbarView, cameraButtonDidPressed sender: UIButton, mediaSelectionType type: ComposeToolbarView.MediaSelectionType) { + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, cameraButtonDidPressed sender: Any, mediaSelectionType type: ComposeToolbarView.MediaSelectionType) { switch type { case .photoLibrary: present(photoLibraryPicker, animated: true, completion: nil) @@ -790,7 +914,7 @@ extension ComposeViewController: ComposeToolbarViewDelegate { } } - func composeToolbarView(_ composeToolbarView: ComposeToolbarView, pollButtonDidPressed sender: UIButton) { + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, pollButtonDidPressed sender: Any) { // toggle poll composing state viewModel.isPollComposing.value.toggle() @@ -812,11 +936,11 @@ extension ComposeViewController: ComposeToolbarViewDelegate { } } - func composeToolbarView(_ composeToolbarView: ComposeToolbarView, emojiButtonDidPressed sender: UIButton) { + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, emojiButtonDidPressed sender: Any) { viewModel.isCustomEmojiComposing.value.toggle() } - func composeToolbarView(_ composeToolbarView: ComposeToolbarView, contentWarningButtonDidPressed sender: UIButton) { + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, contentWarningButtonDidPressed sender: Any) { // cancel custom picker input viewModel.isCustomEmojiComposing.value = false @@ -836,7 +960,7 @@ extension ComposeViewController: ComposeToolbarViewDelegate { } } - func composeToolbarView(_ composeToolbarView: ComposeToolbarView, visibilityButtonDidPressed sender: UIButton, visibilitySelectionType type: ComposeToolbarView.VisibilitySelectionType) { + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, visibilityButtonDidPressed sender: Any, visibilitySelectionType type: ComposeToolbarView.VisibilitySelectionType) { viewModel.selectedStatusVisibility.value = type } @@ -924,8 +1048,12 @@ extension ComposeViewController: UICollectionViewDelegate { extension ComposeViewController: UIAdaptivePresentationControllerDelegate { func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle { - return .overFullScreen - //return traitCollection.userInterfaceIdiom == .pad ? .formSheet : .automatic + switch traitCollection.horizontalSizeClass { + case .compact: + return .overFullScreen + default: + return .pageSheet + } } func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool { @@ -1022,6 +1150,9 @@ extension ComposeViewController: ComposeStatusAttachmentCollectionViewCellDelega extension ComposeViewController: ComposeStatusPollOptionCollectionViewCellDelegate { func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, textFieldDidBeginEditing textField: UITextField) { + + setupInputAssistantItem(item: textField.inputAssistantItem) + // FIXME: make poll section visible // DispatchQueue.main.async { // self.collectionView.scroll(to: .bottom, animated: true) @@ -1118,6 +1249,14 @@ extension ComposeViewController: ComposeStatusPollExpiresOptionCollectionViewCel } } +// MARK: - ComposeStatusContentTableViewCellDelegate +extension ComposeViewController: ComposeStatusContentTableViewCellDelegate { + func composeStatusContentTableViewCell(_ cell: ComposeStatusContentTableViewCell, textViewShouldBeginEditing textView: UITextView) -> Bool { + setupInputAssistantItem(item: textView.inputAssistantItem) + return true + } +} + // MARK: - AutoCompleteViewControllerDelegate extension ComposeViewController: AutoCompleteViewControllerDelegate { func autoCompleteViewController(_ viewController: AutoCompleteViewController, didSelectItem item: AutoCompleteItem) { @@ -1136,7 +1275,6 @@ extension ComposeViewController: AutoCompleteViewControllerDelegate { case .bottomLoader: return nil } - text.append(" ") return text }() guard let replacedText = _replacedText else { return } @@ -1147,6 +1285,9 @@ extension ComposeViewController: AutoCompleteViewControllerDelegate { let range = NSRange(info.toHighlightEndRange, in: text) textEditorView.textStorage.replaceCharacters(in: range, with: replacedText) + DispatchQueue.main.async { + textEditorView.textView.insertText(" ") // trigger textView delegate update + } viewModel.autoCompleteInfo.value = nil switch item { diff --git a/Mastodon/Scene/Compose/ComposeViewModel+DataSource.swift b/Mastodon/Scene/Compose/ComposeViewModel+DataSource.swift index f6e700fbb..7fd07bf83 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel+DataSource.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel+DataSource.swift @@ -39,6 +39,15 @@ extension ComposeViewModel { // setup data source tableView.dataSource = self + + composeStatusAttachmentTableViewCell.collectionViewHeightDidUpdate + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let _ = self else { return } + tableView.beginUpdates() + tableView.endUpdates() + } + .store(in: &disposeBag) attachmentServices .removeDuplicates() @@ -55,10 +64,10 @@ extension ComposeViewModel { let items = attachmentServices.map { ComposeStatusAttachmentItem.attachment(attachmentService: $0) } snapshot.appendItems(items, toSection: .main) - tableView.performBatchUpdates { - dataSource.apply(snapshot, animatingDifferences: true) - } completion: { _ in - // do nothing + if #available(iOS 15.0, *) { + dataSource.applySnapshotUsingReloadData(snapshot) + } else { + dataSource.apply(snapshot, animatingDifferences: false) } } .store(in: &disposeBag) @@ -82,7 +91,7 @@ extension ComposeViewModel { for attribute in pollOptionAttributes { items.append(.pollOption(attribute: attribute)) } - if pollOptionAttributes.count < 4 { + if pollOptionAttributes.count < self.maxPollOptions { items.append(.pollOptionAppendEntry) } items.append(.pollExpiresOption(attribute: self.pollExpiresOptionAttribute)) @@ -90,9 +99,11 @@ extension ComposeViewModel { snapshot.appendItems(items, toSection: .main) tableView.performBatchUpdates { - dataSource.apply(snapshot, animatingDifferences: true) - } completion: { _ in - // do nothing + if #available(iOS 15.0, *) { + dataSource.apply(snapshot, animatingDifferences: false) + } else { + dataSource.apply(snapshot, animatingDifferences: true) + } } } .store(in: &disposeBag) @@ -226,7 +237,7 @@ extension ComposeViewModel: UITableViewDataSource { return } cell.statusView.headerContainerView.isHidden = false - cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.replyIconImage) + cell.statusView.headerIconLabel.configure(attributedString: StatusView.iconAttributedString(image: StatusView.replyIconImage)) let headerText: String = { let author = replyTo.author let name = author.displayName.isEmpty ? author.username : author.displayName diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index f91565d38..8cb54d88a 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -15,7 +15,6 @@ import MastodonSDK final class ComposeViewModel: NSObject { - static let composeContentLimit: Int = 500 var disposeBag = Set() @@ -38,6 +37,24 @@ final class ComposeViewModel: NSObject { var isViewAppeared = false // output + let instanceConfiguration: Mastodon.Entity.Instance.Configuration? + var composeContentLimit: Int { + guard let maxCharacters = instanceConfiguration?.statuses?.maxCharacters else { return 500 } + return max(1, maxCharacters) + } + var maxMediaAttachments: Int { + guard let maxMediaAttachments = instanceConfiguration?.statuses?.maxMediaAttachments else { + return 4 + } + // FIXME: update timeline media preview UI + return min(4, max(1, maxMediaAttachments)) + // return max(1, maxMediaAttachments) + } + var maxPollOptions: Int { + guard let maxOptions = instanceConfiguration?.polls?.maxOptions else { return 4 } + return max(2, maxOptions) + } + let composeStatusContentTableViewCell = ComposeStatusContentTableViewCell() let composeStatusAttachmentTableViewCell = ComposeStatusAttachmentTableViewCell() let composeStatusPollTableViewCell = ComposeStatusPollTableViewCell() @@ -128,8 +145,12 @@ final class ComposeViewModel: NSObject { } return CurrentValueSubject(visibility) }() - self.activeAuthentication = CurrentValueSubject(context.authenticationService.activeMastodonAuthentication.value) + let _activeAuthentication = context.authenticationService.activeMastodonAuthentication.value + self.activeAuthentication = CurrentValueSubject(_activeAuthentication) self.activeAuthenticationBox = CurrentValueSubject(context.authenticationService.activeMastodonAuthenticationBox.value) + // set limit + let _instanceConfiguration = _activeAuthentication?.instance?.configuration + self.instanceConfiguration = _instanceConfiguration super.init() // end init @@ -151,7 +172,9 @@ final class ComposeViewModel: NSObject { .sorted(by: { $0.index.intValue < $1.index.intValue }) .filter { $0.id != composeAuthor?.id } for mention in mentions { - mentionAccts.append("@" + mention.acct) + let acct = "@" + mention.acct + guard !mentionAccts.contains(acct) else { continue } + mentionAccts.append(acct) } for acct in mentionAccts { UITextChecker.learnWord(acct) @@ -241,8 +264,9 @@ final class ComposeViewModel: NSObject { let isComposeContentEmpty = composeStatusAttribute.composeContent .map { ($0 ?? "").isEmpty } let isComposeContentValid = characterCount - .map { characterCount -> Bool in - return characterCount <= ComposeViewModel.composeContentLimit + .compactMap { [weak self] characterCount -> Bool in + guard let self = self else { return characterCount <= 500 } + return characterCount <= self.composeContentLimit } let isMediaEmpty = attachmentServices .map { $0.isEmpty } @@ -379,7 +403,7 @@ final class ComposeViewModel: NSObject { .receive(on: DispatchQueue.main) .sink(receiveValue: { [weak self] isPollComposing, attachmentServices in guard let self = self else { return } - let shouldMediaDisable = isPollComposing || attachmentServices.count >= 4 + let shouldMediaDisable = isPollComposing || attachmentServices.count >= self.maxMediaAttachments let shouldPollDisable = attachmentServices.count > 0 self.isMediaToolbarButtonEnabled.value = !shouldMediaDisable @@ -453,7 +477,7 @@ extension ComposeViewModel { extension ComposeViewModel { func createNewPollOptionIfPossible() { - guard pollOptionAttributes.value.count < 4 else { return } + guard pollOptionAttributes.value.count < maxPollOptions else { return } let attribute = ComposeStatusPollItem.PollOptionAttribute() pollOptionAttributes.value = pollOptionAttributes.value + [attribute] @@ -486,7 +510,7 @@ extension ComposeViewModel { // check exclusive limit: // - up to 1 video - // - up to 4 photos + // - up to N photos func checkAttachmentPrecondition() throws { let attachmentServices = self.attachmentServices.value guard !attachmentServices.isEmpty else { return } diff --git a/Mastodon/Scene/Compose/TableViewCell/ComposeStatusAttachmentTableViewCell.swift b/Mastodon/Scene/Compose/TableViewCell/ComposeStatusAttachmentTableViewCell.swift index 561c6c3ba..6d2bbe93a 100644 --- a/Mastodon/Scene/Compose/TableViewCell/ComposeStatusAttachmentTableViewCell.swift +++ b/Mastodon/Scene/Compose/TableViewCell/ComposeStatusAttachmentTableViewCell.swift @@ -35,6 +35,7 @@ final class ComposeStatusAttachmentTableViewCell: UITableViewCell { collectionView.isScrollEnabled = false return collectionView }() + let collectionViewHeightDidUpdate = PassthroughSubject() override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) @@ -68,6 +69,7 @@ extension ComposeStatusAttachmentTableViewCell { collectionView.observe(\.contentSize, options: [.initial, .new]) { [weak self] collectionView, _ in guard let self = self else { return } self.collectionViewHeightLayoutConstraint.constant = collectionView.contentSize.height + self.collectionViewHeightDidUpdate.send() } .store(in: &observations) diff --git a/Mastodon/Scene/Compose/TableViewCell/ComposeStatusPollTableViewCell.swift b/Mastodon/Scene/Compose/TableViewCell/ComposeStatusPollTableViewCell.swift index f1a1693a2..ac8d5094f 100644 --- a/Mastodon/Scene/Compose/TableViewCell/ComposeStatusPollTableViewCell.swift +++ b/Mastodon/Scene/Compose/TableViewCell/ComposeStatusPollTableViewCell.swift @@ -5,13 +5,17 @@ // Created by MainasuK Cirno on 2021-6-29. // +import os.log import UIKit +import Combine protocol ComposeStatusPollTableViewCellDelegate: AnyObject { func composeStatusPollTableViewCell(_ cell: ComposeStatusPollTableViewCell, pollOptionAttributesDidReorder options: [ComposeStatusPollItem.PollOptionAttribute]) } final class ComposeStatusPollTableViewCell: UITableViewCell { + + let logger = Logger(subsystem: "ComposeStatusPollTableViewCell", category: "UI") private(set) var dataSource: UICollectionViewDiffableDataSource! var observations = Set() @@ -43,8 +47,10 @@ final class ComposeStatusPollTableViewCell: UITableViewCell { collectionView.backgroundColor = .clear collectionView.alwaysBounceVertical = true collectionView.isScrollEnabled = false + collectionView.dragInteractionEnabled = true return collectionView }() + let collectionViewHeightDidUpdate = PassthroughSubject() override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) @@ -75,13 +81,10 @@ extension ComposeStatusPollTableViewCell { collectionViewHeightLayoutConstraint, ]) - let longPressReorderGesture = UILongPressGestureRecognizer(target: self, action: #selector(ComposeStatusPollTableViewCell.longPressReorderGestureHandler(_:))) - collectionView.addGestureRecognizer(longPressReorderGesture) - collectionView.observe(\.contentSize, options: [.initial, .new]) { [weak self] collectionView, _ in guard let self = self else { return } - print(collectionView.contentSize) self.collectionViewHeightLayoutConstraint.constant = collectionView.contentSize.height + self.collectionViewHeightDidUpdate.send() } .store(in: &observations) @@ -122,66 +125,84 @@ extension ComposeStatusPollTableViewCell { return cell } } - - dataSource.reorderingHandlers.canReorderItem = { item in - switch item { - case .pollOption: return true - default: return false - } - } - - // update reordered data source - dataSource.reorderingHandlers.didReorder = { [weak self] transaction in - guard let self = self else { return } - - let items = transaction.finalSnapshot.itemIdentifiers - var pollOptionAttributes: [ComposeStatusPollItem.PollOptionAttribute] = [] - for item in items { - guard case let .pollOption(attribute) = item else { continue } - pollOptionAttributes.append(attribute) - } - self.delegate?.composeStatusPollTableViewCell(self, pollOptionAttributesDidReorder: pollOptionAttributes) - } + + collectionView.dragDelegate = self + collectionView.dropDelegate = self } } -extension ComposeStatusPollTableViewCell { - - @objc private func longPressReorderGestureHandler(_ sender: UILongPressGestureRecognizer) { - switch(sender.state) { - case .began: - guard let selectedIndexPath = collectionView.indexPathForItem(at: sender.location(in: collectionView)), - let cell = collectionView.cellForItem(at: selectedIndexPath) as? ComposeStatusPollOptionCollectionViewCell else { - break - } - // check if pressing reorder bar no not - let locationInCell = sender.location(in: cell) - guard cell.reorderBarImageView.frame.contains(locationInCell) else { - return - } - - collectionView.beginInteractiveMovementForItem(at: selectedIndexPath) - case .changed: - guard let selectedIndexPath = collectionView.indexPathForItem(at: sender.location(in: collectionView)), - let dataSource = self.dataSource else { - break - } - guard let item = dataSource.itemIdentifier(for: selectedIndexPath), - case .pollOption = item else { - collectionView.cancelInteractiveMovement() - return - } - - var position = sender.location(in: collectionView) - position.x = collectionView.frame.width * 0.5 - collectionView.updateInteractiveMovementTargetPosition(position) - case .ended: - collectionView.endInteractiveMovement() - collectionView.reloadData() +// MARK: - UICollectionViewDragDelegate +extension ComposeStatusPollTableViewCell: UICollectionViewDragDelegate { + + func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { + guard let item = dataSource.itemIdentifier(for: indexPath) else { return [] } + switch item { + case .pollOption: + let itemProvider = NSItemProvider(object: String(item.hashValue) as NSString) + let dragItem = UIDragItem(itemProvider: itemProvider) + dragItem.localObject = item + return [dragItem] default: - collectionView.cancelInteractiveMovement() + return [] } } - + + func collectionView(_ collectionView: UICollectionView, dragSessionIsRestrictedToDraggingApplication session: UIDragSession) -> Bool { + // drag to app should be the same app + return true + } +} + +// MARK: - UICollectionViewDropDelegate +extension ComposeStatusPollTableViewCell: UICollectionViewDropDelegate { + // didUpdate + func collectionView(_ collectionView: UICollectionView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UICollectionViewDropProposal { + guard collectionView.hasActiveDrag, + let destinationIndexPath = destinationIndexPath, + let item = dataSource.itemIdentifier(for: destinationIndexPath) + else { + return UICollectionViewDropProposal(operation: .forbidden) + } + + switch item { + case .pollOption: + return UICollectionViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath) + default: + return UICollectionViewDropProposal(operation: .cancel) + } + } + + // performDrop + func collectionView(_ collectionView: UICollectionView, performDropWith coordinator: UICollectionViewDropCoordinator) { + guard let dropItem = coordinator.items.first, + let item = dropItem.dragItem.localObject as? ComposeStatusPollItem, + case .pollOption = item + else { return } + + guard coordinator.proposal.operation == .move else { return } + guard let destinationIndexPath = coordinator.destinationIndexPath, + let _ = collectionView.cellForItem(at: destinationIndexPath) as? ComposeStatusPollOptionCollectionViewCell + else { return } + + var snapshot = dataSource.snapshot() + guard destinationIndexPath.row < snapshot.itemIdentifiers.count else { return } + let anchorItem = snapshot.itemIdentifiers[destinationIndexPath.row] + snapshot.moveItem(item, afterItem: anchorItem) + dataSource.apply(snapshot) + + coordinator.drop(dropItem.dragItem, toItemAt: destinationIndexPath) + } +} + +extension ComposeStatusPollTableViewCell: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, targetIndexPathForMoveFromItemAt originalIndexPath: IndexPath, toProposedIndexPath proposedIndexPath: IndexPath) -> IndexPath { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): \(originalIndexPath.debugDescription) -> \(proposedIndexPath.debugDescription)") + + guard let _ = collectionView.cellForItem(at: proposedIndexPath) as? ComposeStatusPollOptionCollectionViewCell else { + return originalIndexPath + } + + return proposedIndexPath + } } diff --git a/Mastodon/Scene/Compose/View/ComposeToolbarView.swift b/Mastodon/Scene/Compose/View/ComposeToolbarView.swift index 99fe88ce7..6b06973a2 100644 --- a/Mastodon/Scene/Compose/View/ComposeToolbarView.swift +++ b/Mastodon/Scene/Compose/View/ComposeToolbarView.swift @@ -11,11 +11,11 @@ import Combine import MastodonSDK protocol ComposeToolbarViewDelegate: AnyObject { - func composeToolbarView(_ composeToolbarView: ComposeToolbarView, cameraButtonDidPressed sender: UIButton, mediaSelectionType type: ComposeToolbarView.MediaSelectionType) - func composeToolbarView(_ composeToolbarView: ComposeToolbarView, pollButtonDidPressed sender: UIButton) - func composeToolbarView(_ composeToolbarView: ComposeToolbarView, emojiButtonDidPressed sender: UIButton) - func composeToolbarView(_ composeToolbarView: ComposeToolbarView, contentWarningButtonDidPressed sender: UIButton) - func composeToolbarView(_ composeToolbarView: ComposeToolbarView, visibilityButtonDidPressed sender: UIButton, visibilitySelectionType type: ComposeToolbarView.VisibilitySelectionType) + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, cameraButtonDidPressed sender: Any, mediaSelectionType type: ComposeToolbarView.MediaSelectionType) + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, pollButtonDidPressed sender: Any) + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, emojiButtonDidPressed sender: Any) + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, contentWarningButtonDidPressed sender: Any) + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, visibilityButtonDidPressed sender: Any, visibilitySelectionType type: ComposeToolbarView.VisibilitySelectionType) } final class ComposeToolbarView: UIView { @@ -27,6 +27,37 @@ final class ComposeToolbarView: UIView { weak var delegate: ComposeToolbarViewDelegate? + // barButtonItem + private(set) lazy var mediaBarButtonItem: UIBarButtonItem = { + let barButtonItem = UIBarButtonItem() + barButtonItem.image = UIImage(systemName: "photo") + barButtonItem.accessibilityLabel = L10n.Scene.Compose.Accessibility.appendAttachment + return barButtonItem + }() + + let pollBarButtonItem: UIBarButtonItem = { + let barButtonItem = UIBarButtonItem() + barButtonItem.image = UIImage(systemName: "list.bullet") + barButtonItem.accessibilityLabel = L10n.Scene.Compose.Accessibility.appendPoll + return barButtonItem + }() + + let contentWarningBarButtonItem: UIBarButtonItem = { + let barButtonItem = UIBarButtonItem() + barButtonItem.image = UIImage(systemName: "exclamationmark.shield") + barButtonItem.accessibilityLabel = L10n.Scene.Compose.Accessibility.enableContentWarning + return barButtonItem + }() + + let visibilityBarButtonItem: UIBarButtonItem = { + let barButtonItem = UIBarButtonItem() + barButtonItem.image = UIImage(systemName: "person.3") + barButtonItem.accessibilityLabel = L10n.Scene.Compose.Accessibility.postVisibilityMenu + return barButtonItem + }() + + // button + let mediaButton: UIButton = { let button = HighlightDimmableButton() ComposeToolbarView.configureToolbarButtonAppearance(button: button) @@ -142,11 +173,17 @@ extension ComposeToolbarView { ]) characterCountLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) + mediaBarButtonItem.menu = createMediaContextMenu() mediaButton.menu = createMediaContextMenu() mediaButton.showsMenuAsPrimaryAction = true + pollBarButtonItem.target = self + pollBarButtonItem.action = #selector(ComposeToolbarView.pollButtonDidPressed(_:)) pollButton.addTarget(self, action: #selector(ComposeToolbarView.pollButtonDidPressed(_:)), for: .touchUpInside) emojiButton.addTarget(self, action: #selector(ComposeToolbarView.emojiButtonDidPressed(_:)), for: .touchUpInside) + contentWarningBarButtonItem.target = self + contentWarningBarButtonItem.action = #selector(ComposeToolbarView.contentWarningButtonDidPressed(_:)) contentWarningButton.addTarget(self, action: #selector(ComposeToolbarView.contentWarningButtonDidPressed(_:)), for: .touchUpInside) + visibilityBarButtonItem.menu = createVisibilityContextMenu(interfaceStyle: traitCollection.userInterfaceStyle) visibilityButton.menu = createVisibilityContextMenu(interfaceStyle: traitCollection.userInterfaceStyle) visibilityButton.showsMenuAsPrimaryAction = true @@ -157,6 +194,7 @@ extension ComposeToolbarView { .receive(on: RunLoop.main) .sink { [weak self] type in guard let self = self else { return } + self.visibilityBarButtonItem.menu = self.createVisibilityContextMenu(interfaceStyle: self.traitCollection.userInterfaceStyle) self.visibilityButton.menu = self.createVisibilityContextMenu(interfaceStyle: self.traitCollection.userInterfaceStyle) } .store(in: &disposeBag) @@ -224,7 +262,7 @@ extension ComposeToolbarView { } private static func configureToolbarButtonAppearance(button: UIButton) { - button.tintColor = Asset.Colors.brandBlue.color + button.tintColor = ThemeService.tintColor button.setBackgroundImage(.placeholder(size: ComposeToolbarView.toolbarButtonSize, color: .systemFill), for: .highlighted) button.layer.masksToBounds = true button.layer.cornerRadius = 5 @@ -240,17 +278,22 @@ extension ComposeToolbarView { switch traitCollection.userInterfaceStyle { case .light: + mediaBarButtonItem.image = UIImage(systemName: "photo") mediaButton.setImage(UIImage(systemName: "photo", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular))!, for: .normal) + contentWarningBarButtonItem.image = UIImage(systemName: "exclamationmark.shield") contentWarningButton.setImage(UIImage(systemName: "exclamationmark.shield", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular))!, for: .normal) case .dark: + mediaBarButtonItem.image = UIImage(systemName: "photo.fill") mediaButton.setImage(UIImage(systemName: "photo.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular))!, for: .normal) + contentWarningBarButtonItem.image = UIImage(systemName: "exclamationmark.shield.fill") contentWarningButton.setImage(UIImage(systemName: "exclamationmark.shield.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular))!, for: .normal) default: assertionFailure() } + visibilityBarButtonItem.menu = createVisibilityContextMenu(interfaceStyle: traitCollection.userInterfaceStyle) visibilityButton.menu = createVisibilityContextMenu(interfaceStyle: traitCollection.userInterfaceStyle) } @@ -296,17 +339,17 @@ extension ComposeToolbarView { extension ComposeToolbarView { - @objc private func pollButtonDidPressed(_ sender: UIButton) { + @objc private func pollButtonDidPressed(_ sender: Any) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) delegate?.composeToolbarView(self, pollButtonDidPressed: sender) } - @objc private func emojiButtonDidPressed(_ sender: UIButton) { + @objc private func emojiButtonDidPressed(_ sender: Any) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) delegate?.composeToolbarView(self, emojiButtonDidPressed: sender) } - @objc private func contentWarningButtonDidPressed(_ sender: UIButton) { + @objc private func contentWarningButtonDidPressed(_ sender: Any) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) delegate?.composeToolbarView(self, contentWarningButtonDidPressed: sender) } diff --git a/Mastodon/Scene/Compose/View/ReplicaStatusView.swift b/Mastodon/Scene/Compose/View/ReplicaStatusView.swift index cb34f3ded..6f0527d55 100644 --- a/Mastodon/Scene/Compose/View/ReplicaStatusView.swift +++ b/Mastodon/Scene/Compose/View/ReplicaStatusView.swift @@ -45,9 +45,10 @@ final class ReplicaStatusView: UIView { return attributedString } - let headerIconLabel: UILabel = { - let label = UILabel() - label.attributedText = ReplicaStatusView.iconAttributedString(image: ReplicaStatusView.reblogIconImage) + let headerIconLabel: MetaLabel = { + let label = MetaLabel(style: .statusHeader) + let attributedString = StatusView.iconAttributedString(image: StatusView.reblogIconImage) + label.configure(attributedString: attributedString) return label }() diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift index 9801d701a..72f084fad 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift @@ -24,7 +24,7 @@ class HashtagTimelineViewController: UIViewController, NeedsDependency, MediaPre let composeBarButtonItem: UIBarButtonItem = { let barButtonItem = UIBarButtonItem() - barButtonItem.tintColor = Asset.Colors.brandBlue.color + // barButtonItem.tintColor = Asset.Colors.brandBlue.color barButtonItem.image = UIImage(systemName: "square.and.pencil")?.withRenderingMode(.alwaysTemplate) return barButtonItem }() diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift index 093258733..a601eb927 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift @@ -17,17 +17,11 @@ extension HashtagTimelineViewModel { statusTableViewCellDelegate: StatusTableViewCellDelegate, timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate ) { - let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common) - .autoconnect() - .share() - .eraseToAnyPublisher() - diffableDataSource = StatusSection.tableViewDiffableDataSource( for: tableView, timelineContext: .hashtag, dependency: dependency, managedObjectContext: context.managedObjectContext, - timestampUpdatePublisher: timestampUpdatePublisher, statusTableViewCellDelegate: statusTableViewCellDelegate, timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate, threadReplyLoaderTableViewCellDelegate: nil @@ -95,7 +89,7 @@ extension HashtagTimelineViewModel { } DispatchQueue.main.async { - diffableDataSource.apply(newSnapshot, animatingDifferences: false) { + diffableDataSource.reloadData(snapshot: newSnapshot) { tableView.scrollToRow(at: difference.targetIndexPath, at: .top, animated: false) tableView.contentOffset.y = tableView.contentOffset.y - difference.offset self.isFetchingLatestTimeline.value = false diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift index c1698f5dc..1bb76493a 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift @@ -85,16 +85,20 @@ final class HashtagTimelineViewModel: NSObject { return } let query = Mastodon.API.V2.Search.Query(q: hashtag, type: .hashtags) - context.apiService.search(domain: activeMastodonAuthenticationBox.domain, query: query, mastodonAuthenticationBox: activeMastodonAuthenticationBox) - .sink { _ in - - } receiveValue: { [weak self] response in - let matchedTag = response.value.hashtags.first { tag -> Bool in - return tag.name == self?.hashtag - } - self?.hashtagEntity.send(matchedTag) + context.apiService.search( + domain: activeMastodonAuthenticationBox.domain, + query: query, + mastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + .sink { _ in + + } receiveValue: { [weak self] response in + let matchedTag = response.value.hashtags.first { tag -> Bool in + return tag.name == self?.hashtag } - .store(in: &disposeBag) + self?.hashtagEntity.send(matchedTag) + } + .store(in: &disposeBag) } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift index a55f1ebf6..6e75a17e7 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift @@ -14,6 +14,8 @@ import CoreDataStack import FLEX import SwiftUI import MastodonUI +import MastodonSDK +import StoreKit extension HomeTimelineViewController { var debugMenu: UIMenu { @@ -23,40 +25,11 @@ extension HomeTimelineViewController { identifier: nil, options: .displayInline, children: [ - UIAction(title: "Show FLEX", image: nil, attributes: [], handler: { [weak self] action in - guard let self = self else { return } - self.showFLEXAction(action) - }), + showMenu, moveMenu, dropMenu, - UIAction(title: "Show Welcome", image: UIImage(systemName: "figure.walk"), attributes: []) { [weak self] action in - guard let self = self else { return } - self.showWelcomeAction(action) - }, - UIAction(title: "Show Confirm Email", image: UIImage(systemName: "envelope"), attributes: []) { [weak self] action in - guard let self = self else { return } - self.showConfirmEmail(action) - }, - UIAction(title: "Toggle EmptyView", image: UIImage(systemName: "clear"), attributes: []) { [weak self] action in - guard let self = self else { return } - if self.emptyView.superview != nil { - self.emptyView.removeFromSuperview() - } else { - self.showEmptyView() - } - }, - UIAction(title: "Show Public Timeline", image: UIImage(systemName: "list.dash"), attributes: []) { [weak self] action in - guard let self = self else { return } - self.showPublicTimelineAction(action) - }, - UIAction(title: "Show Profile", image: UIImage(systemName: "person.crop.circle"), attributes: []) { [weak self] action in - guard let self = self else { return } - self.showProfileAction(action) - }, - UIAction(title: "Show Thread", image: UIImage(systemName: "bubble.left.and.bubble.right"), attributes: []) { [weak self] action in - guard let self = self else { return } - self.showThreadAction(action) - }, + miscMenu, + notificationMenu, UIAction(title: "Settings", image: UIImage(systemName: "gear"), attributes: []) { [weak self] action in guard let self = self else { return } self.showSettings(action) @@ -69,6 +42,50 @@ extension HomeTimelineViewController { ) return menu } + + var showMenu: UIMenu { + return UIMenu( + title: "Show…", + image: UIImage(systemName: "plus.rectangle.on.rectangle"), + identifier: nil, + options: [], + children: [ + UIAction(title: "FLEX", image: nil, attributes: [], handler: { [weak self] action in + guard let self = self else { return } + self.showFLEXAction(action) + }), + UIAction(title: "Welcome", image: UIImage(systemName: "figure.walk"), attributes: []) { [weak self] action in + guard let self = self else { return } + self.showWelcomeAction(action) + }, + UIAction(title: "Confirm Email", image: UIImage(systemName: "envelope"), attributes: []) { [weak self] action in + guard let self = self else { return } + self.showConfirmEmail(action) + }, + UIAction(title: "Account List", image: UIImage(systemName: "person"), attributes: []) { [weak self] action in + guard let self = self else { return } + self.showAccountList(action) + }, + UIAction(title: "Public Timeline", image: UIImage(systemName: "list.dash"), attributes: []) { [weak self] action in + guard let self = self else { return } + self.showPublicTimelineAction(action) + }, + UIAction(title: "Profile", image: UIImage(systemName: "person.crop.circle"), attributes: []) { [weak self] action in + guard let self = self else { return } + self.showProfileAction(action) + }, + UIAction(title: "Thread", image: UIImage(systemName: "bubble.left.and.bubble.right"), attributes: []) { [weak self] action in + guard let self = self else { return } + self.showThreadAction(action) + }, + UIAction(title: "Store Rating", image: UIImage(systemName: "star.fill"), attributes: []) { [weak self] action in + guard let self = self else { return } + guard let windowScene = self.view.window?.windowScene else { return } + SKStoreReviewController.requestReview(in: windowScene) + }, + ] + ) + } var moveMenu: UIMenu { return UIMenu( @@ -123,6 +140,68 @@ extension HomeTimelineViewController { } ) } + + var miscMenu: UIMenu { + return UIMenu( + title: "Debug…", + image: UIImage(systemName: "switch.2"), + identifier: nil, + options: [], + children: [ + UIAction(title: "Toggle EmptyView", image: UIImage(systemName: "clear"), attributes: []) { [weak self] action in + guard let self = self else { return } + if self.emptyView.superview != nil { + self.emptyView.removeFromSuperview() + } else { + self.showEmptyView() + } + }, + UIAction( + title: "Notification badge +1", + image: UIImage(systemName: "1.circle.fill"), + identifier: nil, + attributes: [], + state: .off, + handler: { [weak self] _ in + guard let self = self else { return } + guard let accessToken = self.context.authenticationService.activeMastodonAuthentication.value?.userAccessToken else { return } + UserDefaults.shared.increaseNotificationCount(accessToken: accessToken) + self.context.notificationService.applicationIconBadgeNeedsUpdate.send() + } + ), + UIAction( + title: "Enable account switcher wizard", + image: UIImage(systemName: "square.stack.3d.down.forward.fill"), + identifier: nil, + attributes: [], + state: .off, + handler: { _ in + UserDefaults.shared.didShowMultipleAccountSwitchWizard = false + } + ), + ] + ) + } + + var notificationMenu: UIMenu { + return UIMenu( + title: "Notification…", + image: UIImage(systemName: "bell.badge"), + identifier: nil, + options: [], + children: [ + UIAction(title: "Profile", image: UIImage(systemName: "person.badge.plus"), attributes: []) { [weak self] action in + guard let self = self else { return } + self.showNotification(action, notificationType: .follow) + }, + UIAction(title: "Status", image: UIImage(systemName: "list.bullet.rectangle"), attributes: []) { [weak self] action in + guard let self = self else { return } + self.showNotification(action, notificationType: .mention) + }, + ] + ) + } + } extension HomeTimelineViewController { @@ -321,6 +400,10 @@ extension HomeTimelineViewController { let mastodonConfirmEmailViewModel = MastodonConfirmEmailViewModel() coordinator.present(scene: .mastodonConfirmEmail(viewModel: mastodonConfirmEmailViewModel), from: nil, transition: .modal(animated: true, completion: nil)) } + + @objc private func showAccountList(_ sender: UIAction) { + coordinator.present(scene: .accountList, from: self, transition: .modal(animated: true, completion: nil)) + } @objc private func showPublicTimelineAction(_ sender: UIAction) { coordinator.present(scene: .publicTimeline, from: self, transition: .show) @@ -356,6 +439,63 @@ extension HomeTimelineViewController { coordinator.present(scene: .alertController(alertController: alertController), from: self, transition: .alertController(animated: true, completion: nil)) } + private func showNotification(_ sender: UIAction, notificationType: Mastodon.Entity.Notification.NotificationType) { + guard let authenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { return } + + let alertController = UIAlertController(title: "Enter notification ID", message: nil, preferredStyle: .alert) + alertController.addTextField() + + let showAction = UIAlertAction(title: "Show", style: .default) { [weak self, weak alertController] _ in + guard let self = self else { return } + guard let textField = alertController?.textFields?.first, + let text = textField.text, + let notificationID = Int(text) + else { return } + + let pushNotification = MastodonPushNotification( + _accessToken: authenticationBox.userAuthorization.accessToken, + notificationID: notificationID, + notificationType: notificationType.rawValue, + preferredLocale: nil, + icon: nil, + title: "", + body: "" + ) + self.context.notificationService.requestRevealNotificationPublisher.send(pushNotification) + } + alertController.addAction(showAction) + + // for multiple accounts debug + let boxes = self.context.authenticationService.mastodonAuthenticationBoxes.value // already sorted + if boxes.count >= 2 { + let accessToken = boxes[1].userAuthorization.accessToken + let showForSecondaryAction = UIAlertAction(title: "Show for Secondary", style: .default) { [weak self, weak alertController] _ in + guard let self = self else { return } + guard let textField = alertController?.textFields?.first, + let text = textField.text, + let notificationID = Int(text) + else { return } + + let pushNotification = MastodonPushNotification( + _accessToken: accessToken, + notificationID: notificationID, + notificationType: notificationType.rawValue, + preferredLocale: nil, + icon: nil, + title: "", + body: "" + ) + self.context.notificationService.requestRevealNotificationPublisher.send(pushNotification) + } + alertController.addAction(showForSecondaryAction) + } + + let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil) + alertController.addAction(cancelAction) + + self.coordinator.present(scene: .alertController(alertController: alertController), from: self, transition: .alertController(animated: true, completion: nil)) + } + @objc private func showSettings(_ sender: UIAction) { guard let currentSetting = context.settingService.currentSetting.value else { return } let settingsViewModel = SettingsViewModel(context: context, setting: currentSetting) diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index a08f162a8..62695f211 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -14,9 +14,12 @@ import CoreDataStack import GameplayKit import MastodonSDK import AlamofireImage +import StoreKit final class HomeTimelineViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { + let logger = Logger(subsystem: "HomeTimelineViewController", category: "UI") + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } @@ -44,14 +47,14 @@ final class HomeTimelineViewController: UIViewController, NeedsDependency, Media let settingBarButtonItem: UIBarButtonItem = { let barButtonItem = UIBarButtonItem() - barButtonItem.tintColor = Asset.Colors.brandBlue.color + barButtonItem.tintColor = ThemeService.tintColor barButtonItem.image = UIImage(systemName: "gear")?.withRenderingMode(.alwaysTemplate) return barButtonItem }() let composeBarButtonItem: UIBarButtonItem = { let barButtonItem = UIBarButtonItem() - barButtonItem.tintColor = Asset.Colors.brandBlue.color + barButtonItem.tintColor = ThemeService.tintColor barButtonItem.image = UIImage(systemName: "square.and.pencil")?.withRenderingMode(.alwaysTemplate) return barButtonItem }() @@ -95,7 +98,41 @@ extension HomeTimelineViewController { self.view.backgroundColor = theme.secondarySystemBackgroundColor } .store(in: &disposeBag) - navigationItem.leftBarButtonItem = settingBarButtonItem + viewModel.displaySettingBarButtonItem + .receive(on: DispatchQueue.main) + .sink { [weak self] displaySettingBarButtonItem in + guard let self = self else { return } + #if DEBUG + // display debug menu + self.navigationItem.leftBarButtonItem = { + let barButtonItem = UIBarButtonItem() + barButtonItem.image = UIImage(systemName: "ellipsis.circle") + barButtonItem.menu = self.debugMenu + return barButtonItem + }() + #else + self.navigationItem.leftBarButtonItem = displaySettingBarButtonItem ? self.settingBarButtonItem : nil + #endif + } + .store(in: &disposeBag) + #if DEBUG + // long press to trigger debug menu + settingBarButtonItem.menu = debugMenu + #else + settingBarButtonItem.target = self + settingBarButtonItem.action = #selector(HomeTimelineViewController.settingBarButtonItemPressed(_:)) + #endif + + viewModel.displayComposeBarButtonItem + .receive(on: DispatchQueue.main) + .sink { [weak self] displayComposeBarButtonItem in + guard let self = self else { return } + self.navigationItem.rightBarButtonItem = displayComposeBarButtonItem ? self.composeBarButtonItem : nil + } + .store(in: &disposeBag) + composeBarButtonItem.target = self + composeBarButtonItem.action = #selector(HomeTimelineViewController.composeBarButtonItemPressed(_:)) + navigationItem.titleView = titleView titleView.delegate = self @@ -108,17 +145,20 @@ extension HomeTimelineViewController { } .store(in: &disposeBag) - #if DEBUG - // long press to trigger debug menu - settingBarButtonItem.menu = debugMenu - #else - settingBarButtonItem.target = self - settingBarButtonItem.action = #selector(HomeTimelineViewController.settingBarButtonItemPressed(_:)) - #endif - - navigationItem.rightBarButtonItem = composeBarButtonItem - composeBarButtonItem.target = self - composeBarButtonItem.action = #selector(HomeTimelineViewController.composeBarButtonItemPressed(_:)) + viewModel.homeTimelineNavigationBarTitleViewModel.state + .removeDuplicates() + .filter { $0 == .publishedButton } + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + guard UserDefaults.shared.lastVersionPromptedForReview == nil else { return } + guard UserDefaults.shared.processCompletedCount > 3 else { return } + guard let windowScene = self.view.window?.windowScene else { return } + let version = UIApplication.appVersion() + UserDefaults.shared.lastVersionPromptedForReview = version + SKStoreReviewController.requestReview(in: windowScene) + } + .store(in: &disposeBag) tableView.refreshControl = refreshControl refreshControl.addTarget(self, action: #selector(HomeTimelineViewController.refreshControlValueChanged(_:)), for: .valueChanged) @@ -202,6 +242,31 @@ extension HomeTimelineViewController { } } .store(in: &disposeBag) + + NotificationCenter.default + .publisher(for: .statusBarTapped, object: nil) + .throttle(for: 0.5, scheduler: DispatchQueue.main, latest: false) + .sink { [weak self] notification in + guard let self = self else { return } + guard let _ = self.view.window else { return } // displaying + + // https://developer.limneos.net/index.php?ios=13.1.3&framework=UIKitCore.framework&header=UIStatusBarTapAction.h + guard let action = notification.object as AnyObject?, + let xPosition = action.value(forKey: "xPosition") as? Double + else { return } + + let viewFrameInWindow = self.view.convert(self.view.frame, to: nil) + guard xPosition >= viewFrameInWindow.minX && xPosition <= viewFrameInWindow.maxX else { return } + + // works on iOS 14 + self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): receive notification \(xPosition)") + + // check if scroll to top + guard self.shouldRestoreScrollPosition() else { return } + self.restorePositionWhenScrollToTop() + } + .store(in: &disposeBag) + } override func viewWillAppear(_ animated: Bool) { @@ -218,9 +283,15 @@ extension HomeTimelineViewController { viewModel.viewDidAppear.send() - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in - guard let self = self else { return } - // always try to refresh timeline after appear + if let timestamp = viewModel.lastAutomaticFetchTimestamp.value { + let now = Date() + if now.timeIntervalSince(timestamp) > 60 { + self.viewModel.lastAutomaticFetchTimestamp.value = now + self.viewModel.homeTimelineNeedRefresh.send() + } else { + // do nothing + } + } else { self.viewModel.homeTimelineNeedRefresh.send() } } @@ -287,8 +358,6 @@ extension HomeTimelineViewController { emptyView.addArrangedSubview(friendsAssetImageView) emptyView.addArrangedSubview(bottomPaddingView) - friendsAssetImageView.isHidden = traitCollection.userInterfaceIdiom != .phone - topPaddingView.translatesAutoresizingMaskIntoConstraints = false bottomPaddingView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ @@ -378,9 +447,79 @@ extension HomeTimelineViewController: TableViewCellHeightCacheableContainer { // MARK: - UIScrollViewDelegate extension HomeTimelineViewController { func scrollViewDidScroll(_ scrollView: UIScrollView) { + switch scrollView { + case tableView: + aspectScrollViewDidScroll(scrollView) + viewModel.homeTimelineNavigationBarTitleViewModel.handleScrollViewDidScroll(scrollView) + default: + break + } + } + + func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool { + switch scrollView { + case tableView: + + let indexPath = IndexPath(row: 0, section: 0) + guard viewModel.diffableDataSource?.itemIdentifier(for: indexPath) != nil else { + return true + } + // save position + savePositionBeforeScrollToTop() + // override by custom scrollToRow + tableView.scrollToRow(at: indexPath, at: .top, animated: true) + return false + default: + assertionFailure() + return true + } + } + + private func savePositionBeforeScrollToTop() { + // check save action interval + // should not fast than 0.5s to prevent save when scrollToTop on-flying + if let record = viewModel.scrollPositionRecord.value { + let now = Date() + guard now.timeIntervalSince(record.timestamp) > 0.5 else { + // skip this save action + return + } + } - aspectScrollViewDidScroll(scrollView) - viewModel.homeTimelineNavigationBarTitleViewModel.handleScrollViewDidScroll(scrollView) + guard let diffableDataSource = viewModel.diffableDataSource else { return } + guard let anchorIndexPaths = tableView.indexPathsForVisibleRows?.sorted() else { return } + guard !anchorIndexPaths.isEmpty else { return } + let anchorIndexPath = anchorIndexPaths[anchorIndexPaths.count / 2] + guard let anchorItem = diffableDataSource.itemIdentifier(for: anchorIndexPath) else { return } + + let offset: CGFloat = { + guard let anchorCell = tableView.cellForRow(at: anchorIndexPath) else { return 0 } + let cellFrameInView = tableView.convert(anchorCell.frame, to: view) + return cellFrameInView.origin.y + }() + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): save position record for \(anchorIndexPath) with offset: \(offset)") + viewModel.scrollPositionRecord.value = HomeTimelineViewModel.ScrollPositionRecord( + item: anchorItem, + offset: offset, + timestamp: Date() + ) + } + + private func shouldRestoreScrollPosition() -> Bool { + // check if scroll to top + guard self.tableView.safeAreaInsets.top > 0 else { return false } + let zeroOffset = -self.tableView.safeAreaInsets.top + return abs(self.tableView.contentOffset.y - zeroOffset) < 2.0 + } + + private func restorePositionWhenScrollToTop() { + guard let diffableDataSource = self.viewModel.diffableDataSource else { return } + guard let record = self.viewModel.scrollPositionRecord.value, + let indexPath = diffableDataSource.indexPath(for: record.item) + else { return } + + self.tableView.scrollToRow(at: indexPath, at: .middle, animated: true) + self.viewModel.scrollPositionRecord.value = nil } } @@ -528,6 +667,8 @@ extension HomeTimelineViewController: ScrollViewContainer { } else { let indexPath = IndexPath(row: 0, section: 0) guard viewModel.diffableDataSource?.itemIdentifier(for: indexPath) != nil else { return } + // save position + savePositionBeforeScrollToTop() tableView.scrollToRow(at: indexPath, at: .top, animated: true) } } @@ -556,7 +697,12 @@ extension HomeTimelineViewController: StatusTableViewCellDelegate { // MARK: - HomeTimelineNavigationBarTitleViewDelegate extension HomeTimelineViewController: HomeTimelineNavigationBarTitleViewDelegate { func homeTimelineNavigationBarTitleView(_ titleView: HomeTimelineNavigationBarTitleView, logoButtonDidPressed sender: UIButton) { - scrollToTop(animated: true) + if shouldRestoreScrollPosition() { + restorePositionWhenScrollToTop() + } else { + savePositionBeforeScrollToTop() + scrollToTop(animated: true) + } } func homeTimelineNavigationBarTitleView(_ titleView: HomeTimelineNavigationBarTitleView, buttonDidPressed sender: UIButton) { @@ -565,6 +711,8 @@ extension HomeTimelineViewController: HomeTimelineNavigationBarTitleViewDelegate guard let diffableDataSource = viewModel.diffableDataSource else { return } let indexPath = IndexPath(row: 0, section: 0) guard diffableDataSource.itemIdentifier(for: indexPath) != nil else { return } + + savePositionBeforeScrollToTop() tableView.scrollToRow(at: indexPath, at: .top, animated: true) case .offlineButton: // TODO: retry diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift index d25f30aee..e87cab1c1 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift @@ -18,17 +18,11 @@ extension HomeTimelineViewModel { statusTableViewCellDelegate: StatusTableViewCellDelegate, timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate ) { - let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common) - .autoconnect() - .share() - .eraseToAnyPublisher() - diffableDataSource = StatusSection.tableViewDiffableDataSource( for: tableView, timelineContext: .home, dependency: dependency, managedObjectContext: fetchedResultsController.managedObjectContext, - timestampUpdatePublisher: timestampUpdatePublisher, statusTableViewCellDelegate: statusTableViewCellDelegate, timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate, threadReplyLoaderTableViewCellDelegate: nil @@ -125,7 +119,7 @@ extension HomeTimelineViewModel: NSFetchedResultsControllerDelegate { return } - diffableDataSource.apply(newSnapshot, animatingDifferences: false) { + diffableDataSource.reloadData(snapshot: newSnapshot) { tableView.scrollToRow(at: difference.targetIndexPath, at: .top, animated: false) tableView.contentOffset.y = tableView.contentOffset.y - difference.offset self.isFetchingLatestTimeline.value = false diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift index 0bf1e1041..c4681b40b 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift @@ -28,6 +28,10 @@ final class HomeTimelineViewModel: NSObject { let isFetchingLatestTimeline = CurrentValueSubject(false) let viewDidAppear = PassthroughSubject() let homeTimelineNavigationBarTitleViewModel: HomeTimelineNavigationBarTitleViewModel + let lastAutomaticFetchTimestamp = CurrentValueSubject(nil) + let scrollPositionRecord = CurrentValueSubject(nil) + let displaySettingBarButtonItem = CurrentValueSubject(true) + let displayComposeBarButtonItem = CurrentValueSubject(true) weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate? weak var tableView: UITableView? @@ -68,7 +72,6 @@ final class HomeTimelineViewModel: NSObject { let loadMiddleSateMachineList = CurrentValueSubject<[NSManagedObjectID: GKStateMachine], Never>([:]) // TimelineIndex.objectID : middle loading state machine var diffableDataSource: UITableViewDiffableDataSource? var cellFrameCache = NSCache() - init(context: AppContext) { self.context = context @@ -153,3 +156,12 @@ final class HomeTimelineViewModel: NSObject { } extension HomeTimelineViewModel: SuggestionAccountViewModelDelegate { } + + +extension HomeTimelineViewModel { + struct ScrollPositionRecord { + let item: Item + let offset: CGFloat + let timestamp: Date + } +} diff --git a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift index 29ddc206a..88beda0f5 100644 --- a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift +++ b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift @@ -55,6 +55,7 @@ extension MediaPreviewViewController { overrideUserInterfaceStyle = .dark visualEffectView.frame = view.bounds + visualEffectView.autoresizingMask = [.flexibleWidth, .flexibleHeight] view.addSubview(visualEffectView) pagingViewController.view.translatesAutoresizingMaskIntoConstraints = false diff --git a/Mastodon/Scene/Notification/NotificationViewController+StatusProvider.swift b/Mastodon/Scene/Notification/NotificationViewController+StatusProvider.swift index 127cca1b9..57272404e 100644 --- a/Mastodon/Scene/Notification/NotificationViewController+StatusProvider.swift +++ b/Mastodon/Scene/Notification/NotificationViewController+StatusProvider.swift @@ -19,21 +19,25 @@ extension NotificationViewController: StatusProvider { func status(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future { return Future { promise in - guard let cell = cell, - let diffableDataSource = self.viewModel.diffableDataSource, - let indexPath = self.tableView.indexPath(for: cell), + guard let diffableDataSource = self.viewModel.diffableDataSource else { + assertionFailure() + promise(.success(nil)) + return + } + guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }), let item = diffableDataSource.itemIdentifier(for: indexPath) else { promise(.success(nil)) return } switch item { - case .notification(let objectID, _): + case .notification(let objectID, _), + .notificationStatus(let objectID, _): self.viewModel.fetchedResultsController.managedObjectContext.perform { let notification = self.viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! MastodonNotification promise(.success(notification.status)) } - default: + case .bottomLoader: promise(.success(nil)) } } @@ -68,3 +72,6 @@ extension NotificationViewController: StatusProvider { } } + +// MARK: - UserProvider +extension NotificationViewController: UserProvider { } diff --git a/Mastodon/Scene/Notification/NotificationViewController.swift b/Mastodon/Scene/Notification/NotificationViewController.swift index 100cd3d82..0567d04dd 100644 --- a/Mastodon/Scene/Notification/NotificationViewController.swift +++ b/Mastodon/Scene/Notification/NotificationViewController.swift @@ -14,8 +14,10 @@ import OSLog import UIKit import Meta import MetaTextKit +import AVKit -final class NotificationViewController: UIViewController, NeedsDependency { +final class NotificationViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } @@ -23,15 +25,18 @@ final class NotificationViewController: UIViewController, NeedsDependency { var observations = Set() private(set) lazy var viewModel = NotificationViewModel(context: context) + + let mediaPreviewTransitionController = MediaPreviewTransitionController() let segmentControl: UISegmentedControl = { let control = UISegmentedControl(items: [L10n.Scene.Notification.Title.everything, L10n.Scene.Notification.Title.mentions]) - control.selectedSegmentIndex = NotificationViewModel.NotificationSegment.EveryThing.rawValue + control.selectedSegmentIndex = NotificationViewModel.NotificationSegment.everyThing.rawValue return control }() let tableView: UITableView = { let tableView = ControlContainableTableView() + tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.self)) tableView.register(NotificationStatusTableViewCell.self, forCellReuseIdentifier: String(describing: NotificationStatusTableViewCell.self)) tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) tableView.estimatedRowHeight = UITableView.automaticDimension @@ -42,6 +47,10 @@ final class NotificationViewController: UIViewController, NeedsDependency { }() let refreshControl = UIRefreshControl() + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } } extension NotificationViewController { @@ -78,7 +87,12 @@ extension NotificationViewController { tableView.delegate = self viewModel.tableView = tableView viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self - viewModel.setupDiffableDataSource(for: tableView, delegate: self, dependency: self) + viewModel.setupDiffableDataSource( + for: tableView, + dependency: self, + delegate: self, + statusTableViewCellDelegate: self + ) viewModel.viewDidLoad.send() // bind refresh control @@ -124,9 +138,9 @@ extension NotificationViewController { self.viewModel.needsScrollToTopAfterDataSourceUpdate = true switch segment { - case .EveryThing: + case .everyThing: self.viewModel.notificationPredicate.value = MastodonNotification.predicate(domain: domain, userID: userID) - case .Mentions: + case .mentions: self.viewModel.notificationPredicate.value = MastodonNotification.predicate(domain: domain, userID: userID, typeRaw: Mastodon.Entity.Notification.NotificationType.mention.rawValue) } } @@ -144,8 +158,8 @@ extension NotificationViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - - tableView.deselectRow(with: transitionCoordinator, animated: animated) + + aspectViewWillAppear(animated) // fetch latest notification when scroll position is within half screen height to prevent list reload if tableView.contentOffset.y < view.frame.height * 0.5 { @@ -166,6 +180,22 @@ extension NotificationViewController { self.viewModel.loadLatestStateMachine.enter(NotificationViewModel.LoadLatestState.Loading.self) } } + + // reset notification count + context.notificationService.clearNotificationCountForActiveUser() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + // reset notification count + context.notificationService.clearNotificationCountForActiveUser() + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + aspectViewDidDisappear(animated) } override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { @@ -194,33 +224,34 @@ extension NotificationViewController { } } -// MARK: - StatusTableViewControllerAspect -extension NotificationViewController: StatusTableViewControllerAspect { } - -extension NotificationViewController { - +// MARK: - TableViewCellHeightCacheableContainer +extension NotificationViewController: TableViewCellHeightCacheableContainer { + var cellFrameCache: NSCache { return viewModel.cellFrameCache } + func cacheTableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { guard let diffableDataSource = viewModel.diffableDataSource else { return } guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } switch item { - case .notification(let objectID, _): + case .notification(let objectID, _), + .notificationStatus(let objectID, _): guard let object = try? viewModel.fetchedResultsController.managedObjectContext.existingObject(with: objectID) as? MastodonNotification else { return } - let key = object.id as NSString + let key = object.objectID.hashValue let frame = cell.frame - viewModel.cellFrameCache.setObject(NSValue(cgRect: frame), forKey: key) + viewModel.cellFrameCache.setObject(NSValue(cgRect: frame), forKey: NSNumber(value: key)) case .bottomLoader: break } } - + func handleTableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { guard let diffableDataSource = viewModel.diffableDataSource else { return UITableView.automaticDimension } guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return UITableView.automaticDimension } switch item { - case .notification(let objectID, _): + case .notification(let objectID, _), + .notificationStatus(let objectID, _): guard let object = try? viewModel.fetchedResultsController.managedObjectContext.existingObject(with: objectID) as? MastodonNotification else { return UITableView.automaticDimension } - let key = object.id as NSString - guard let frame = viewModel.cellFrameCache.object(forKey: key)?.cgRectValue else { return UITableView.automaticDimension } + let key = object.objectID.hashValue + guard let frame = viewModel.cellFrameCache.object(forKey: NSNumber(value: key))?.cgRectValue else { return UITableView.automaticDimension } return frame.height case .bottomLoader: return TimelineLoaderTableViewCell.cellHeight @@ -228,22 +259,55 @@ extension NotificationViewController { } } + +// MARK: - StatusTableViewControllerAspect +extension NotificationViewController: StatusTableViewControllerAspect { } + // MARK: - UITableViewDelegate extension NotificationViewController: UITableViewDelegate { - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { + aspectTableView(tableView, estimatedHeightForRowAt: indexPath) + } + + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { guard let diffableDataSource = viewModel.diffableDataSource else { return } guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } - open(item: item) + switch item { + case .notificationStatus: + aspectTableView(tableView, willDisplay: cell, forRowAt: indexPath) + case .bottomLoader: + if !tableView.isDragging, !tableView.isDecelerating { + viewModel.loadOldestStateMachine.enter(NotificationViewModel.LoadOldestState.Loading.self) + } + default: + break + } } func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { - cacheTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) + aspectTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + aspectTableView(tableView, didSelectRowAt: indexPath) } - func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { - return handleTableView(tableView, estimatedHeightForRowAt: indexPath) + func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { + return aspectTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point) + } + + func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + return aspectTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration) + } + + func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + return aspectTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration) + } + + func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { + aspectTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator) } } @@ -264,19 +328,6 @@ extension NotificationViewController { break } } - - func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { - guard let diffableDataSource = viewModel.diffableDataSource else { return } - guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } - switch item { - case .bottomLoader: - if !tableView.isDragging, !tableView.isDecelerating { - viewModel.loadOldestStateMachine.enter(NotificationViewModel.LoadOldestState.Loading.self) - } - default: - break - } - } } // MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate @@ -374,6 +425,7 @@ extension NotificationViewController: ScrollViewContainer { } } +// MARK: - LoadMoreConfigurableTableViewContainer extension NotificationViewController: LoadMoreConfigurableTableViewContainer { typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell typealias LoadingState = NotificationViewModel.LoadOldestState.Loading @@ -381,6 +433,24 @@ extension NotificationViewController: LoadMoreConfigurableTableViewContainer { var loadMoreConfigurableStateMachine: GKStateMachine { viewModel.loadOldestStateMachine } } +// MARK: - AVPlayerViewControllerDelegate +extension NotificationViewController: AVPlayerViewControllerDelegate { + func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + handlePlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator) + } + + func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + handlePlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator) + } +} + +// MARK: - statusTableViewCellDelegate +extension NotificationViewController: StatusTableViewCellDelegate { + var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { + return self + } +} + extension NotificationViewController { enum CategorySwitch: String, CaseIterable { @@ -438,9 +508,9 @@ extension NotificationViewController { switch category { case .showEverything: - viewModel.selectedIndex.value = .EveryThing + viewModel.selectedIndex.value = .everyThing case .showMentions: - viewModel.selectedIndex.value = .Mentions + viewModel.selectedIndex.value = .mentions } } diff --git a/Mastodon/Scene/Notification/NotificationViewModel+Diffable.swift b/Mastodon/Scene/Notification/NotificationViewModel+Diffable.swift index 6a542bf2c..6c7a70e43 100644 --- a/Mastodon/Scene/Notification/NotificationViewModel+Diffable.swift +++ b/Mastodon/Scene/Notification/NotificationViewModel+Diffable.swift @@ -14,14 +14,16 @@ import MastodonSDK extension NotificationViewModel { func setupDiffableDataSource( for tableView: UITableView, + dependency: NeedsDependency, delegate: NotificationTableViewCellDelegate, - dependency: NeedsDependency + statusTableViewCellDelegate: StatusTableViewCellDelegate ) { diffableDataSource = NotificationSection.tableViewDiffableDataSource( for: tableView, + dependency: dependency, managedObjectContext: fetchedResultsController.managedObjectContext, delegate: delegate, - dependency: dependency + statusTableViewCellDelegate: statusTableViewCellDelegate ) var snapshot = NSDiffableDataSourceSnapshot() @@ -81,11 +83,23 @@ extension NotificationViewModel: NSFetchedResultsControllerDelegate { } var newSnapshot = NSDiffableDataSourceSnapshot() newSnapshot.appendSections([.main]) - let items: [NotificationItem] = notifications.map { notification in - let attribute: Item.StatusAttribute = oldSnapshotAttributeDict[notification.objectID] ?? Item.StatusAttribute() - return NotificationItem.notification(objectID: notification.objectID, attribute: attribute) + + let segment = self.selectedIndex.value + switch segment { + case .everyThing: + let items: [NotificationItem] = notifications.map { notification in + let attribute: Item.StatusAttribute = oldSnapshotAttributeDict[notification.objectID] ?? Item.StatusAttribute() + return NotificationItem.notification(objectID: notification.objectID, attribute: attribute) + } + newSnapshot.appendItems(items, toSection: .main) + case .mentions: + let items: [NotificationItem] = notifications.map { notification in + let attribute: Item.StatusAttribute = oldSnapshotAttributeDict[notification.objectID] ?? Item.StatusAttribute() + return NotificationItem.notificationStatus(objectID: notification.objectID, attribute: attribute) + } + newSnapshot.appendItems(items, toSection: .main) } - newSnapshot.appendItems(items, toSection: .main) + if !notifications.isEmpty, self.noMoreNotification.value == false { newSnapshot.appendItems([.bottomLoader], toSection: .main) } diff --git a/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift b/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift index 04d33202f..dac7bb7d3 100644 --- a/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift +++ b/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift @@ -67,8 +67,6 @@ extension NotificationViewModel.LoadLatestState { viewModel.isFetchingLatestNotification.value = false os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch notification failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) case .finished: - // toggle unread state - viewModel.context.notificationService.hasUnreadPushNotification.value = false // handle isFetchingLatestTimeline in fetch controller delegate break } diff --git a/Mastodon/Scene/Notification/NotificationViewModel+LoadOldestState.swift b/Mastodon/Scene/Notification/NotificationViewModel+LoadOldestState.swift index 9567d6cbb..bf2c03174 100644 --- a/Mastodon/Scene/Notification/NotificationViewModel+LoadOldestState.swift +++ b/Mastodon/Scene/Notification/NotificationViewModel+LoadOldestState.swift @@ -92,13 +92,13 @@ extension NotificationViewModel.LoadOldestState { } receiveValue: { [weak viewModel] response in guard let viewModel = viewModel else { return } switch viewModel.selectedIndex.value { - case .EveryThing: + case .everyThing: if response.value.isEmpty { stateMachine.enter(NoMore.self) } else { stateMachine.enter(Idle.self) } - case .Mentions: + case .mentions: viewModel.noMoreNotification.value = response.value.isEmpty let list = response.value.filter { $0.type == Mastodon.Entity.Notification.NotificationType.mention } if list.isEmpty { diff --git a/Mastodon/Scene/Notification/NotificationViewModel.swift b/Mastodon/Scene/Notification/NotificationViewModel.swift index 712380917..98b7deec3 100644 --- a/Mastodon/Scene/Notification/NotificationViewModel.swift +++ b/Mastodon/Scene/Notification/NotificationViewModel.swift @@ -23,13 +23,13 @@ final class NotificationViewModel: NSObject { weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate? let viewDidLoad = PassthroughSubject() - let selectedIndex = CurrentValueSubject(.EveryThing) + let selectedIndex = CurrentValueSubject(.everyThing) let noMoreNotification = CurrentValueSubject(false) let activeMastodonAuthenticationBox: CurrentValueSubject let fetchedResultsController: NSFetchedResultsController! let notificationPredicate = CurrentValueSubject(nil) - let cellFrameCache = NSCache() + let cellFrameCache = NSCache() var needsScrollToTopAfterDataSourceUpdate = false let dataSourceDidUpdated = PassthroughSubject() @@ -161,7 +161,7 @@ final class NotificationViewModel: NSObject { extension NotificationViewModel { enum NotificationSegment: Int { - case EveryThing - case Mentions + case everyThing + case mentions } } diff --git a/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewController.swift b/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewController.swift index 9e9235baf..0718938f6 100644 --- a/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewController.swift +++ b/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewController.swift @@ -20,6 +20,8 @@ final class MastodonConfirmEmailViewController: UIViewController, NeedsDependenc var viewModel: MastodonConfirmEmailViewModel! + let stackView = UIStackView() + let largeTitleLabel: UILabel = { let label = UILabel() label.font = UIFontMetrics(forTextStyle: .largeTitle).scaledFont(for: UIFont.systemFont(ofSize: 34, weight: .bold)) @@ -72,9 +74,10 @@ extension MastodonConfirmEmailViewController { override func viewDidLoad() { setupOnboardingAppearance() + configureTitleLabel() + configureMargin() // stackView - let stackView = UIStackView() stackView.axis = .vertical stackView.distribution = .fill stackView.spacing = 10 @@ -92,8 +95,8 @@ extension MastodonConfirmEmailViewController { stackView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ stackView.topAnchor.constraint(equalTo: view.readableContentGuide.topAnchor), - stackView.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor), - stackView.trailingAnchor.constraint(equalTo: view.readableContentGuide.trailingAnchor), + stackView.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor), stackView.bottomAnchor.constraint(equalTo: view.readableContentGuide.bottomAnchor), ]) NSLayoutConstraint.activate([ @@ -131,13 +134,46 @@ extension MastodonConfirmEmailViewController { } } receiveValue: { response in os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: user %s's email confirmed", ((#file as NSString).lastPathComponent), #line, #function, response.value.username) - self.dismiss(animated: true, completion: nil) + self.coordinator.setup() + // self.dismiss(animated: true, completion: nil) } .store(in: &self.disposeBag) } .store(in: &self.disposeBag) } + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + configureTitleLabel() + configureMargin() + } + +} + +extension MastodonConfirmEmailViewController { + private func configureTitleLabel() { + switch traitCollection.horizontalSizeClass { + case .regular: + navigationItem.largeTitleDisplayMode = .always + navigationItem.title = L10n.Scene.ConfirmEmail.title.replacingOccurrences(of: "\n", with: " ") + largeTitleLabel.isHidden = true + default: + navigationItem.largeTitleDisplayMode = .never + navigationItem.title = nil + largeTitleLabel.isHidden = false + } + } + + private func configureMargin() { + switch traitCollection.horizontalSizeClass { + case .regular: + let margin = MastodonConfirmEmailViewController.viewEdgeMargin + stackView.layoutMargins = UIEdgeInsets(top: 18, left: margin, bottom: 23, right: margin) + default: + stackView.layoutMargins = UIEdgeInsets(top: 10, left: 0, bottom: 23, right: 0) + } + } } extension MastodonConfirmEmailViewController { diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift index 70c1067f9..f3570c6c5 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift @@ -29,6 +29,8 @@ final class MastodonPickServerViewController: UIViewController, NeedsDependency private var expandServerDomainSet = Set() private let emptyStateView = PickServerEmptyStateView() + private var emptyStateViewLeadingLayoutConstraint: NSLayoutConstraint! + private var emptyStateViewTrailingLayoutConstraint: NSLayoutConstraint! let tableViewTopPaddingView = UIView() // fix empty state view background display when tableView bounce scrolling var tableViewTopPaddingViewHeightLayoutConstraint: NSLayoutConstraint! @@ -44,16 +46,22 @@ final class MastodonPickServerViewController: UIViewController, NeedsDependency tableView.backgroundColor = .clear tableView.keyboardDismissMode = .onDrag tableView.translatesAutoresizingMaskIntoConstraints = false + if #available(iOS 15.0, *) { + tableView.sectionHeaderTopPadding = .leastNonzeroMagnitude + } else { + // Fallback on earlier versions + } return tableView }() + let buttonContainer = UIView() let nextStepButton: PrimaryActionButton = { let button = PrimaryActionButton() button.setTitle(L10n.Common.Controls.Actions.signUp, for: .normal) button.translatesAutoresizingMaskIntoConstraints = false return button }() - var nextStepButtonBottomLayoutConstraint: NSLayoutConstraint! + var buttonContainerBottomLayoutConstraint: NSLayoutConstraint! var mastodonAuthenticationController: MastodonAuthenticationController? @@ -72,6 +80,8 @@ extension MastodonPickServerViewController { setupOnboardingAppearance() defer { setupNavigationBarBackgroundView() } + configureTitleLabel() + configureMargin() #if DEBUG navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "ellipsis.circle"), style: .plain, target: nil, action: nil) @@ -84,14 +94,24 @@ extension MastodonPickServerViewController { navigationItem.rightBarButtonItem?.menu = UIMenu(title: "Debug Tool", image: nil, identifier: nil, options: [], children: children) #endif - view.addSubview(nextStepButton) - nextStepButtonBottomLayoutConstraint = view.bottomAnchor.constraint(equalTo: nextStepButton.bottomAnchor, constant: 0).priority(.defaultHigh) + buttonContainer.translatesAutoresizingMaskIntoConstraints = false + buttonContainer.preservesSuperviewLayoutMargins = true + view.addSubview(buttonContainer) + buttonContainerBottomLayoutConstraint = view.bottomAnchor.constraint(equalTo: buttonContainer.bottomAnchor, constant: 0).priority(.defaultHigh) NSLayoutConstraint.activate([ - nextStepButton.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor, constant: MastodonPickServerViewController.actionButtonMargin), - view.readableContentGuide.trailingAnchor.constraint(equalTo: nextStepButton.trailingAnchor, constant: MastodonPickServerViewController.actionButtonMargin), + buttonContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor), + buttonContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor), + view.safeAreaLayoutGuide.bottomAnchor.constraint(greaterThanOrEqualTo: buttonContainer.bottomAnchor, constant: WelcomeViewController.viewBottomPaddingHeight), + buttonContainerBottomLayoutConstraint, + ]) + + view.addSubview(nextStepButton) + NSLayoutConstraint.activate([ + nextStepButton.topAnchor.constraint(equalTo: buttonContainer.topAnchor), + nextStepButton.leadingAnchor.constraint(equalTo: buttonContainer.layoutMarginsGuide.leadingAnchor), + buttonContainer.layoutMarginsGuide.trailingAnchor.constraint(equalTo: nextStepButton.trailingAnchor), + nextStepButton.bottomAnchor.constraint(equalTo: buttonContainer.bottomAnchor), nextStepButton.heightAnchor.constraint(equalToConstant: MastodonPickServerViewController.actionButtonHeight).priority(.defaultHigh), - view.safeAreaLayoutGuide.bottomAnchor.constraint(greaterThanOrEqualTo: nextStepButton.bottomAnchor, constant: WelcomeViewController.viewBottomPaddingHeight), - nextStepButtonBottomLayoutConstraint, ]) // fix AutoLayout warning when observe before view appear @@ -122,16 +142,18 @@ extension MastodonPickServerViewController { tableView.topAnchor.constraint(equalTo: view.topAnchor), tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - nextStepButton.topAnchor.constraint(equalTo: tableView.bottomAnchor, constant: 7), + buttonContainer.topAnchor.constraint(equalTo: tableView.bottomAnchor, constant: 7), ]) emptyStateView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(emptyStateView) + emptyStateViewLeadingLayoutConstraint = emptyStateView.leadingAnchor.constraint(equalTo: tableView.leadingAnchor) + emptyStateViewTrailingLayoutConstraint = tableView.trailingAnchor.constraint(equalTo: emptyStateView.trailingAnchor) NSLayoutConstraint.activate([ emptyStateView.topAnchor.constraint(equalTo: view.topAnchor), - emptyStateView.leadingAnchor.constraint(equalTo: tableView.readableContentGuide.leadingAnchor), - emptyStateView.trailingAnchor.constraint(equalTo: tableView.readableContentGuide.trailingAnchor), - nextStepButton.topAnchor.constraint(equalTo: emptyStateView.bottomAnchor, constant: 21), + emptyStateViewLeadingLayoutConstraint, + emptyStateViewTrailingLayoutConstraint, + buttonContainer.topAnchor.constraint(equalTo: emptyStateView.bottomAnchor, constant: 21), ]) view.sendSubviewToBack(emptyStateView) @@ -149,18 +171,18 @@ extension MastodonPickServerViewController { // guard external keyboard connected guard isShow, state == .dock, GCKeyboard.coalesced != nil else { - self.nextStepButtonBottomLayoutConstraint.constant = WelcomeViewController.viewBottomPaddingHeight + self.buttonContainerBottomLayoutConstraint.constant = WelcomeViewController.viewBottomPaddingHeight return } let externalKeyboardToolbarHeight = self.view.frame.maxY - endFrame.minY guard externalKeyboardToolbarHeight > 0 else { - self.nextStepButtonBottomLayoutConstraint.constant = WelcomeViewController.viewBottomPaddingHeight + self.buttonContainerBottomLayoutConstraint.constant = WelcomeViewController.viewBottomPaddingHeight return } UIView.animate(withDuration: 0.3) { - self.nextStepButtonBottomLayoutConstraint.constant = externalKeyboardToolbarHeight + 16 + self.buttonContainerBottomLayoutConstraint.constant = externalKeyboardToolbarHeight + 16 self.view.layoutIfNeeded() } } @@ -222,7 +244,8 @@ extension MastodonPickServerViewController { assertionFailure(error.localizedDescription) case .success(let isActived): assert(isActived) - self.dismiss(animated: true, completion: nil) + // self.dismiss(animated: true, completion: nil) + self.coordinator.setup() } } .store(in: &disposeBag) @@ -268,9 +291,34 @@ extension MastodonPickServerViewController { viewModel.viewWillAppear.send() } + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + setupNavigationBarAppearance() + updateEmptyStateViewLayout() + configureTitleLabel() + configureMargin() + } } +extension MastodonPickServerViewController { + private func configureTitleLabel() { + guard UIDevice.current.userInterfaceIdiom == .pad else { + return + } + + switch traitCollection.horizontalSizeClass { + case .regular: + navigationItem.largeTitleDisplayMode = .always + navigationItem.title = L10n.Scene.ServerPicker.title.replacingOccurrences(of: "\n", with: " ") + default: + navigationItem.largeTitleDisplayMode = .never + navigationItem.title = nil + } + } +} + extension MastodonPickServerViewController { @objc @@ -420,31 +468,6 @@ extension MastodonPickServerViewController: UITableViewDelegate { } } - func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - let headerView = UIView() - headerView.backgroundColor = Asset.Theme.Mastodon.systemGroupedBackground.color - return headerView - } - - func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - guard let diffableDataSource = viewModel.diffableDataSource else { return 0 } - let sections = diffableDataSource.snapshot().sectionIdentifiers - let section = sections[section] - switch section { - case .header: - return 20 - case .category: - // Since category view has a blur shadow effect, its height need to be large than the actual height, - // Thus we reduce the section header's height by 10, and make the category cell height 60+20(10 inset for top and bottom) - return 10 - case .search: - // Same reason as above - return 10 - case .servers: - return 0 - } - } - func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { guard let diffableDataSource = viewModel.diffableDataSource else { return nil } guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return nil } @@ -503,6 +526,26 @@ extension MastodonPickServerViewController { let rectInTableView = tableView.rectForRow(at: indexPath) emptyStateView.topPaddingViewTopLayoutConstraint.constant = rectInTableView.maxY + + switch traitCollection.horizontalSizeClass { + case .regular: + emptyStateViewLeadingLayoutConstraint.constant = MastodonPickServerViewController.viewEdgeMargin + emptyStateViewTrailingLayoutConstraint.constant = MastodonPickServerViewController.viewEdgeMargin + default: + let margin = tableView.layoutMarginsGuide.layoutFrame.origin.x + emptyStateViewLeadingLayoutConstraint.constant = margin + emptyStateViewTrailingLayoutConstraint.constant = margin + } + } + + private func configureMargin() { + switch traitCollection.horizontalSizeClass { + case .regular: + let margin = MastodonPickServerViewController.viewEdgeMargin + buttonContainer.layoutMargins = UIEdgeInsets(top: 0, left: margin, bottom: 0, right: margin) + default: + buttonContainer.layoutMargins = .zero + } } } diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCategoriesCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCategoriesCell.swift index 8207ccdb9..659317752 100644 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCategoriesCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCategoriesCell.swift @@ -56,12 +56,13 @@ extension PickServerCategoriesCell { private func _init() { selectionStyle = .none backgroundColor = Asset.Theme.Mastodon.systemGroupedBackground.color + configureMargin() metricView.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(metricView) NSLayoutConstraint.activate([ - metricView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), - metricView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), + metricView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), + metricView.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), metricView.topAnchor.constraint(equalTo: contentView.topAnchor), metricView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), metricView.heightAnchor.constraint(equalToConstant: 80).priority(.defaultHigh), @@ -71,14 +72,20 @@ extension PickServerCategoriesCell { NSLayoutConstraint.activate([ collectionView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), collectionView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - collectionView.topAnchor.constraint(equalTo: contentView.topAnchor), - collectionView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + collectionView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10), + contentView.bottomAnchor.constraint(equalTo: collectionView.bottomAnchor, constant: 20), collectionView.heightAnchor.constraint(equalToConstant: 80).priority(.defaultHigh), ]) collectionView.delegate = self } + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + configureMargin() + } + override func layoutSubviews() { super.layoutSubviews() @@ -87,6 +94,18 @@ extension PickServerCategoriesCell { } +extension PickServerCategoriesCell { + private func configureMargin() { + switch traitCollection.horizontalSizeClass { + case .regular: + let margin = MastodonPickServerViewController.viewEdgeMargin + contentView.layoutMargins = UIEdgeInsets(top: 0, left: margin, bottom: 0, right: margin) + default: + contentView.layoutMargins = .zero + } + } +} + // MARK: - UICollectionViewDelegateFlowLayout extension PickServerCategoriesCell: UICollectionViewDelegateFlowLayout { diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift index ee2471878..2f60a5206 100644 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift @@ -198,6 +198,7 @@ extension PickServerCell { private func _init() { selectionStyle = .none backgroundColor = .clear + configureMargin() contentView.addSubview(containerView) containerView.addSubview(domainLabel) @@ -229,8 +230,8 @@ extension PickServerCell { NSLayoutConstraint.activate([ // Set background view containerView.topAnchor.constraint(equalTo: contentView.topAnchor), - containerView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), - contentView.readableContentGuide.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + containerView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), + contentView.layoutMarginsGuide.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), contentView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), // Set bottom separator @@ -291,6 +292,12 @@ extension PickServerCell { expandButton.addTarget(self, action: #selector(expandButtonDidPressed(_:)), for: .touchUpInside) } + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + configureMargin() + } + private func makeVerticalInfoStackView(arrangedView: UIView...) -> UIStackView { let stackView = UIStackView() stackView.translatesAutoresizingMaskIntoConstraints = false @@ -318,6 +325,18 @@ extension PickServerCell { } } +extension PickServerCell { + private func configureMargin() { + switch traitCollection.horizontalSizeClass { + case .regular: + let margin = MastodonPickServerViewController.viewEdgeMargin + contentView.layoutMargins = UIEdgeInsets(top: 0, left: margin, bottom: 0, right: margin) + default: + contentView.layoutMargins = .zero + } + } +} + extension PickServerCell { enum ExpandMode { diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerLoaderTableViewCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerLoaderTableViewCell.swift index 1b8264ec3..945ecac6a 100644 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerLoaderTableViewCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerLoaderTableViewCell.swift @@ -37,14 +37,16 @@ final class PickServerLoaderTableViewCell: TimelineLoaderTableViewCell { override func _init() { super._init() + configureMargin() + contentView.addSubview(containerView) contentView.addSubview(seperator) NSLayoutConstraint.activate([ // Set background view containerView.topAnchor.constraint(equalTo: contentView.topAnchor), - containerView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), - contentView.readableContentGuide.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + containerView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), + contentView.layoutMarginsGuide.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), contentView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: 1), // Set bottom separator @@ -67,6 +69,24 @@ final class PickServerLoaderTableViewCell: TimelineLoaderTableViewCell { activityIndicatorView.isHidden = false startAnimating() } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + configureMargin() + } +} + +extension PickServerLoaderTableViewCell { + private func configureMargin() { + switch traitCollection.horizontalSizeClass { + case .regular: + let margin = MastodonPickServerViewController.viewEdgeMargin + contentView.layoutMargins = UIEdgeInsets(top: 0, left: margin, bottom: 0, right: margin) + default: + contentView.layoutMargins = .zero + } + } } #if canImport(SwiftUI) && DEBUG diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift index fa3e3ae27..0a64103d2 100644 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift @@ -109,6 +109,7 @@ extension PickServerSearchCell { private func _init() { selectionStyle = .none backgroundColor = Asset.Theme.Mastodon.systemGroupedBackground.color + configureMargin() searchTextField.addTarget(self, action: #selector(textFieldDidChange(_:)), for: .editingChanged) searchTextField.delegate = self @@ -118,9 +119,9 @@ extension PickServerSearchCell { contentView.addSubview(searchTextField) NSLayoutConstraint.activate([ - bgView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), + bgView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), bgView.topAnchor.constraint(equalTo: contentView.topAnchor), - bgView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), + bgView.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), bgView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), textFieldBgView.leadingAnchor.constraint(equalTo: bgView.leadingAnchor, constant: 14), @@ -134,6 +135,24 @@ extension PickServerSearchCell { textFieldBgView.bottomAnchor.constraint(equalTo: searchTextField.bottomAnchor, constant: 4), ]) } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + configureMargin() + } +} + +extension PickServerSearchCell { + private func configureMargin() { + switch traitCollection.horizontalSizeClass { + case .regular: + let margin = MastodonPickServerViewController.viewEdgeMargin + contentView.layoutMargins = UIEdgeInsets(top: 0, left: margin, bottom: 0, right: margin) + default: + contentView.layoutMargins = .zero + } + } } extension PickServerSearchCell { diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerTitleCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerTitleCell.swift index 682ebbf30..f0d78eb41 100644 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerTitleCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerTitleCell.swift @@ -20,6 +20,8 @@ final class PickServerTitleCell: UITableViewCell { return label }() + var containerHeightLayoutConstraint: NSLayoutConstraint! + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) _init() @@ -36,13 +38,45 @@ extension PickServerTitleCell { private func _init() { selectionStyle = .none backgroundColor = Asset.Theme.Mastodon.systemGroupedBackground.color - - contentView.addSubview(titleLabel) + + let container = UIStackView() + container.axis = .vertical + container.translatesAutoresizingMaskIntoConstraints = false + containerHeightLayoutConstraint = container.heightAnchor.constraint(equalToConstant: .leastNonzeroMagnitude) + contentView.addSubview(container) NSLayoutConstraint.activate([ - titleLabel.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), - contentView.readableContentGuide.trailingAnchor.constraint(equalTo: titleLabel.trailingAnchor), - titleLabel.topAnchor.constraint(equalTo: contentView.topAnchor), - titleLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + container.topAnchor.constraint(equalTo: contentView.topAnchor), + container.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), + container.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), + container.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), ]) + + container.addArrangedSubview(titleLabel) + + configureTitleLabelDisplay() + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + configureTitleLabelDisplay() + } +} + +extension PickServerTitleCell { + private func configureTitleLabelDisplay() { + guard traitCollection.userInterfaceIdiom == .pad else { + titleLabel.isHidden = false + return + } + + switch traitCollection.horizontalSizeClass { + case .regular: + titleLabel.isHidden = true + containerHeightLayoutConstraint.isActive = true + default: + titleLabel.isHidden = false + containerHeightLayoutConstraint.isActive = false + } } } diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift index ffef3d872..8428aaa79 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift @@ -61,6 +61,8 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O return scrollview }() + let stackView = UIStackView() + let largeTitleLabel: UILabel = { let label = UILabel() label.font = UIFontMetrics(forTextStyle: .largeTitle).scaledFont(for: .systemFont(ofSize: 34, weight: .bold)) @@ -287,7 +289,11 @@ extension MastodonRegisterViewController { super.viewDidLoad() setupOnboardingAppearance() - defer { setupNavigationBarBackgroundView() } + configureTitleLabel() + defer { + setupNavigationBarBackgroundView() + configureFormLayout() + } avatarButton.menu = createMediaContextMenu() avatarButton.showsMenuAsPrimaryAction = true @@ -306,8 +312,7 @@ extension MastodonRegisterViewController { view.addGestureRecognizer(tapGestureRecognizer) tapGestureRecognizer.addTarget(self, action: #selector(tapGestureRecognizerHandler)) - // stackview - let stackView = UIStackView() + // stackView stackView.axis = .vertical stackView.distribution = .fill stackView.spacing = 40 @@ -315,17 +320,24 @@ extension MastodonRegisterViewController { stackView.isLayoutMarginsRelativeArrangement = true stackView.addArrangedSubview(largeTitleLabel) stackView.addArrangedSubview(avatarView) - stackView.addArrangedSubview(usernameTextField) - stackView.addArrangedSubview(displayNameTextField) - stackView.addArrangedSubview(emailTextField) - stackView.addArrangedSubview(passwordTextField) - stackView.addArrangedSubview(passwordCheckLabel) + + let formTableStackView = UIStackView() + stackView.addArrangedSubview(formTableStackView) + formTableStackView.axis = .vertical + formTableStackView.distribution = .fill + formTableStackView.spacing = 40 + + formTableStackView.addArrangedSubview(usernameTextField) + formTableStackView.addArrangedSubview(displayNameTextField) + formTableStackView.addArrangedSubview(emailTextField) + formTableStackView.addArrangedSubview(passwordTextField) + formTableStackView.addArrangedSubview(passwordCheckLabel) if viewModel.approvalRequired { - stackView.addArrangedSubview(reasonTextField) + formTableStackView.addArrangedSubview(reasonTextField) } usernameErrorPromptLabel.translatesAutoresizingMaskIntoConstraints = false - stackView.addSubview(usernameErrorPromptLabel) + formTableStackView.addSubview(usernameErrorPromptLabel) NSLayoutConstraint.activate([ usernameErrorPromptLabel.topAnchor.constraint(equalTo: usernameTextField.bottomAnchor, constant: 6), usernameErrorPromptLabel.leadingAnchor.constraint(equalTo: usernameTextField.leadingAnchor), @@ -333,7 +345,7 @@ extension MastodonRegisterViewController { ]) emailErrorPromptLabel.translatesAutoresizingMaskIntoConstraints = false - stackView.addSubview(emailErrorPromptLabel) + formTableStackView.addSubview(emailErrorPromptLabel) NSLayoutConstraint.activate([ emailErrorPromptLabel.topAnchor.constraint(equalTo: emailTextField.bottomAnchor, constant: 6), emailErrorPromptLabel.leadingAnchor.constraint(equalTo: emailTextField.leadingAnchor), @@ -341,7 +353,7 @@ extension MastodonRegisterViewController { ]) passwordErrorPromptLabel.translatesAutoresizingMaskIntoConstraints = false - stackView.addSubview(passwordErrorPromptLabel) + formTableStackView.addSubview(passwordErrorPromptLabel) NSLayoutConstraint.activate([ passwordErrorPromptLabel.topAnchor.constraint(equalTo: passwordCheckLabel.bottomAnchor, constant: 2), passwordErrorPromptLabel.leadingAnchor.constraint(equalTo: passwordTextField.leadingAnchor), @@ -358,7 +370,7 @@ extension MastodonRegisterViewController { scrollView.frameLayoutGuide.widthAnchor.constraint(equalTo: scrollView.contentLayoutGuide.widthAnchor), ]) - // stackview + // stackView scrollView.addSubview(stackView) stackView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ @@ -373,12 +385,14 @@ extension MastodonRegisterViewController { avatarView.translatesAutoresizingMaskIntoConstraints = false avatarView.addSubview(avatarButton) NSLayoutConstraint.activate([ - avatarView.heightAnchor.constraint(equalToConstant: 90).priority(.defaultHigh), + avatarView.heightAnchor.constraint(equalToConstant: 92).priority(.required - 1), ]) avatarButton.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ - avatarButton.heightAnchor.constraint(equalToConstant: 92).priority(.defaultHigh), - avatarButton.widthAnchor.constraint(equalToConstant: 92).priority(.defaultHigh), + avatarButton.heightAnchor.constraint(equalToConstant: 92).priority(.required - 1), + avatarButton.widthAnchor.constraint(equalToConstant: 92).priority(.required - 1), + avatarButton.leadingAnchor.constraint(greaterThanOrEqualTo: avatarView.leadingAnchor).priority(.required - 1), + avatarView.trailingAnchor.constraint(greaterThanOrEqualTo: avatarButton.trailingAnchor).priority(.required - 1), avatarButton.centerXAnchor.constraint(equalTo: avatarView.centerXAnchor), avatarButton.centerYAnchor.constraint(equalTo: avatarView.centerYAnchor), ]) @@ -392,15 +406,15 @@ extension MastodonRegisterViewController { // textfield NSLayoutConstraint.activate([ - usernameTextField.heightAnchor.constraint(equalToConstant: 50).priority(.defaultHigh), - displayNameTextField.heightAnchor.constraint(equalToConstant: 50).priority(.defaultHigh), - emailTextField.heightAnchor.constraint(equalToConstant: 50).priority(.defaultHigh), - passwordTextField.heightAnchor.constraint(equalToConstant: 50).priority(.defaultHigh), + usernameTextField.heightAnchor.constraint(equalToConstant: 50).priority(.required - 1), + displayNameTextField.heightAnchor.constraint(equalToConstant: 50).priority(.required - 1), + emailTextField.heightAnchor.constraint(equalToConstant: 50).priority(.required - 1), + passwordTextField.heightAnchor.constraint(equalToConstant: 50).priority(.required - 1), ]) // password - stackView.setCustomSpacing(6, after: passwordTextField) - stackView.setCustomSpacing(32, after: passwordCheckLabel) + formTableStackView.setCustomSpacing(6, after: passwordTextField) + formTableStackView.setCustomSpacing(32, after: passwordCheckLabel) // return if viewModel.approvalRequired { @@ -410,16 +424,22 @@ extension MastodonRegisterViewController { } // button - stackView.addArrangedSubview(buttonContainer) + formTableStackView.addArrangedSubview(buttonContainer) signUpButton.translatesAutoresizingMaskIntoConstraints = false buttonContainer.addSubview(signUpButton) NSLayoutConstraint.activate([ signUpButton.topAnchor.constraint(equalTo: buttonContainer.topAnchor), - signUpButton.leadingAnchor.constraint(equalTo: buttonContainer.leadingAnchor, constant: MastodonRegisterViewController.actionButtonMargin), - buttonContainer.trailingAnchor.constraint(equalTo: signUpButton.trailingAnchor, constant: MastodonRegisterViewController.actionButtonMargin), + signUpButton.leadingAnchor.constraint(equalTo: buttonContainer.leadingAnchor), + buttonContainer.trailingAnchor.constraint(equalTo: signUpButton.trailingAnchor), buttonContainer.bottomAnchor.constraint(equalTo: signUpButton.bottomAnchor), - signUpButton.heightAnchor.constraint(equalToConstant: MastodonRegisterViewController.actionButtonHeight).priority(.defaultHigh), + signUpButton.heightAnchor.constraint(equalToConstant: MastodonRegisterViewController.actionButtonHeight).priority(.required - 1), + buttonContainer.heightAnchor.constraint(equalToConstant: MastodonRegisterViewController.actionButtonHeight).priority(.required - 1), ]) + signUpButton.setContentHuggingPriority(.defaultLow, for: .horizontal) + signUpButton.setContentHuggingPriority(.defaultLow, for: .vertical) + signUpButton.setContentCompressionResistancePriority(.required - 1, for: .vertical) + signUpButton.setContentCompressionResistancePriority(.required - 1, for: .horizontal) + buttonContainer.setContentCompressionResistancePriority(.required - 1, for: .vertical) Publishers.CombineLatest( KeyboardResponderService.shared.state.eraseToAnyPublisher(), @@ -645,6 +665,12 @@ extension MastodonRegisterViewController { plusIconImageView.layer.masksToBounds = true } + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + configureTitleLabel() + configureFormLayout() + } } extension MastodonRegisterViewController: UITextFieldDelegate { @@ -714,7 +740,7 @@ extension MastodonRegisterViewController: UITextFieldDelegate { textField.layer.shadowRadius = 2.0 textField.layer.shadowOffset = CGSize.zero textField.layer.shadowColor = color.cgColor - textField.layer.shadowPath = UIBezierPath(roundedRect: textField.bounds, byRoundingCorners: .allCorners, cornerRadii: CGSize(width: 2.0, height: 2.0)).cgPath + // textField.layer.shadowPath = UIBezierPath(roundedRect: textField.bounds, byRoundingCorners: .allCorners, cornerRadii: CGSize(width: 2.0, height: 2.0)).cgPath } private func setTextFieldValidAppearance(_ textField: UITextField, validateState: MastodonRegisterViewModel.ValidateState) { @@ -729,6 +755,36 @@ extension MastodonRegisterViewController: UITextFieldDelegate { } } +extension MastodonRegisterViewController { + private func configureTitleLabel() { + switch traitCollection.horizontalSizeClass { + case .regular: + navigationItem.largeTitleDisplayMode = .always + navigationItem.title = L10n.Scene.ServerPicker.title.replacingOccurrences(of: "\n", with: " ") + largeTitleLabel.isHidden = true + default: + navigationItem.largeTitleDisplayMode = .never + navigationItem.title = nil + largeTitleLabel.isHidden = false + } + } + + private func configureFormLayout() { + switch traitCollection.horizontalSizeClass { + case .regular: + stackView.axis = .horizontal + stackView.distribution = .fillProportionally + default: + stackView.axis = .vertical + stackView.distribution = .fill + } + } + + private func configureMargin() { + + } +} + extension MastodonRegisterViewController { @objc private func tapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) { view.endEditing(true) @@ -746,23 +802,48 @@ extension MastodonRegisterViewController { let password = viewModel.password.value let locale: String = { - let fallbackLanguageCode = Locale.current.languageCode ?? "en" + guard let url = Bundle.main.url(forResource: "local-codes", withExtension: "json"), + let data = try? Data(contentsOf: url), + let localCode = try? JSONDecoder().decode(MastodonLocalCode.self, from: data) + else { + assertionFailure() + return "en" + } + let fallbackLanguageCode: String = { + let code = Locale.current.languageCode ?? "en" + guard localCode[code] != nil else { return "en" } + return code + }() + + // pick device preferred language guard let identifier = Locale.preferredLanguages.first else { return fallbackLanguageCode } + // prepare languageCode and validate then return fallback if needs let local = Locale(identifier: identifier) - guard let languageCode = local.languageCode else { + guard let languageCode = local.languageCode, + localCode[languageCode] != nil + else { return fallbackLanguageCode } - switch languageCode { - case "zh": - // Check Simplified Chinese / Traditional Chinese - // https://github.com/gunchleoc/mastodon/blob/ed6153b8f24d3a8f5a124cc95683bd1f20aec882/app/helpers/settings_helper.rb - guard let regionCode = local.regionCode else { return languageCode } - return "zh" + "-" + regionCode - default: + // prepare extendCode and validate then return fallback if needs + let extendCodes: [String] = { + let locales = Locale.preferredLanguages.map { Locale(identifier: $0) } + return locales.compactMap { locale in + guard let languageCode = locale.languageCode, + let regionCode = locale.regionCode + else { return nil } + return languageCode + "-" + regionCode + } + }() + let _firstMatchExtendCode = extendCodes.first { code in + localCode[code] != nil + } + guard let firstMatchExtendCode = _firstMatchExtendCode else { return languageCode } + return firstMatchExtendCode + }() let query = Mastodon.API.Account.RegisterQuery( reason: viewModel.reason.value, @@ -772,6 +853,8 @@ extension MastodonRegisterViewController { agreement: true, // user confirmed in the server rules scene locale: locale ) + + var retryCount = 0 // register without show server rules context.apiService.accountRegister( @@ -779,6 +862,32 @@ extension MastodonRegisterViewController { query: query, authorization: viewModel.applicationAuthorization ) + .tryCatch { [weak self] error -> AnyPublisher, Error> in + guard let self = self else { throw error } + guard let error = self.viewModel.error.value as? Mastodon.API.Error, + case let .generic(errorEntity) = error.mastodonError, + errorEntity.error == "Validation failed: Locale is not included in the list" + else { + throw error + } + guard retryCount == 0 else { + throw error + } + let retryQuery = Mastodon.API.Account.RegisterQuery( + reason: query.reason, + username: query.username, + email: query.email, + password: query.password, + agreement: query.agreement, + locale: self.viewModel.instance.languages?.first ?? "en" + ) + retryCount += 1 + return self.context.apiService.accountRegister( + domain: self.viewModel.domain, + query: retryQuery, + authorization: self.viewModel.applicationAuthorization + ) + } .receive(on: DispatchQueue.main) .sink { [weak self] completion in guard let self = self else { return } diff --git a/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift b/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift index c31e91304..e93d06e19 100644 --- a/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift +++ b/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift @@ -21,6 +21,8 @@ final class MastodonServerRulesViewController: UIViewController, NeedsDependency var viewModel: MastodonServerRulesViewModel! + let stackView = UIStackView() + let largeTitleLabel: UILabel = { let label = UILabel() label.font = UIFontMetrics(forTextStyle: .largeTitle).scaledFont(for: .systemFont(ofSize: 34, weight: .bold)) @@ -96,6 +98,8 @@ extension MastodonServerRulesViewController { super.viewDidLoad() setupOnboardingAppearance() + configureTitleLabel() + configureMargin() configTextView() defer { setupNavigationBarBackgroundView() } @@ -116,8 +120,8 @@ extension MastodonServerRulesViewController { bottomContainerView.addSubview(confirmButton) NSLayoutConstraint.activate([ bottomContainerView.layoutMarginsGuide.bottomAnchor.constraint(equalTo: confirmButton.bottomAnchor, constant: MastodonServerRulesViewController.viewBottomPaddingHeight), - confirmButton.leadingAnchor.constraint(equalTo: bottomContainerView.readableContentGuide.leadingAnchor, constant: MastodonServerRulesViewController.actionButtonMargin), - bottomContainerView.readableContentGuide.trailingAnchor.constraint(equalTo: confirmButton.trailingAnchor, constant: MastodonServerRulesViewController.actionButtonMargin), + confirmButton.leadingAnchor.constraint(equalTo: bottomContainerView.layoutMarginsGuide.leadingAnchor), + bottomContainerView.layoutMarginsGuide.trailingAnchor.constraint(equalTo: confirmButton.trailingAnchor), confirmButton.heightAnchor.constraint(equalToConstant: MastodonServerRulesViewController.actionButtonHeight).priority(.defaultHigh), ]) @@ -125,8 +129,8 @@ extension MastodonServerRulesViewController { bottomContainerView.addSubview(bottomPromptMetaText.textView) NSLayoutConstraint.activate([ bottomPromptMetaText.textView.frameLayoutGuide.topAnchor.constraint(equalTo: bottomContainerView.topAnchor, constant: 20), - bottomPromptMetaText.textView.frameLayoutGuide.leadingAnchor.constraint(equalTo: bottomContainerView.readableContentGuide.leadingAnchor), - bottomPromptMetaText.textView.frameLayoutGuide.trailingAnchor.constraint(equalTo: bottomContainerView.readableContentGuide.trailingAnchor), + bottomPromptMetaText.textView.frameLayoutGuide.leadingAnchor.constraint(equalTo: bottomContainerView.layoutMarginsGuide.leadingAnchor), + bottomPromptMetaText.textView.frameLayoutGuide.trailingAnchor.constraint(equalTo: bottomContainerView.layoutMarginsGuide.trailingAnchor), confirmButton.topAnchor.constraint(equalTo: bottomPromptMetaText.textView.frameLayoutGuide.bottomAnchor, constant: 20), ]) @@ -140,10 +144,10 @@ extension MastodonServerRulesViewController { scrollView.frameLayoutGuide.widthAnchor.constraint(equalTo: scrollView.contentLayoutGuide.widthAnchor), ]) - let stackView = UIStackView() stackView.axis = .vertical stackView.distribution = .fill stackView.spacing = 10 + stackView.isLayoutMarginsRelativeArrangement = true stackView.layoutMargins = UIEdgeInsets(top: 20, left: 0, bottom: 20, right: 0) stackView.addArrangedSubview(largeTitleLabel) stackView.addArrangedSubview(subtitleLabel) @@ -162,6 +166,12 @@ extension MastodonServerRulesViewController { confirmButton.addTarget(self, action: #selector(MastodonServerRulesViewController.confirmButtonPressed(_:)), for: .touchUpInside) } + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + scrollView.flashScrollIndicators() + } + override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() updateScrollViewContentInset() @@ -172,6 +182,46 @@ extension MastodonServerRulesViewController { updateScrollViewContentInset() } + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + setupNavigationBarAppearance() + configureTitleLabel() + configureMargin() + } + +} + +extension MastodonServerRulesViewController { + private func configureTitleLabel() { + guard UIDevice.current.userInterfaceIdiom == .pad else { + return + } + + switch traitCollection.horizontalSizeClass { + case .regular: + navigationItem.largeTitleDisplayMode = .always + navigationItem.title = L10n.Scene.ServerRules.title.replacingOccurrences(of: "\n", with: " ") + largeTitleLabel.isHidden = true + default: + navigationItem.leftBarButtonItem = nil + navigationItem.largeTitleDisplayMode = .never + navigationItem.title = nil + largeTitleLabel.isHidden = false + } + } + + private func configureMargin() { + switch traitCollection.horizontalSizeClass { + case .regular: + let margin = MastodonPickServerViewController.viewEdgeMargin + stackView.layoutMargins = UIEdgeInsets(top: 32, left: margin, bottom: 20, right: margin) + bottomContainerView.layoutMargins = UIEdgeInsets(top: 0, left: margin, bottom: 0, right: margin) + default: + stackView.layoutMargins = UIEdgeInsets(top: 20, left: 0, bottom: 20, right: 0) + bottomContainerView.layoutMargins = .zero + } + } } extension MastodonServerRulesViewController { diff --git a/Mastodon/Scene/Onboarding/Share/OnboardingViewControllerAppearance.swift b/Mastodon/Scene/Onboarding/Share/OnboardingViewControllerAppearance.swift index d93c677f8..17c4699ec 100644 --- a/Mastodon/Scene/Onboarding/Share/OnboardingViewControllerAppearance.swift +++ b/Mastodon/Scene/Onboarding/Share/OnboardingViewControllerAppearance.swift @@ -24,19 +24,38 @@ extension OnboardingViewControllerAppearance { setupNavigationBarAppearance() - let backItem = UIBarButtonItem() - backItem.title = L10n.Common.Controls.Actions.back + let backItem = UIBarButtonItem( + title: L10n.Common.Controls.Actions.back, + style: .plain, + target: nil, + action: nil + ) navigationItem.backBarButtonItem = backItem } func setupNavigationBarAppearance() { // use TransparentBackground so view push / dismiss will be more visual nature // please add opaque background for status bar manually if needs - let barAppearance = UINavigationBarAppearance() - barAppearance.configureWithTransparentBackground() - navigationController?.navigationBar.standardAppearance = barAppearance - navigationController?.navigationBar.compactAppearance = barAppearance - navigationController?.navigationBar.scrollEdgeAppearance = barAppearance + + switch traitCollection.userInterfaceIdiom { + case .pad: + if traitCollection.horizontalSizeClass == .regular { + // do nothing + } else { + fallthrough + } + default: + let barAppearance = UINavigationBarAppearance() + barAppearance.configureWithTransparentBackground() + navigationItem.standardAppearance = barAppearance + navigationItem.compactAppearance = barAppearance + navigationItem.scrollEdgeAppearance = barAppearance + if #available(iOS 15.0, *) { + navigationItem.compactScrollEdgeAppearance = barAppearance + } else { + // Fallback on earlier versions + } + } } func setupNavigationBarBackgroundView() { @@ -57,3 +76,12 @@ extension OnboardingViewControllerAppearance { } } + +extension OnboardingViewControllerAppearance { + static var viewEdgeMargin: CGFloat { + guard UIDevice.current.userInterfaceIdiom == .pad else { return .zero } + return 20 +// let shortEdgeWidth = min(UIScreen.main.bounds.height, UIScreen.main.bounds.width) +// return shortEdgeWidth * 0.17 // magic + } +} diff --git a/Mastodon/Scene/Onboarding/Welcome/View/WizardCardView.swift b/Mastodon/Scene/Onboarding/Welcome/View/WizardCardView.swift new file mode 100644 index 000000000..6f18afc94 --- /dev/null +++ b/Mastodon/Scene/Onboarding/Welcome/View/WizardCardView.swift @@ -0,0 +1,141 @@ +// +// WizardCardView.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-9-15. +// + +import UIKit + +final class WizardCardView: UIView { + + static let bubbleArrowHeight: CGFloat = 17 + static let bubbleArrowWidth: CGFloat = 20 + + let contentView = UIView() + + let backgroundShapeLayer = CAShapeLayer() + var arrowRectCorner: UIRectCorner = .bottomRight + + let titleLabel: UILabel = { + let label = UILabel() + label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .semibold)) + label.textColor = .black + return label + }() + + let descriptionLabel: UILabel = { + let label = UILabel() + label.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(for: .systemFont(ofSize: 13, weight: .regular)) + label.textColor = .black + label.numberOfLines = 0 + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension WizardCardView { + private func _init() { + layer.masksToBounds = false + layer.addSublayer(backgroundShapeLayer) + + contentView.translatesAutoresizingMaskIntoConstraints = false + addSubview(contentView) + NSLayoutConstraint.activate([ + contentView.topAnchor.constraint(equalTo: topAnchor, constant: WizardCardView.bubbleArrowHeight), + contentView.leadingAnchor.constraint(equalTo: leadingAnchor), + trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: WizardCardView.bubbleArrowHeight), + ]) + + let containerStackView = UIStackView() + containerStackView.axis = .vertical + containerStackView.spacing = 2 + containerStackView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(containerStackView) + NSLayoutConstraint.activate([ + containerStackView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 5), + containerStackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 7), + contentView.trailingAnchor.constraint(equalTo: containerStackView.trailingAnchor, constant: 24), + contentView.bottomAnchor.constraint(equalTo: containerStackView.bottomAnchor, constant: 5), + ]) + + containerStackView.addArrangedSubview(titleLabel) + containerStackView.addArrangedSubview(descriptionLabel) + } + + override func layoutSubviews() { + super.layoutSubviews() + + let radius: CGFloat = 5 + let rect = contentView.frame + let path = UIBezierPath() + + switch arrowRectCorner { + case .bottomRight: + path.move(to: CGPoint(x: rect.maxX - WizardCardView.bubbleArrowWidth, y: rect.maxY + radius)) + path.addArc(withCenter: CGPoint(x: rect.minX, y: rect.maxY), radius: radius, startAngle: .pi / 2, endAngle: .pi, clockwise: true) + path.addArc(withCenter: CGPoint(x: rect.minX, y: rect.minY), radius: radius, startAngle: .pi, endAngle: .pi / 2 * 3, clockwise: true) + path.addArc(withCenter: CGPoint(x: rect.maxX, y: rect.minY), radius: radius, startAngle: .pi / 2 * 3, endAngle: .pi * 2, clockwise: true) + path.addLine(to: CGPoint(x: rect.maxX + radius, y: rect.maxY + radius + WizardCardView.bubbleArrowHeight)) + path.close() + case .bottomLeft: + path.move(to: CGPoint(x: rect.minX + WizardCardView.bubbleArrowWidth, y: rect.maxY + radius)) + path.addArc(withCenter: CGPoint(x: rect.maxX, y: rect.maxY), radius: radius, startAngle: .pi / 2, endAngle: 0, clockwise: false) + path.addArc(withCenter: CGPoint(x: rect.maxX, y: rect.minY), radius: radius, startAngle: 0, endAngle: -.pi / 2, clockwise: false) + path.addArc(withCenter: CGPoint(x: rect.minX, y: rect.minY), radius: radius, startAngle: -.pi / 2, endAngle: -.pi, clockwise: false) + path.addLine(to: CGPoint(x: rect.minX - radius, y: rect.maxY + radius + WizardCardView.bubbleArrowHeight)) + path.close() + case .allCorners: // no arrow + path.move(to: CGPoint(x: rect.maxX, y: rect.maxY + radius)) + path.addArc(withCenter: CGPoint(x: rect.minX, y: rect.maxY), radius: radius, startAngle: .pi / 2, endAngle: .pi, clockwise: true) + path.addArc(withCenter: CGPoint(x: rect.minX, y: rect.minY), radius: radius, startAngle: .pi, endAngle: .pi / 2 * 3, clockwise: true) + path.addArc(withCenter: CGPoint(x: rect.maxX, y: rect.minY), radius: radius, startAngle: .pi / 2 * 3, endAngle: .pi * 2, clockwise: true) + path.addArc(withCenter: CGPoint(x: rect.maxX, y: rect.maxY), radius: radius, startAngle: .pi * 2, endAngle: .pi / 2 * 5, clockwise: true) + path.close() + default: + assertionFailure("FIXME") + } + + backgroundShapeLayer.lineCap = .round + backgroundShapeLayer.lineJoin = .round + backgroundShapeLayer.lineWidth = 3 + backgroundShapeLayer.strokeColor = UIColor.white.cgColor + backgroundShapeLayer.fillColor = UIColor.white.cgColor + backgroundShapeLayer.path = path.cgPath + } + + override var isAccessibilityElement: Bool { + get { true } + set { } + } + + override var accessibilityLabel: String? { + get { + return [ + titleLabel.text, + descriptionLabel.text + ] + .compactMap { $0 } + .joined(separator: " ") + } + set { } + } + + override var accessibilityHint: String? { + get { + return L10n.Scene.Wizard.accessibilityHint + } + set { } + } +} diff --git a/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift b/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift index 8dba6d700..bf33ea13d 100644 --- a/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift +++ b/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift @@ -7,17 +7,24 @@ import os.log import UIKit +import Combine final class WelcomeViewController: UIViewController, NeedsDependency { weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + var disposeBag = Set() + var observations = Set() + private(set) lazy var viewModel = WelcomeViewModel(context: context) + let welcomeIllustrationView = WelcomeIllustrationView() var welcomeIllustrationViewBottomAnchorLayoutConstraint: NSLayoutConstraint? + private(set) lazy var dismissBarButtonItem = UIBarButtonItem(barButtonSystemItem: .close, target: self, action: #selector(WelcomeViewController.dismissBarButtonItemDidPressed(_:))) + private(set) lazy var logoImageView: UIImageView = { - let image = view.traitCollection.userInterfaceIdiom == .phone ? Asset.Scene.Welcome.mastodonLogo.image : Asset.Scene.Welcome.mastodonLogoBlackLarge.image + let image = Asset.Scene.Welcome.mastodonLogo.image let imageView = UIImageView(image: image) imageView.translatesAutoresizingMaskIntoConstraints = false return imageView @@ -34,15 +41,15 @@ final class WelcomeViewController: UIViewController, NeedsDependency { return label }() - private(set) lazy var signUpButton: PrimaryActionButton = { + private(set) lazy var signUpButton: PrimaryActionButton = { let button = PrimaryActionButton() button.adjustsBackgroundImageWhenUserInterfaceStyleChanges = false button.setTitle(L10n.Common.Controls.Actions.signUp, for: .normal) - let backgroundImageColor: UIColor = traitCollection.userInterfaceIdiom == .phone ? .white : Asset.Colors.brandBlue.color - let backgroundImageHighlightedColor: UIColor = traitCollection.userInterfaceIdiom == .phone ? UIColor(white: 0.8, alpha: 1.0) : Asset.Colors.brandBlueDarken20.color + let backgroundImageColor: UIColor = .white + let backgroundImageHighlightedColor: UIColor = UIColor(white: 0.8, alpha: 1.0) button.setBackgroundImage(.placeholder(color: backgroundImageColor), for: .normal) button.setBackgroundImage(.placeholder(color: backgroundImageHighlightedColor), for: .highlighted) - let titleColor: UIColor = traitCollection.userInterfaceIdiom == .phone ? Asset.Colors.brandBlue.color : UIColor.white + let titleColor: UIColor = Asset.Colors.brandBlue.color button.setTitleColor(titleColor, for: .normal) button.translatesAutoresizingMaskIntoConstraints = false return button @@ -52,7 +59,7 @@ final class WelcomeViewController: UIViewController, NeedsDependency { let button = UIButton(type: .system) button.titleLabel?.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .semibold)) button.setTitle(L10n.Common.Controls.Actions.signIn, for: .normal) - let titleColor: UIColor = traitCollection.userInterfaceIdiom == .phone ? UIColor.white.withAlphaComponent(0.8) : Asset.Colors.brandBlue.color + let titleColor: UIColor = UIColor.white.withAlphaComponent(0.8) button.setTitleColor(titleColor, for: .normal) button.translatesAutoresizingMaskIntoConstraints = false return button @@ -69,6 +76,10 @@ extension WelcomeViewController { override func viewDidLoad() { super.viewDidLoad() + // preferredContentSize = CGSize(width: 547, height: 678) + + navigationController?.navigationBar.prefersLargeTitles = true + navigationItem.largeTitleDisplayMode = .never view.overrideUserInterfaceStyle = .light setupOnboardingAppearance() @@ -90,16 +101,39 @@ extension WelcomeViewController { signUpButton.addTarget(self, action: #selector(signUpButtonDidClicked(_:)), for: .touchUpInside) signInButton.addTarget(self, action: #selector(signInButtonDidClicked(_:)), for: .touchUpInside) + + viewModel.needsShowDismissEntry + .receive(on: DispatchQueue.main) + .sink { [weak self] needsShowDismissEntry in + guard let self = self else { return } + self.navigationItem.leftBarButtonItem = needsShowDismissEntry ? self.dismissBarButtonItem : nil + } + .store(in: &disposeBag) + + view.observe(\.frame, options: [.initial, .new]) { [weak self] view, _ in + guard let self = self else { return } + switch view.traitCollection.userInterfaceIdiom { + case .phone: + break + default: + self.welcomeIllustrationView.elephantOnAirplaneWithContrailImageView.isHidden = view.frame.height < 800 + } + } + .store(in: &observations) } override func viewSafeAreaInsetsDidChange() { super.viewSafeAreaInsetsDidChange() - // shift illustration down for non-notch phone var overlap: CGFloat = 5 + // shift illustration down for non-notch phone if view.safeAreaInsets.bottom == 0 { overlap += 56 } + // shift illustration down for iPad modal + if UIDevice.current.userInterfaceIdiom != .phone { + overlap += 20 + } welcomeIllustrationViewBottomAnchorLayoutConstraint?.constant = overlap } @@ -121,85 +155,80 @@ extension WelcomeViewController { } // set illustration for phone - if traitCollection.userInterfaceIdiom == .phone { - guard welcomeIllustrationView.superview == nil else { - return - } - - welcomeIllustrationView.translatesAutoresizingMaskIntoConstraints = false - welcomeIllustrationViewBottomAnchorLayoutConstraint = welcomeIllustrationView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: 5) - - view.addSubview(welcomeIllustrationView) - NSLayoutConstraint.activate([ - view.leftAnchor.constraint(equalTo: welcomeIllustrationView.leftAnchor, constant: 15), - welcomeIllustrationView.rightAnchor.constraint(equalTo: view.rightAnchor, constant: 15), - welcomeIllustrationViewBottomAnchorLayoutConstraint! - ]) - - welcomeIllustrationView.cloudBaseImageView.addMotionEffect( - UIInterpolatingMotionEffect.motionEffect(minX: -5, maxX: 5, minY: -5, maxY: 5) - ) - welcomeIllustrationView.rightHillImageView.addMotionEffect( - UIInterpolatingMotionEffect.motionEffect(minX: -15, maxX: 25, minY: -10, maxY: 10) - ) - welcomeIllustrationView.leftHillImageView.addMotionEffect( - UIInterpolatingMotionEffect.motionEffect(minX: -25, maxX: 15, minY: -15, maxY: 15) - ) - welcomeIllustrationView.centerHillImageView.addMotionEffect( - UIInterpolatingMotionEffect.motionEffect(minX: -14, maxX: 14, minY: -5, maxY: 25) - ) - - let topPaddingView = UIView() - topPaddingView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(topPaddingView) - NSLayoutConstraint.activate([ - topPaddingView.topAnchor.constraint(equalTo: logoImageView.bottomAnchor), - topPaddingView.leadingAnchor.constraint(equalTo: logoImageView.leadingAnchor), - topPaddingView.trailingAnchor.constraint(equalTo: logoImageView.trailingAnchor), - ]) - welcomeIllustrationView.elephantOnAirplaneWithContrailImageView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(welcomeIllustrationView.elephantOnAirplaneWithContrailImageView) - NSLayoutConstraint.activate([ - view.leftAnchor.constraint(equalTo: welcomeIllustrationView.elephantOnAirplaneWithContrailImageView.leftAnchor, constant: 12), // add 12pt bleeding - welcomeIllustrationView.elephantOnAirplaneWithContrailImageView.topAnchor.constraint(equalTo: topPaddingView.bottomAnchor), - // make a little bit large - welcomeIllustrationView.elephantOnAirplaneWithContrailImageView.widthAnchor.constraint(equalTo: view.widthAnchor, multiplier: 0.84), - welcomeIllustrationView.elephantOnAirplaneWithContrailImageView.heightAnchor.constraint(equalTo: welcomeIllustrationView.elephantOnAirplaneWithContrailImageView.widthAnchor, multiplier: 105.0/318.0), - ]) - let bottomPaddingView = UIView() - bottomPaddingView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(bottomPaddingView) - NSLayoutConstraint.activate([ - bottomPaddingView.topAnchor.constraint(equalTo: welcomeIllustrationView.elephantOnAirplaneWithContrailImageView.bottomAnchor), - bottomPaddingView.leadingAnchor.constraint(equalTo: logoImageView.leadingAnchor), - bottomPaddingView.trailingAnchor.constraint(equalTo: logoImageView.trailingAnchor), - bottomPaddingView.bottomAnchor.constraint(equalTo: view.centerYAnchor), - bottomPaddingView.heightAnchor.constraint(equalTo: topPaddingView.heightAnchor, multiplier: 4), - ]) - - welcomeIllustrationView.elephantOnAirplaneWithContrailImageView.addMotionEffect( - UIInterpolatingMotionEffect.motionEffect(minX: -20, maxX: 12, minY: -20, maxY: 12) // maxX should not larger then the bleeding (12pt) - ) - - view.bringSubviewToFront(logoImageView) - view.bringSubviewToFront(sloganLabel) - } - - // set slogan for non-phone - if traitCollection.userInterfaceIdiom != .phone { - guard sloganLabel.superview == nil else { - return - } - view.addSubview(sloganLabel) - NSLayoutConstraint.activate([ - sloganLabel.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor, constant: 16), - view.readableContentGuide.trailingAnchor.constraint(equalTo: sloganLabel.trailingAnchor, constant: 16), - sloganLabel.topAnchor.constraint(equalTo: logoImageView.bottomAnchor, constant: 168), - ]) + guard welcomeIllustrationView.superview == nil else { + return } - view.bringSubviewToFront(sloganLabel) + welcomeIllustrationView.translatesAutoresizingMaskIntoConstraints = false + welcomeIllustrationViewBottomAnchorLayoutConstraint = welcomeIllustrationView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: 5) + + view.addSubview(welcomeIllustrationView) + NSLayoutConstraint.activate([ + view.leftAnchor.constraint(equalTo: welcomeIllustrationView.leftAnchor, constant: 15), + welcomeIllustrationView.rightAnchor.constraint(equalTo: view.rightAnchor, constant: 15), + welcomeIllustrationViewBottomAnchorLayoutConstraint! + ]) + + welcomeIllustrationView.cloudBaseImageView.addMotionEffect( + UIInterpolatingMotionEffect.motionEffect(minX: -5, maxX: 5, minY: -5, maxY: 5) + ) + welcomeIllustrationView.rightHillImageView.addMotionEffect( + UIInterpolatingMotionEffect.motionEffect(minX: -15, maxX: 25, minY: -10, maxY: 10) + ) + welcomeIllustrationView.leftHillImageView.addMotionEffect( + UIInterpolatingMotionEffect.motionEffect(minX: -25, maxX: 15, minY: -15, maxY: 15) + ) + welcomeIllustrationView.centerHillImageView.addMotionEffect( + UIInterpolatingMotionEffect.motionEffect(minX: -14, maxX: 14, minY: -5, maxY: 25) + ) + + let topPaddingView = UIView() + topPaddingView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(topPaddingView) + NSLayoutConstraint.activate([ + topPaddingView.topAnchor.constraint(equalTo: logoImageView.bottomAnchor), + topPaddingView.leadingAnchor.constraint(equalTo: logoImageView.leadingAnchor), + topPaddingView.trailingAnchor.constraint(equalTo: logoImageView.trailingAnchor), + ]) + welcomeIllustrationView.elephantOnAirplaneWithContrailImageView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(welcomeIllustrationView.elephantOnAirplaneWithContrailImageView) + NSLayoutConstraint.activate([ + view.leftAnchor.constraint(equalTo: welcomeIllustrationView.elephantOnAirplaneWithContrailImageView.leftAnchor, constant: 12), // add 12pt bleeding + welcomeIllustrationView.elephantOnAirplaneWithContrailImageView.topAnchor.constraint(equalTo: topPaddingView.bottomAnchor), + // make a little bit large + welcomeIllustrationView.elephantOnAirplaneWithContrailImageView.widthAnchor.constraint(equalTo: view.widthAnchor, multiplier: 0.84), + welcomeIllustrationView.elephantOnAirplaneWithContrailImageView.heightAnchor.constraint(equalTo: welcomeIllustrationView.elephantOnAirplaneWithContrailImageView.widthAnchor, multiplier: 105.0/318.0), + ]) + let bottomPaddingView = UIView() + bottomPaddingView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(bottomPaddingView) + NSLayoutConstraint.activate([ + bottomPaddingView.topAnchor.constraint(equalTo: welcomeIllustrationView.elephantOnAirplaneWithContrailImageView.bottomAnchor), + bottomPaddingView.leadingAnchor.constraint(equalTo: logoImageView.leadingAnchor), + bottomPaddingView.trailingAnchor.constraint(equalTo: logoImageView.trailingAnchor), + bottomPaddingView.bottomAnchor.constraint(equalTo: view.centerYAnchor), + bottomPaddingView.heightAnchor.constraint(equalTo: topPaddingView.heightAnchor, multiplier: 4), + ]) + + welcomeIllustrationView.elephantOnAirplaneWithContrailImageView.addMotionEffect( + UIInterpolatingMotionEffect.motionEffect(minX: -20, maxX: 12, minY: -20, maxY: 12) // maxX should not larger then the bleeding (12pt) + ) + view.bringSubviewToFront(logoImageView) + view.bringSubviewToFront(sloganLabel) + + // set slogan for non-phone +// if traitCollection.userInterfaceIdiom != .phone { +// guard sloganLabel.superview == nil else { +// return +// } +// view.addSubview(sloganLabel) +// NSLayoutConstraint.activate([ +// sloganLabel.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor, constant: 16), +// view.readableContentGuide.trailingAnchor.constraint(equalTo: sloganLabel.trailingAnchor, constant: 16), +// sloganLabel.topAnchor.constraint(equalTo: logoImageView.bottomAnchor, constant: 168), +// ]) +// } } } @@ -213,15 +242,48 @@ extension WelcomeViewController { private func signInButtonDidClicked(_ sender: UIButton) { coordinator.present(scene: .mastodonPickServer(viewMode: MastodonPickServerViewModel(context: context, mode: .signIn)), from: self, transition: .show) } + + @objc + private func dismissBarButtonItemDidPressed(_ sender: UIButton) { + dismiss(animated: true, completion: nil) + } } // MARK: - OnboardingViewControllerAppearance -extension WelcomeViewController: OnboardingViewControllerAppearance { } +extension WelcomeViewController: OnboardingViewControllerAppearance { + func setupNavigationBarAppearance() { + // always transparent + let barAppearance = UINavigationBarAppearance() + barAppearance.configureWithTransparentBackground() + navigationItem.standardAppearance = barAppearance + navigationItem.compactAppearance = barAppearance + navigationItem.scrollEdgeAppearance = barAppearance + if #available(iOS 15.0, *) { + navigationItem.compactScrollEdgeAppearance = barAppearance + } else { + // Fallback on earlier versions + } + } +} // MARK: - UIAdaptivePresentationControllerDelegate extension WelcomeViewController: UIAdaptivePresentationControllerDelegate { func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle { - // make underneath view controller alive to fix layout issue due to view life cycle - return .fullScreen + switch traitCollection.userInterfaceIdiom { + case .phone: + // make underneath view controller alive to fix layout issue due to view life cycle + return .fullScreen + default: + return .formSheet +// switch traitCollection.horizontalSizeClass { +// case .regular: +// default: +// return .fullScreen +// } + } + } + + func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool { + return false } } diff --git a/Mastodon/Scene/Onboarding/Welcome/WelcomeViewModel.swift b/Mastodon/Scene/Onboarding/Welcome/WelcomeViewModel.swift new file mode 100644 index 000000000..74b13b1a8 --- /dev/null +++ b/Mastodon/Scene/Onboarding/Welcome/WelcomeViewModel.swift @@ -0,0 +1,30 @@ +// +// WelcomeViewModel.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-9-15. +// + +import Foundation +import Combine + +final class WelcomeViewModel { + + var disposeBag = Set() + + // input + let context: AppContext + + // output + let needsShowDismissEntry = CurrentValueSubject(false) + + init(context: AppContext) { + self.context = context + + context.authenticationService.mastodonAuthentications + .map { !$0.isEmpty } + .assign(to: \.value, on: needsShowDismissEntry) + .store(in: &disposeBag) + } + +} diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+Diffable.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+Diffable.swift index f21498aaf..314721413 100644 --- a/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+Diffable.swift +++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+Diffable.swift @@ -13,18 +13,12 @@ extension FavoriteViewModel { for tableView: UITableView, dependency: NeedsDependency, statusTableViewCellDelegate: StatusTableViewCellDelegate - ) { - let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common) - .autoconnect() - .share() - .eraseToAnyPublisher() - + ) { diffableDataSource = StatusSection.tableViewDiffableDataSource( for: tableView, timelineContext: .favorite, dependency: dependency, managedObjectContext: statusFetchedResultsController.fetchedResultsController.managedObjectContext, - timestampUpdatePublisher: timestampUpdatePublisher, statusTableViewCellDelegate: statusTableViewCellDelegate, timelineMiddleLoaderTableViewCellDelegate: nil, threadReplyLoaderTableViewCellDelegate: nil diff --git a/Mastodon/Scene/Profile/Follower/FollowerListViewController+Provider.swift b/Mastodon/Scene/Profile/Follower/FollowerListViewController+Provider.swift new file mode 100644 index 000000000..25e102846 --- /dev/null +++ b/Mastodon/Scene/Profile/Follower/FollowerListViewController+Provider.swift @@ -0,0 +1,51 @@ +// +// FollowerListViewController+Provider.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-11-1. +// + +import os.log +import UIKit +import Combine +import CoreData +import CoreDataStack + +extension FollowerListViewController: UserProvider { + + func mastodonUser() -> Future { + Future { promise in + promise(.success(nil)) + } + } + + func mastodonUser(for cell: UITableViewCell?) -> Future { + Future { [weak self] promise in + guard let self = self else { return } + guard let diffableDataSource = self.viewModel.diffableDataSource else { + assertionFailure() + promise(.success(nil)) + return + } + guard let cell = cell, + let indexPath = self.tableView.indexPath(for: cell), + let item = diffableDataSource.itemIdentifier(for: indexPath) else { + promise(.success(nil)) + return + } + + let managedObjectContext = self.viewModel.userFetchedResultsController.fetchedResultsController.managedObjectContext + + switch item { + case .follower(let objectID), + .following(let objectID): + managedObjectContext.perform { + let user = managedObjectContext.object(with: objectID) as? MastodonUser + promise(.success(user)) + } + case .bottomLoader, .bottomHeader: + promise(.success(nil)) + } + } + } +} diff --git a/Mastodon/Scene/Profile/Follower/FollowerListViewController.swift b/Mastodon/Scene/Profile/Follower/FollowerListViewController.swift new file mode 100644 index 000000000..97e62ea8d --- /dev/null +++ b/Mastodon/Scene/Profile/Follower/FollowerListViewController.swift @@ -0,0 +1,108 @@ +// +// FollowerListViewController.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-11-1. +// + +import os.log +import UIKit +import GameplayKit +import Combine + +final class FollowerListViewController: UIViewController, NeedsDependency { + + var disposeBag = Set() + + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + var viewModel: FollowerListViewModel! + + lazy var tableView: UITableView = { + let tableView = UITableView() + tableView.register(UserTableViewCell.self, forCellReuseIdentifier: String(describing: UserTableViewCell.self)) + tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) + tableView.register(TimelineFooterTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineFooterTableViewCell.self)) + tableView.rowHeight = UITableView.automaticDimension + tableView.separatorStyle = .none + tableView.backgroundColor = .clear + return tableView + }() + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension FollowerListViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = ThemeService.shared.currentTheme.value.secondarySystemBackgroundColor + ThemeService.shared.currentTheme + .receive(on: RunLoop.main) + .sink { [weak self] theme in + guard let self = self else { return } + self.view.backgroundColor = theme.secondarySystemBackgroundColor + } + .store(in: &disposeBag) + + tableView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(tableView) + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + tableView.delegate = self + viewModel.setupDiffableDataSource( + for: tableView, + dependency: self + ) + // TODO: add UserTableViewCellDelegate + + // trigger user timeline loading + Publishers.CombineLatest( + viewModel.domain.removeDuplicates().eraseToAnyPublisher(), + viewModel.userID.removeDuplicates().eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + self.viewModel.stateMachine.enter(FollowerListViewModel.State.Reloading.self) + } + .store(in: &disposeBag) + } + +} + +// MARK: - LoadMoreConfigurableTableViewContainer +extension FollowerListViewController: LoadMoreConfigurableTableViewContainer { + typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell + typealias LoadingState = FollowerListViewModel.State.Loading + var loadMoreConfigurableTableView: UITableView { tableView } + var loadMoreConfigurableStateMachine: GKStateMachine { viewModel.stateMachine } +} + +// MARK: - UIScrollViewDelegate +extension FollowerListViewController { + func scrollViewDidScroll(_ scrollView: UIScrollView) { + handleScrollViewDidScroll(scrollView) + } +} + + +// MARK: - UITableViewDelegate +extension FollowerListViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + handleTableView(tableView, didSelectRowAt: indexPath) + } +} + +// MARK: - UserTableViewCellDelegate +extension FollowerListViewController: UserTableViewCellDelegate { } diff --git a/Mastodon/Scene/Profile/Follower/FollowerListViewModel+Diffable.swift b/Mastodon/Scene/Profile/Follower/FollowerListViewModel+Diffable.swift new file mode 100644 index 000000000..fc9f31779 --- /dev/null +++ b/Mastodon/Scene/Profile/Follower/FollowerListViewModel+Diffable.swift @@ -0,0 +1,66 @@ +// +// FollowerListViewModel+Diffable.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-11-1. +// + +import UIKit + +extension FollowerListViewModel { + func setupDiffableDataSource( + for tableView: UITableView, + dependency: NeedsDependency + ) { + diffableDataSource = UserSection.tableViewDiffableDataSource( + for: tableView, + dependency: dependency, + managedObjectContext: userFetchedResultsController.fetchedResultsController.managedObjectContext + ) + + // workaround to append loader wrong animation issue + // set empty section to make update animation top-to-bottom style + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems([.bottomLoader], toSection: .main) + if #available(iOS 15.0, *) { + diffableDataSource?.applySnapshotUsingReloadData(snapshot, completion: nil) + } else { + // Fallback on earlier versions + diffableDataSource?.apply(snapshot, animatingDifferences: false) + } + + userFetchedResultsController.objectIDs + .receive(on: DispatchQueue.main) + .sink { [weak self] objectIDs in + guard let self = self else { return } + guard let diffableDataSource = self.diffableDataSource else { return } + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + let items: [UserItem] = objectIDs.map { + UserItem.follower(objectID: $0) + } + snapshot.appendItems(items, toSection: .main) + + if let currentState = self.stateMachine.currentState { + switch currentState { + case is State.Idle, is State.Loading, is State.Fail: + snapshot.appendItems([.bottomLoader], toSection: .main) + case is State.NoMore: + guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value, + let userID = self.userID.value, + userID != activeMastodonAuthenticationBox.userID + else { break } + let text = L10n.Scene.Follower.footer + snapshot.appendItems([.bottomHeader(text: text)], toSection: .main) + default: + break + } + } + + diffableDataSource.apply(snapshot) + } + .store(in: &disposeBag) + } +} diff --git a/Mastodon/Scene/Profile/Follower/FollowerListViewModel+State.swift b/Mastodon/Scene/Profile/Follower/FollowerListViewModel+State.swift new file mode 100644 index 000000000..43e532673 --- /dev/null +++ b/Mastodon/Scene/Profile/Follower/FollowerListViewModel+State.swift @@ -0,0 +1,184 @@ +// +// FollowerListViewModel+State.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-11-1. +// + +import os.log +import Foundation +import GameplayKit +import MastodonSDK + +extension FollowerListViewModel { + class State: GKState { + weak var viewModel: FollowerListViewModel? + + init(viewModel: FollowerListViewModel) { + self.viewModel = viewModel + } + + override func didEnter(from previousState: GKState?) { + os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription) + } + } +} + +extension FollowerListViewModel.State { + class Initial: FollowerListViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + guard let viewModel = viewModel else { return false } + switch stateClass { + case is Reloading.Type: + return viewModel.userID.value != nil + default: + return false + } + } + } + + class Reloading: FollowerListViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Loading.Type: + return true + default: + return false + } + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + + // reset + viewModel.userFetchedResultsController.userIDs.value = [] + + stateMachine.enter(Loading.self) + } + } + + class Fail: FollowerListViewModel.State { + + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Loading.Type: + return true + default: + return false + } + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let _ = viewModel, let stateMachine = stateMachine else { return } + + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading 3s later…", ((#file as NSString).lastPathComponent), #line, #function) + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading", ((#file as NSString).lastPathComponent), #line, #function) + stateMachine.enter(Loading.self) + } + } + } + + class Idle: FollowerListViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Reloading.Type, is Loading.Type: + return true + default: + return false + } + } + } + + class Loading: FollowerListViewModel.State { + + var maxID: String? + + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Fail.Type: + return true + case is Idle.Type: + return true + case is NoMore.Type: + return true + default: + return false + } + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + + if previousState is Reloading { + maxID = nil + } + + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + + guard let userID = viewModel.userID.value, !userID.isEmpty else { + stateMachine.enter(Fail.self) + return + } + + guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { + stateMachine.enter(Fail.self) + return + } + + viewModel.context.apiService.followers( + userID: userID, + maxID: maxID, + authorizationBox: activeMastodonAuthenticationBox + ) + .receive(on: DispatchQueue.main) + .sink { completion in + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch user timeline fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + stateMachine.enter(Fail.self) + case .finished: + break + } + } receiveValue: { response in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + + var hasNewAppend = false + var userIDs = viewModel.userFetchedResultsController.userIDs.value + for user in response.value { + guard !userIDs.contains(user.id) else { continue } + userIDs.append(user.id) + hasNewAppend = true + } + + let maxID = response.link?.maxID + + if hasNewAppend && maxID != nil { + stateMachine.enter(Idle.self) + } else { + stateMachine.enter(NoMore.self) + } + self.maxID = maxID + viewModel.userFetchedResultsController.userIDs.value = userIDs + } + .store(in: &viewModel.disposeBag) + } // end func didEnter + } + + class NoMore: FollowerListViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Reloading.Type: + return true + default: + return false + } + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + } + } +} diff --git a/Mastodon/Scene/Profile/Follower/FollowerListViewModel.swift b/Mastodon/Scene/Profile/Follower/FollowerListViewModel.swift new file mode 100644 index 000000000..f62441cf1 --- /dev/null +++ b/Mastodon/Scene/Profile/Follower/FollowerListViewModel.swift @@ -0,0 +1,53 @@ +// +// FollowerListViewModel.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-11-1. +// + +import Foundation +import Combine +import Combine +import CoreData +import CoreDataStack +import GameplayKit +import MastodonSDK + +final class FollowerListViewModel { + + var disposeBag = Set() + + // input + let context: AppContext + let domain: CurrentValueSubject + let userID: CurrentValueSubject + let userFetchedResultsController: UserFetchedResultsController + + // output + var diffableDataSource: UITableViewDiffableDataSource? + private(set) lazy var stateMachine: GKStateMachine = { + let stateMachine = GKStateMachine(states: [ + State.Initial(viewModel: self), + State.Reloading(viewModel: self), + State.Fail(viewModel: self), + State.Idle(viewModel: self), + State.Loading(viewModel: self), + State.NoMore(viewModel: self), + ]) + stateMachine.enter(State.Initial.self) + return stateMachine + }() + + init(context: AppContext, domain: String?, userID: String?) { + self.context = context + self.userFetchedResultsController = UserFetchedResultsController( + managedObjectContext: context.managedObjectContext, + domain: domain, + additionalTweetPredicate: nil + ) + self.domain = CurrentValueSubject(domain) + self.userID = CurrentValueSubject(userID) + // super.init() + + } +} diff --git a/Mastodon/Scene/Profile/Following/FollowingListViewController+Provider.swift b/Mastodon/Scene/Profile/Following/FollowingListViewController+Provider.swift new file mode 100644 index 000000000..aaeb52328 --- /dev/null +++ b/Mastodon/Scene/Profile/Following/FollowingListViewController+Provider.swift @@ -0,0 +1,51 @@ +// +// FollowingListViewController+Provider.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-11-2. +// + +import os.log +import UIKit +import Combine +import CoreData +import CoreDataStack + +extension FollowingListViewController: UserProvider { + + func mastodonUser() -> Future { + Future { promise in + promise(.success(nil)) + } + } + + func mastodonUser(for cell: UITableViewCell?) -> Future { + Future { [weak self] promise in + guard let self = self else { return } + guard let diffableDataSource = self.viewModel.diffableDataSource else { + assertionFailure() + promise(.success(nil)) + return + } + guard let cell = cell, + let indexPath = self.tableView.indexPath(for: cell), + let item = diffableDataSource.itemIdentifier(for: indexPath) else { + promise(.success(nil)) + return + } + + let managedObjectContext = self.viewModel.userFetchedResultsController.fetchedResultsController.managedObjectContext + + switch item { + case .follower(let objectID), + .following(let objectID): + managedObjectContext.perform { + let user = managedObjectContext.object(with: objectID) as? MastodonUser + promise(.success(user)) + } + case .bottomLoader, .bottomHeader: + promise(.success(nil)) + } + } + } +} diff --git a/Mastodon/Scene/Profile/Following/FollowingListViewController.swift b/Mastodon/Scene/Profile/Following/FollowingListViewController.swift new file mode 100644 index 000000000..35691b82d --- /dev/null +++ b/Mastodon/Scene/Profile/Following/FollowingListViewController.swift @@ -0,0 +1,108 @@ +// +// FollowingListViewController.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-11-2. +// + +import os.log +import UIKit +import GameplayKit +import Combine + +final class FollowingListViewController: UIViewController, NeedsDependency { + + var disposeBag = Set() + + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + var viewModel: FollowingListViewModel! + + lazy var tableView: UITableView = { + let tableView = UITableView() + tableView.register(UserTableViewCell.self, forCellReuseIdentifier: String(describing: UserTableViewCell.self)) + tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) + tableView.register(TimelineFooterTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineFooterTableViewCell.self)) + tableView.rowHeight = UITableView.automaticDimension + tableView.separatorStyle = .none + tableView.backgroundColor = .clear + return tableView + }() + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension FollowingListViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = ThemeService.shared.currentTheme.value.secondarySystemBackgroundColor + ThemeService.shared.currentTheme + .receive(on: RunLoop.main) + .sink { [weak self] theme in + guard let self = self else { return } + self.view.backgroundColor = theme.secondarySystemBackgroundColor + } + .store(in: &disposeBag) + + tableView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(tableView) + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + tableView.delegate = self + viewModel.setupDiffableDataSource( + for: tableView, + dependency: self + ) + // TODO: add UserTableViewCellDelegate + + // trigger user timeline loading + Publishers.CombineLatest( + viewModel.domain.removeDuplicates().eraseToAnyPublisher(), + viewModel.userID.removeDuplicates().eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + self.viewModel.stateMachine.enter(FollowingListViewModel.State.Reloading.self) + } + .store(in: &disposeBag) + } + +} + +// MARK: - LoadMoreConfigurableTableViewContainer +extension FollowingListViewController: LoadMoreConfigurableTableViewContainer { + typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell + typealias LoadingState = FollowingListViewModel.State.Loading + var loadMoreConfigurableTableView: UITableView { tableView } + var loadMoreConfigurableStateMachine: GKStateMachine { viewModel.stateMachine } +} + +// MARK: - UIScrollViewDelegate +extension FollowingListViewController { + func scrollViewDidScroll(_ scrollView: UIScrollView) { + handleScrollViewDidScroll(scrollView) + } +} + + +// MARK: - UITableViewDelegate +extension FollowingListViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + handleTableView(tableView, didSelectRowAt: indexPath) + } +} + +// MARK: - UserTableViewCellDelegate +extension FollowingListViewController: UserTableViewCellDelegate { } diff --git a/Mastodon/Scene/Profile/Following/FollowingListViewModel+Diffable.swift b/Mastodon/Scene/Profile/Following/FollowingListViewModel+Diffable.swift new file mode 100644 index 000000000..dc6f1f6fd --- /dev/null +++ b/Mastodon/Scene/Profile/Following/FollowingListViewModel+Diffable.swift @@ -0,0 +1,66 @@ +// +// FollowingListViewModel+Diffable.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-11-2. +// + +import UIKit + +extension FollowingListViewModel { + func setupDiffableDataSource( + for tableView: UITableView, + dependency: NeedsDependency + ) { + diffableDataSource = UserSection.tableViewDiffableDataSource( + for: tableView, + dependency: dependency, + managedObjectContext: userFetchedResultsController.fetchedResultsController.managedObjectContext + ) + + // workaround to append loader wrong animation issue + // set empty section to make update animation top-to-bottom style + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems([.bottomLoader], toSection: .main) + if #available(iOS 15.0, *) { + diffableDataSource?.applySnapshotUsingReloadData(snapshot, completion: nil) + } else { + // Fallback on earlier versions + diffableDataSource?.apply(snapshot, animatingDifferences: false) + } + + userFetchedResultsController.objectIDs + .receive(on: DispatchQueue.main) + .sink { [weak self] objectIDs in + guard let self = self else { return } + guard let diffableDataSource = self.diffableDataSource else { return } + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + let items: [UserItem] = objectIDs.map { + UserItem.following(objectID: $0) + } + snapshot.appendItems(items, toSection: .main) + + if let currentState = self.stateMachine.currentState { + switch currentState { + case is State.Idle, is State.Loading, is State.Fail: + snapshot.appendItems([.bottomLoader], toSection: .main) + case is State.NoMore: + guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value, + let userID = self.userID.value, + userID != activeMastodonAuthenticationBox.userID + else { break } + let text = L10n.Scene.Following.footer + snapshot.appendItems([.bottomHeader(text: text)], toSection: .main) + default: + break + } + } + + diffableDataSource.apply(snapshot) + } + .store(in: &disposeBag) + } +} diff --git a/Mastodon/Scene/Profile/Following/FollowingListViewModel+State.swift b/Mastodon/Scene/Profile/Following/FollowingListViewModel+State.swift new file mode 100644 index 000000000..0ec3d6262 --- /dev/null +++ b/Mastodon/Scene/Profile/Following/FollowingListViewModel+State.swift @@ -0,0 +1,184 @@ +// +// FollowingListViewModel+State.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-11-2. +// + +import os.log +import Foundation +import GameplayKit +import MastodonSDK + +extension FollowingListViewModel { + class State: GKState { + weak var viewModel: FollowingListViewModel? + + init(viewModel: FollowingListViewModel) { + self.viewModel = viewModel + } + + override func didEnter(from previousState: GKState?) { + os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription) + } + } +} + +extension FollowingListViewModel.State { + class Initial: FollowingListViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + guard let viewModel = viewModel else { return false } + switch stateClass { + case is Reloading.Type: + return viewModel.userID.value != nil + default: + return false + } + } + } + + class Reloading: FollowingListViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Loading.Type: + return true + default: + return false + } + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + + // reset + viewModel.userFetchedResultsController.userIDs.value = [] + + stateMachine.enter(Loading.self) + } + } + + class Fail: FollowingListViewModel.State { + + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Loading.Type: + return true + default: + return false + } + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let _ = viewModel, let stateMachine = stateMachine else { return } + + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading 3s later…", ((#file as NSString).lastPathComponent), #line, #function) + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading", ((#file as NSString).lastPathComponent), #line, #function) + stateMachine.enter(Loading.self) + } + } + } + + class Idle: FollowingListViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Reloading.Type, is Loading.Type: + return true + default: + return false + } + } + } + + class Loading: FollowingListViewModel.State { + + var maxID: String? + + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Fail.Type: + return true + case is Idle.Type: + return true + case is NoMore.Type: + return true + default: + return false + } + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + + if previousState is Reloading { + maxID = nil + } + + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + + guard let userID = viewModel.userID.value, !userID.isEmpty else { + stateMachine.enter(Fail.self) + return + } + + guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { + stateMachine.enter(Fail.self) + return + } + + viewModel.context.apiService.following( + userID: userID, + maxID: maxID, + authorizationBox: activeMastodonAuthenticationBox + ) + .receive(on: DispatchQueue.main) + .sink { completion in + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch user timeline fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + stateMachine.enter(Fail.self) + case .finished: + break + } + } receiveValue: { response in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + + var hasNewAppend = false + var userIDs = viewModel.userFetchedResultsController.userIDs.value + for user in response.value { + guard !userIDs.contains(user.id) else { continue } + userIDs.append(user.id) + hasNewAppend = true + } + + let maxID = response.link?.maxID + + if hasNewAppend, maxID != nil { + stateMachine.enter(Idle.self) + } else { + stateMachine.enter(NoMore.self) + } + self.maxID = maxID + viewModel.userFetchedResultsController.userIDs.value = userIDs + } + .store(in: &viewModel.disposeBag) + } // end func didEnter + } + + class NoMore: FollowingListViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Reloading.Type: + return true + default: + return false + } + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + } + } +} diff --git a/Mastodon/Scene/Profile/Following/FollowingListViewModel.swift b/Mastodon/Scene/Profile/Following/FollowingListViewModel.swift new file mode 100644 index 000000000..0677f6cb4 --- /dev/null +++ b/Mastodon/Scene/Profile/Following/FollowingListViewModel.swift @@ -0,0 +1,53 @@ +// +// FollowingListViewModel.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-11-2. +// + +import Foundation +import Combine +import Combine +import CoreData +import CoreDataStack +import GameplayKit +import MastodonSDK + +final class FollowingListViewModel { + + var disposeBag = Set() + + // input + let context: AppContext + let domain: CurrentValueSubject + let userID: CurrentValueSubject + let userFetchedResultsController: UserFetchedResultsController + + // output + var diffableDataSource: UITableViewDiffableDataSource? + private(set) lazy var stateMachine: GKStateMachine = { + let stateMachine = GKStateMachine(states: [ + State.Initial(viewModel: self), + State.Reloading(viewModel: self), + State.Fail(viewModel: self), + State.Idle(viewModel: self), + State.Loading(viewModel: self), + State.NoMore(viewModel: self), + ]) + stateMachine.enter(State.Initial.self) + return stateMachine + }() + + init(context: AppContext, domain: String?, userID: String?) { + self.context = context + self.userFetchedResultsController = UserFetchedResultsController( + managedObjectContext: context.managedObjectContext, + domain: domain, + additionalTweetPredicate: nil + ) + self.domain = CurrentValueSubject(domain) + self.userID = CurrentValueSubject(userID) + // super.init() + + } +} diff --git a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift index 716b62307..34716dde5 100644 --- a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift +++ b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift @@ -44,10 +44,11 @@ final class ProfileHeaderViewController: UIViewController { let profileHeaderView = ProfileHeaderView() let pageSegmentedControl: UISegmentedControl = { - let segmenetedControl = UISegmentedControl(items: ["A", "B"]) - segmenetedControl.selectedSegmentIndex = 0 - return segmenetedControl + let segmentedControl = UISegmentedControl(items: ["A", "B"]) + segmentedControl.selectedSegmentIndex = 0 + return segmentedControl }() + var pageSegmentedControlLeadingLayoutConstraint: NSLayoutConstraint! private var isBannerPinned = false private var bottomShadowAlpha: CGFloat = 0.0 @@ -118,9 +119,10 @@ extension ProfileHeaderViewController { pageSegmentedControl.translatesAutoresizingMaskIntoConstraints = false view.addSubview(pageSegmentedControl) + pageSegmentedControlLeadingLayoutConstraint = pageSegmentedControl.leadingAnchor.constraint(equalTo: view.leadingAnchor) NSLayoutConstraint.activate([ pageSegmentedControl.topAnchor.constraint(equalTo: profileHeaderView.bottomAnchor, constant: ProfileHeaderViewController.segmentedControlMarginHeight), - pageSegmentedControl.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor), + pageSegmentedControlLeadingLayoutConstraint, // Fix iPad layout issue pageSegmentedControl.trailingAnchor.constraint(equalTo: view.readableContentGuide.trailingAnchor), view.bottomAnchor.constraint(equalTo: pageSegmentedControl.bottomAnchor, constant: ProfileHeaderViewController.segmentedControlMarginHeight), pageSegmentedControl.heightAnchor.constraint(equalToConstant: ProfileHeaderViewController.segmentedControlHeight).priority(.defaultHigh), @@ -133,10 +135,10 @@ extension ProfileHeaderViewController { viewModel.isTitleViewContentOffsetSet.eraseToAnyPublisher() ) .receive(on: DispatchQueue.main) - .sink { [weak self] viewDidAppear, isTitleViewContentOffsetDidSetted in + .sink { [weak self] viewDidAppear, isTitleViewContentOffsetDidSet in guard let self = self else { return } - self.titleView.titleLabel.alpha = viewDidAppear && isTitleViewContentOffsetDidSetted ? 1 : 0 - self.titleView.subtitleLabel.alpha = viewDidAppear && isTitleViewContentOffsetDidSetted ? 1 : 0 + self.titleView.titleLabel.alpha = viewDidAppear && isTitleViewContentOffsetDidSet ? 1 : 0 + self.titleView.subtitleLabel.alpha = viewDidAppear && isTitleViewContentOffsetDidSet ? 1 : 0 } .store(in: &disposeBag) @@ -283,6 +285,13 @@ extension ProfileHeaderViewController { setupBottomShadow() } + override func viewLayoutMarginsDidChange() { + super.viewLayoutMarginsDidChange() + + let margin = view.frame.maxX - view.readableContentGuide.layoutFrame.maxX + pageSegmentedControlLeadingLayoutConstraint.constant = margin + } + } extension ProfileHeaderViewController { diff --git a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift index 90f2e7a11..016b31a1e 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift +++ b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift @@ -17,9 +17,7 @@ protocol ProfileHeaderViewDelegate: AnyObject { func profileHeaderView(_ profileHeaderView: ProfileHeaderView, relationshipButtonDidPressed button: ProfileRelationshipActionButton) func profileHeaderView(_ profileHeaderView: ProfileHeaderView, metaTextView: MetaTextView, metaDidPressed meta: Meta) - func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, postDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) - func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followingDashboardMeterViewDidPressed followingDashboardMeterView: ProfileStatusDashboardMeterView) - func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followersDashboardMeterViewDidPressed followersDashboardMeterView: ProfileStatusDashboardMeterView) + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView dashboardView: ProfileStatusDashboardView, dashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView, meter: ProfileStatusDashboardView.Meter) } final class ProfileHeaderView: UIView { @@ -443,6 +441,7 @@ extension ProfileHeaderView { bringSubviewToFront(bannerContainerView) bringSubviewToFront(nameContainerStackView) + statusDashboardView.delegate = self bioMetaText.textView.delegate = self bioMetaText.textView.linkDelegate = self @@ -549,19 +548,9 @@ extension ProfileHeaderView: MetaTextViewDelegate { // MARK: - ProfileStatusDashboardViewDelegate extension ProfileHeaderView: ProfileStatusDashboardViewDelegate { - - func profileStatusDashboardView(_ dashboardView: ProfileStatusDashboardView, postDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) { - delegate?.profileHeaderView(self, profileStatusDashboardView: dashboardView, postDashboardMeterViewDidPressed: dashboardMeterView) + func profileStatusDashboardView(_ dashboardView: ProfileStatusDashboardView, dashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView, meter: ProfileStatusDashboardView.Meter) { + delegate?.profileHeaderView(self, profileStatusDashboardView: dashboardView, dashboardMeterViewDidPressed: dashboardMeterView, meter: meter) } - - func profileStatusDashboardView(_ dashboardView: ProfileStatusDashboardView, followingDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) { - delegate?.profileHeaderView(self, profileStatusDashboardView: dashboardView, followingDashboardMeterViewDidPressed: dashboardMeterView) - } - - func profileStatusDashboardView(_ dashboardView: ProfileStatusDashboardView, followersDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) { - delegate?.profileHeaderView(self, profileStatusDashboardView: dashboardView, followersDashboardMeterViewDidPressed: dashboardMeterView) - } - } // MARK: - AvatarConfigurableView diff --git a/Mastodon/Scene/Profile/Header/View/ProfileStatusDashboardView.swift b/Mastodon/Scene/Profile/Header/View/ProfileStatusDashboardView.swift index 0360421a8..c21703c08 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileStatusDashboardView.swift +++ b/Mastodon/Scene/Profile/Header/View/ProfileStatusDashboardView.swift @@ -9,9 +9,7 @@ import os.log import UIKit protocol ProfileStatusDashboardViewDelegate: AnyObject { - func profileStatusDashboardView(_ dashboardView: ProfileStatusDashboardView, postDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) - func profileStatusDashboardView(_ dashboardView: ProfileStatusDashboardView, followingDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) - func profileStatusDashboardView(_ dashboardView: ProfileStatusDashboardView, followersDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) + func profileStatusDashboardView(_ dashboardView: ProfileStatusDashboardView, dashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView, meter: ProfileStatusDashboardView.Meter) } final class ProfileStatusDashboardView: UIView { @@ -34,6 +32,14 @@ final class ProfileStatusDashboardView: UIView { } +extension ProfileStatusDashboardView { + enum Meter: Hashable { + case post + case following + case follower + } +} + extension ProfileStatusDashboardView { private func _init() { let containerStackView = UIStackView() @@ -67,7 +73,6 @@ extension ProfileStatusDashboardView { tapGestureRecognizer.addTarget(self, action: #selector(ProfileStatusDashboardView.tapGestureRecognizerHandler(_:))) meterView.addGestureRecognizer(tapGestureRecognizer) } - } } @@ -78,12 +83,15 @@ extension ProfileStatusDashboardView { assertionFailure() return } - if sourceView === postDashboardMeterView { - delegate?.profileStatusDashboardView(self, postDashboardMeterViewDidPressed: sourceView) - } else if sourceView === followingDashboardMeterView { - delegate?.profileStatusDashboardView(self, followingDashboardMeterViewDidPressed: sourceView) - } else if sourceView === followersDashboardMeterView { - delegate?.profileStatusDashboardView(self, followersDashboardMeterViewDidPressed: sourceView) + switch sourceView { + case postDashboardMeterView: + delegate?.profileStatusDashboardView(self, dashboardMeterViewDidPressed: sourceView, meter: .post) + case followingDashboardMeterView: + delegate?.profileStatusDashboardView(self, dashboardMeterViewDidPressed: sourceView, meter: .following) + case followersDashboardMeterView: + delegate?.profileStatusDashboardView(self, dashboardMeterViewDidPressed: sourceView, meter: .follower) + default: + assertionFailure() } } } diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index f78079d2e..5ff71ba99 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -97,7 +97,7 @@ final class ProfileViewController: UIViewController, NeedsDependency, MediaPrevi } deinit { - os_log("%{public}s[%{public}ld], %{public}s: deinit", ((#file as NSString).lastPathComponent), #line, #function) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) } } @@ -517,6 +517,7 @@ extension ProfileViewController { .assign(to: \.value, on: profileHeaderViewController.viewModel.displayProfileInfo.note) .store(in: &disposeBag) viewModel.statusesCount + .receive(on: DispatchQueue.main) .sink { [weak self] count in guard let self = self else { return } let text = count.flatMap { MastodonMetricFormatter().string(from: $0) } ?? "-" @@ -526,6 +527,7 @@ extension ProfileViewController { } .store(in: &disposeBag) viewModel.followingCount + .receive(on: DispatchQueue.main) .sink { [weak self] count in guard let self = self else { return } let text = count.flatMap { MastodonMetricFormatter().string(from: $0) } ?? "-" @@ -535,6 +537,7 @@ extension ProfileViewController { } .store(in: &disposeBag) viewModel.followersCount + .receive(on: DispatchQueue.main) .sink { [weak self] count in guard let self = self else { return } let text = count.flatMap { MastodonMetricFormatter().string(from: $0) } ?? "-" @@ -766,7 +769,6 @@ extension ProfileViewController: ProfilePagingViewControllerDelegate { // MARK: - ProfileHeaderViewDelegate extension ProfileViewController: ProfileHeaderViewDelegate { - func profileHeaderView(_ profileHeaderView: ProfileHeaderView, avatarImageViewDidPressed imageView: UIImageView) { guard let mastodonUser = viewModel.mastodonUser.value else { return } guard let avatar = imageView.image else { return } @@ -979,15 +981,40 @@ extension ProfileViewController: ProfileHeaderViewDelegate { } } - func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, postDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) { - - } - - func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followingDashboardMeterViewDidPressed followingDashboardMeterView: ProfileStatusDashboardMeterView) { - - } - - func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followersDashboardMeterViewDidPressed followersDashboardMeterView: ProfileStatusDashboardMeterView) { + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView dashboardView: ProfileStatusDashboardView, dashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView, meter: ProfileStatusDashboardView.Meter) { + switch meter { + case .post: + // do nothing + break + case .follower: + guard let domain = viewModel.domain.value, + let userID = viewModel.userID.value + else { return } + let followerListViewModel = FollowerListViewModel( + context: context, + domain: domain, + userID: userID + ) + coordinator.present( + scene: .follower(viewModel: followerListViewModel), + from: self, + transition: .show + ) + case .following: + guard let domain = viewModel.domain.value, + let userID = viewModel.userID.value + else { return } + let followingListViewModel = FollowingListViewModel( + context: context, + domain: domain, + userID: userID + ) + coordinator.present( + scene: .following(viewModel: followingListViewModel), + from: self, + transition: .show + ) + } } } diff --git a/Mastodon/Scene/Profile/RemoteProfileViewModel.swift b/Mastodon/Scene/Profile/RemoteProfileViewModel.swift index 153f50998..ef04d5811 100644 --- a/Mastodon/Scene/Profile/RemoteProfileViewModel.swift +++ b/Mastodon/Scene/Profile/RemoteProfileViewModel.swift @@ -7,6 +7,7 @@ import os.log import Foundation +import Combine import CoreDataStack import MastodonSDK @@ -49,4 +50,51 @@ final class RemoteProfileViewModel: ProfileViewModel { .store(in: &disposeBag) } + init(context: AppContext, notificationID: Mastodon.Entity.Notification.ID) { + super.init(context: context, optionalMastodonUser: nil) + + guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { + return + } + let domain = activeMastodonAuthenticationBox.domain + let authorization = activeMastodonAuthenticationBox.userAuthorization + + context.apiService.notification( + notificationID: notificationID, + mastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + .compactMap { [weak self] response -> AnyPublisher, Error>? in + let userID = response.value.account.id + // TODO: use .account directly + return context.apiService.accountInfo( + domain: domain, + userID: userID, + authorization: authorization + ) + } + .switchToLatest() + .retry(3) + .sink { completion in + switch completion { + case .failure(let error): + // TODO: handle error + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: remote notification %s user fetch failed: %s", ((#file as NSString).lastPathComponent), #line, #function, notificationID, error.localizedDescription) + case .finished: + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: remote notification %s user fetched", ((#file as NSString).lastPathComponent), #line, #function, notificationID) + } + } receiveValue: { [weak self] response in + guard let self = self else { return } + let managedObjectContext = context.managedObjectContext + let request = MastodonUser.sortedFetchRequest + request.fetchLimit = 1 + request.predicate = MastodonUser.predicate(domain: domain, id: response.value.id) + guard let mastodonUser = managedObjectContext.safeFetch(request).first else { + assertionFailure() + return + } + self.mastodonUser.value = mastodonUser + } + .store(in: &disposeBag) + } + } diff --git a/Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewController.swift b/Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewController.swift index 16dcbbeb6..23630741f 100644 --- a/Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewController.swift +++ b/Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewController.swift @@ -21,6 +21,19 @@ final class ProfilePagingViewController: TabmanViewController { // MARK: - PageboyViewControllerDelegate + override func pageboyViewController(_ pageboyViewController: PageboyViewController, didCancelScrollToPageAt index: PageboyViewController.PageIndex, returnToPageAt previousIndex: PageboyViewController.PageIndex) { + super.pageboyViewController(pageboyViewController, didCancelScrollToPageAt: index, returnToPageAt: previousIndex) + + // Fix the SDK bug for table view get row selected during swipe but cancel paging + guard previousIndex < viewModel.viewControllers.count else { return } + let viewController = viewModel.viewControllers[previousIndex] + + if let tableView = viewController.scrollView as? UITableView { + for cell in tableView.visibleCells { + cell.setHighlighted(false, animated: false) + } + } + } override func pageboyViewController(_ pageboyViewController: PageboyViewController, didScrollToPageAt index: TabmanViewController.PageIndex, direction: PageboyViewController.NavigationDirection, animated: Bool) { super.pageboyViewController(pageboyViewController, didScrollToPageAt: index, direction: direction, animated: animated) diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift index f3803da01..4bee3b8af 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift @@ -12,7 +12,6 @@ import Combine import CoreDataStack import GameplayKit -// TODO: adopt MediaPreviewableViewController final class UserTimelineViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } @@ -73,6 +72,16 @@ extension UserTimelineViewController { statusTableViewCellDelegate: self ) + // setup batch fetch + viewModel.listBatchFetchViewModel.setup(scrollView: tableView) + viewModel.listBatchFetchViewModel.shouldFetch + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + self.viewModel.stateMachine.enter(UserTimelineViewModel.State.Loading.self) + } + .store(in: &disposeBag) + // trigger user timeline loading Publishers.CombineLatest( viewModel.domain.removeDuplicates().eraseToAnyPublisher(), @@ -105,11 +114,11 @@ extension UserTimelineViewController { extension UserTimelineViewController: StatusTableViewControllerAspect { } // MARK: - UIScrollViewDelegate -extension UserTimelineViewController { - func scrollViewDidScroll(_ scrollView: UIScrollView) { - aspectScrollViewDidScroll(scrollView) - } -} +//extension UserTimelineViewController { +// func scrollViewDidScroll(_ scrollView: UIScrollView) { +// aspectScrollViewDidScroll(scrollView) +// } +//} // MARK: - TableViewCellHeightCacheableContainer extension UserTimelineViewController: TableViewCellHeightCacheableContainer { @@ -187,13 +196,13 @@ extension UserTimelineViewController: ScrollViewContainer { } // MARK: - LoadMoreConfigurableTableViewContainer -extension UserTimelineViewController: LoadMoreConfigurableTableViewContainer { - typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell - typealias LoadingState = UserTimelineViewModel.State.Loading - - var loadMoreConfigurableTableView: UITableView { return tableView } - var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.stateMachine } -} +//extension UserTimelineViewController: LoadMoreConfigurableTableViewContainer { +// typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell +// typealias LoadingState = UserTimelineViewModel.State.Loading +// +// var loadMoreConfigurableTableView: UITable``````View { return tableView } +// var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.stateMachine } +//} extension UserTimelineViewController { override var keyCommands: [UIKeyCommand]? { diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift index 5ccc1441f..0d6d47823 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift @@ -14,17 +14,11 @@ extension UserTimelineViewModel { dependency: NeedsDependency, statusTableViewCellDelegate: StatusTableViewCellDelegate ) { - let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common) - .autoconnect() - .share() - .eraseToAnyPublisher() - diffableDataSource = StatusSection.tableViewDiffableDataSource( for: tableView, timelineContext: .account, dependency: dependency, managedObjectContext: statusFetchedResultsController.fetchedResultsController.managedObjectContext, - timestampUpdatePublisher: timestampUpdatePublisher, statusTableViewCellDelegate: statusTableViewCellDelegate, timelineMiddleLoaderTableViewCellDelegate: nil, threadReplyLoaderTableViewCellDelegate: nil diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift index 42edafb0f..5bf520d6d 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift @@ -23,6 +23,7 @@ final class UserTimelineViewModel { let userID: CurrentValueSubject let queryFilter: CurrentValueSubject let statusFetchedResultsController: StatusFetchedResultsController + let listBatchFetchViewModel = ListBatchFetchViewModel() var cellFrameCache = NSCache() let isBlocking = CurrentValueSubject(false) diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift index 7e6eb30f0..e9d5c518b 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift @@ -17,17 +17,11 @@ extension PublicTimelineViewModel { statusTableViewCellDelegate: StatusTableViewCellDelegate, timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate ) { - let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common) - .autoconnect() - .share() - .eraseToAnyPublisher() - diffableDataSource = StatusSection.tableViewDiffableDataSource( for: tableView, timelineContext: .public, dependency: dependency, managedObjectContext: fetchedResultsController.managedObjectContext, - timestampUpdatePublisher: timestampUpdatePublisher, statusTableViewCellDelegate: statusTableViewCellDelegate, timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate, threadReplyLoaderTableViewCellDelegate: nil diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel.swift index c3b1a3d4b..6d6ecbd34 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel.swift @@ -102,7 +102,7 @@ class PublicTimelineViewModel: NSObject { return } - diffableDataSource.apply(snapshot, animatingDifferences: false) { + diffableDataSource.reloadData(snapshot: snapshot) { tableView.scrollToRow(at: difference.targetIndexPath, at: .top, animated: false) tableView.contentOffset.y = tableView.contentOffset.y - difference.offset self.isFetchingLatestTimeline.value = false diff --git a/Mastodon/Scene/Report/ReportViewController.swift b/Mastodon/Scene/Report/ReportViewController.swift index efaa533e1..b97424cb5 100644 --- a/Mastodon/Scene/Report/ReportViewController.swift +++ b/Mastodon/Scene/Report/ReportViewController.swift @@ -51,7 +51,7 @@ class ReportViewController: UIViewController, NeedsDependency { return view }() - lazy var stackview: UIStackView = { + lazy var stackView: UIStackView = { let view = UIStackView() view.axis = .vertical view.alignment = .fill @@ -122,19 +122,19 @@ class ReportViewController: UIViewController, NeedsDependency { setupNavigation() - stackview.addArrangedSubview(header) - stackview.addArrangedSubview(contentView) - stackview.addArrangedSubview(footer) - stackview.addArrangedSubview(bottomSpacing) + stackView.addArrangedSubview(header) + stackView.addArrangedSubview(contentView) + stackView.addArrangedSubview(footer) + stackView.addArrangedSubview(bottomSpacing) contentView.addSubview(tableView) - view.addSubview(stackview) + view.addSubview(stackView) NSLayoutConstraint.activate([ - stackview.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), - stackview.leadingAnchor.constraint(equalTo: view.leadingAnchor), - stackview.bottomAnchor.constraint(equalTo: view.bottomAnchor), - stackview.trailingAnchor.constraint(equalTo: view.trailingAnchor), + stackView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + stackView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor), tableView.topAnchor.constraint(equalTo: contentView.topAnchor), tableView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), tableView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), @@ -251,7 +251,7 @@ class ReportViewController: UIViewController, NeedsDependency { = UIBarButtonItem(barButtonSystemItem: UIBarButtonItem.SystemItem.cancel, target: self, action: #selector(doneButtonDidClick)) - navigationItem.rightBarButtonItem?.tintColor = Asset.Colors.brandBlue.color + navigationItem.rightBarButtonItem?.tintColor = ThemeService.tintColor // fetch old mastodon user let beReportedUser: MastodonUser? = { @@ -273,8 +273,8 @@ class ReportViewController: UIViewController, NeedsDependency { navigationItem.titleView = titleView if let user = beReportedUser { do { - let mastodonConent = MastodonContent(content: user.displayNameWithFallback, emojis: user.emojiMeta) - let metaContent = try MastodonMetaContent.convert(document: mastodonConent) + let mastodonContent = MastodonContent(content: user.displayNameWithFallback, emojis: user.emojiMeta) + let metaContent = try MastodonMetaContent.convert(document: mastodonContent) titleView.update(titleMetaContent: metaContent, subtitle: nil) } catch { let metaContent = PlaintextMetaContent(string: user.displayNameWithFallback) diff --git a/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift b/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift index ff566a248..0880c479a 100644 --- a/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift +++ b/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift @@ -19,6 +19,7 @@ final class ReportedStatusTableViewCell: UITableViewCell, StatusCell { static let bottomPaddingHeight: CGFloat = 10 weak var dependency: ReportViewController? + private var _disposeBag = Set() var disposeBag = Set() var observations = Set() @@ -98,7 +99,7 @@ extension ReportedStatusTableViewCell { guard let self = self else { return } self.backgroundColor = ThemeService.shared.currentTheme.value.secondarySystemGroupedBackgroundColor } - .store(in: &disposeBag) + .store(in: &_disposeBag) checkbox.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(checkbox) diff --git a/Mastodon/Scene/Root/ContentSplitViewController.swift b/Mastodon/Scene/Root/ContentSplitViewController.swift new file mode 100644 index 000000000..8ca597872 --- /dev/null +++ b/Mastodon/Scene/Root/ContentSplitViewController.swift @@ -0,0 +1,108 @@ +// +// ContentSplitViewController.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-10-28. +// + +import os.log +import UIKit +import Combine +import CoreDataStack + +protocol ContentSplitViewControllerDelegate: AnyObject { + func contentSplitViewController(_ contentSplitViewController: ContentSplitViewController, sidebarViewController: SidebarViewController, didSelectTab tab: MainTabBarController.Tab) +} + +final class ContentSplitViewController: UIViewController, NeedsDependency { + + var disposeBag = Set() + + static let sidebarWidth: CGFloat = 89 + + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + weak var delegate: ContentSplitViewControllerDelegate? + + private(set) lazy var sidebarViewController: SidebarViewController = { + let sidebarViewController = SidebarViewController() + sidebarViewController.context = context + sidebarViewController.coordinator = coordinator + sidebarViewController.viewModel = SidebarViewModel(context: context) + sidebarViewController.delegate = self + return sidebarViewController + }() + + @Published var currentSupplementaryTab: MainTabBarController.Tab = .home + private(set) lazy var mainTabBarController: MainTabBarController = { + let mainTabBarController = MainTabBarController(context: context, coordinator: coordinator) + if let homeTimelineViewController = mainTabBarController.viewController(of: HomeTimelineViewController.self) { + homeTimelineViewController.viewModel.displayComposeBarButtonItem.value = false + homeTimelineViewController.viewModel.displaySettingBarButtonItem.value = false + } + return mainTabBarController + }() + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension ContentSplitViewController { + override func viewDidLoad() { + super.viewDidLoad() + + navigationController?.setNavigationBarHidden(true, animated: false) + + addChild(sidebarViewController) + sidebarViewController.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(sidebarViewController.view) + sidebarViewController.didMove(toParent: self) + NSLayoutConstraint.activate([ + sidebarViewController.view.topAnchor.constraint(equalTo: view.topAnchor), + sidebarViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + sidebarViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + sidebarViewController.view.widthAnchor.constraint(equalToConstant: ContentSplitViewController.sidebarWidth), + ]) + + addChild(mainTabBarController) + mainTabBarController.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(mainTabBarController.view) + sidebarViewController.didMove(toParent: self) + NSLayoutConstraint.activate([ + mainTabBarController.view.topAnchor.constraint(equalTo: view.topAnchor), + mainTabBarController.view.leadingAnchor.constraint(equalTo: sidebarViewController.view.trailingAnchor, constant: UIView.separatorLineHeight(of: view)), + mainTabBarController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + mainTabBarController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + $currentSupplementaryTab + .removeDuplicates() + .sink(receiveValue: { [weak self] tab in + guard let self = self else { return } + self.mainTabBarController.selectedIndex = tab.rawValue + self.mainTabBarController.currentTab.value = tab + }) + .store(in: &disposeBag) + } +} + +// MARK: - SidebarViewControllerDelegate +extension ContentSplitViewController: SidebarViewControllerDelegate { + + func sidebarViewController(_ sidebarViewController: SidebarViewController, didSelectTab tab: MainTabBarController.Tab) { + delegate?.contentSplitViewController(self, sidebarViewController: sidebarViewController, didSelectTab: tab) + } + + func sidebarViewController(_ sidebarViewController: SidebarViewController, didLongPressItem item: SidebarViewModel.Item, sourceView: UIView) { + guard case let .tab(tab) = item, tab == .me else { return } + + let accountListViewController = coordinator.present(scene: .accountList, from: nil, transition: .popover(sourceView: sourceView)) as! AccountListViewController + accountListViewController.dragIndicatorView.barView.isHidden = true + // content width needs > 300 to make checkmark display + accountListViewController.preferredContentSize = CGSize(width: 375, height: 400) + } + +} diff --git a/Mastodon/Scene/MainTab/MainTabBarController.swift b/Mastodon/Scene/Root/MainTab/MainTabBarController.swift similarity index 63% rename from Mastodon/Scene/MainTab/MainTabBarController.swift rename to Mastodon/Scene/Root/MainTab/MainTabBarController.swift index 1f9bcb578..4b803bc49 100644 --- a/Mastodon/Scene/MainTab/MainTabBarController.swift +++ b/Mastodon/Scene/Root/MainTab/MainTabBarController.swift @@ -11,19 +11,28 @@ import Combine import SafariServices class MainTabBarController: UITabBarController { + + let logger = Logger(subsystem: "MainTabBarController", category: "UI") var disposeBag = Set() weak var context: AppContext! weak var coordinator: SceneCoordinator! - - var currentTab = Tab.home + + static let avatarButtonSize = CGSize(width: 25, height: 25) + let avatarButton = CircleAvatarButton() + + var currentTab = CurrentValueSubject(.home) enum Tab: Int, CaseIterable { case home case search case notification case me + + var tag: Int { + return rawValue + } var title: String { switch self { @@ -52,6 +61,15 @@ class MainTabBarController: UITabBarController { } } + var sidebarImage: UIImage { + switch self { + case .home: return UIImage(systemName: "house")! + case .search: return UIImage(systemName: "magnifyingglass")! + case .notification: return UIImage(systemName: "bell")! + case .me: return UIImage(systemName: "person.fill")! + } + } + func viewController(context: AppContext, coordinator: SceneCoordinator) -> UIViewController { let viewController: UIViewController switch self { @@ -86,6 +104,10 @@ class MainTabBarController: UITabBarController { } } + var _viewControllers: [UIViewController] = [] + + private(set) var isReadyForWizardAvatarButton = false + init(context: AppContext, coordinator: SceneCoordinator) { self.context = context self.coordinator = coordinator @@ -121,6 +143,7 @@ extension MainTabBarController { let tabs = Tab.allCases let viewControllers: [UIViewController] = tabs.map { tab in let viewController = tab.viewController(context: context, coordinator: coordinator) + viewController.tabBarItem.tag = tab.tag viewController.tabBarItem.title = tab.title viewController.tabBarItem.image = tab.image viewController.tabBarItem.accessibilityLabel = tab.title @@ -128,6 +151,7 @@ extension MainTabBarController { viewController.tabBarItem.imageInsets = UIEdgeInsets(top: 6, left: 0, bottom: -6, right: 0) return viewController } + _viewControllers = viewControllers setViewControllers(viewControllers, animated: false) selectedIndex = 0 @@ -182,41 +206,168 @@ extension MainTabBarController { .store(in: &disposeBag) // handle push notification. toggle entry when finish fetch latest notification - context.notificationService.hasUnreadPushNotification - .receive(on: DispatchQueue.main) - .sink { [weak self] hasUnreadPushNotification in - guard let self = self else { return } - guard let notificationViewController = self.notificationViewController else { return } - - let image = hasUnreadPushNotification ? UIImage(systemName: "bell.badge.fill")! : UIImage(systemName: "bell.fill")! - notificationViewController.tabBarItem.image = image - notificationViewController.navigationController?.tabBarItem.image = image - } - .store(in: &disposeBag) + Publishers.CombineLatest( + context.authenticationService.activeMastodonAuthentication, + context.notificationService.unreadNotificationCountDidUpdate + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] authentication, _ in + guard let self = self else { return } + guard let notificationViewController = self.notificationViewController else { return } + + let hasUnreadPushNotification: Bool = authentication.flatMap { authentication in + let count = UserDefaults.shared.getNotificationCountWithAccessToken(accessToken: authentication.userAccessToken) + return count > 0 + } ?? false + + let image = hasUnreadPushNotification ? UIImage(systemName: "bell.badge.fill")! : UIImage(systemName: "bell.fill")! + notificationViewController.tabBarItem.image = image + notificationViewController.navigationController?.tabBarItem.image = image + } + .store(in: &disposeBag) - context.notificationService.requestRevealNotificationPublisher + layoutAvatarButton() + context.authenticationService.activeMastodonAuthentication .receive(on: DispatchQueue.main) - .sink { [weak self] notificationID in + .sink { [weak self] activeMastodonAuthentication in guard let self = self else { return } - self.coordinator.switchToTabBar(tab: .notification) - let threadViewModel = RemoteThreadViewModel(context: self.context, notificationID: notificationID) - self.coordinator.present(scene: .thread(viewModel: threadViewModel), from: nil, transition: .show) + + let avatarImageURL = activeMastodonAuthentication?.user.avatarImageURL() + self.avatarButton.avatarImageView.setImage( + url: avatarImageURL, + placeholder: .placeholder(color: .systemFill), + scaleToSize: MainTabBarController.avatarButtonSize + ) + + // a11y + let _profileTabItem = self.tabBar.items?.first { item in item.tag == Tab.me.tag } + guard let profileTabItem = _profileTabItem else { return } + + let currentUserDisplayName = activeMastodonAuthentication?.user.displayNameWithFallback ?? "no user" + profileTabItem.accessibilityHint = L10n.Scene.AccountList.tabBarHint(currentUserDisplayName) } .store(in: &disposeBag) + let tabBarLongPressGestureRecognizer = UILongPressGestureRecognizer() + tabBarLongPressGestureRecognizer.addTarget(self, action: #selector(MainTabBarController.tabBarLongPressGestureRecognizerHandler(_:))) + tabBar.addGestureRecognizer(tabBarLongPressGestureRecognizer) + + context.authenticationService.activeMastodonAuthenticationBox + .receive(on: DispatchQueue.main) + .sink { [weak self] authenticationBox in + guard let self = self else { return } + self.isReadyForWizardAvatarButton = authenticationBox != nil + } + .store(in: &disposeBag) + + currentTab + .receive(on: DispatchQueue.main) + .sink { [weak self] tab in + guard let self = self else { return } + self.updateAvatarButtonAppearance() + } + .store(in: &disposeBag) + + updateTabBarDisplay() + #if DEBUG // selectedIndex = 1 #endif } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + updateTabBarDisplay() + updateAvatarButtonAppearance() + } } +extension MainTabBarController { + private func updateTabBarDisplay() { + switch traitCollection.horizontalSizeClass { + case .compact: + tabBar.isHidden = false + default: + tabBar.isHidden = true + } + } +} + +extension MainTabBarController { + @objc private func tabBarLongPressGestureRecognizerHandler(_ sender: UILongPressGestureRecognizer) { + guard sender.state == .began else { return } + + var _tab: Tab? + let location = sender.location(in: tabBar) + for item in tabBar.items ?? [] { + guard let tab = Tab(rawValue: item.tag) else { continue } + guard let view = item.value(forKey: "view") as? UIView else { continue } + guard view.frame.contains(location) else { continue} + + _tab = tab + break + } + + guard let tab = _tab else { return } + logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): long press \(tab.title) tab") + + switch tab { + case .me: + coordinator.present(scene: .accountList, from: self, transition: .panModal) + default: + break + } + } +} + +extension MainTabBarController { + private func layoutAvatarButton() { + guard avatarButton.superview == nil else { return } + + let _profileTabItem = self.tabBar.items?.first { item in item.tag == Tab.me.tag } + guard let profileTabItem = _profileTabItem else { return } + guard let view = profileTabItem.value(forKey: "view") as? UIView else { + return + } + + let _anchorImageView = view.subviews.first { subview in subview is UIImageView } as? UIImageView + guard let anchorImageView = _anchorImageView else { + assertionFailure() + return + } + anchorImageView.alpha = 0 + + self.avatarButton.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(self.avatarButton) + NSLayoutConstraint.activate([ + self.avatarButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), + self.avatarButton.centerYAnchor.constraint(equalTo: anchorImageView.centerYAnchor, constant: 1.5), // 1.5pt offset + self.avatarButton.widthAnchor.constraint(equalToConstant: MainTabBarController.avatarButtonSize.width).priority(.required - 1), + self.avatarButton.heightAnchor.constraint(equalToConstant: MainTabBarController.avatarButtonSize.height).priority(.required - 1), + ]) + self.avatarButton.setContentHuggingPriority(.required - 1, for: .horizontal) + self.avatarButton.setContentHuggingPriority(.required - 1, for: .vertical) + self.avatarButton.isUserInteractionEnabled = false + } + + private func updateAvatarButtonAppearance() { + avatarButton.borderColor = currentTab.value == .me ? .label : .systemFill + avatarButton.setNeedsLayout() + } +} + extension MainTabBarController { var notificationViewController: NotificationViewController? { return viewController(of: NotificationViewController.self) } + var searchViewController: SearchViewController? { + return viewController(of: SearchViewController.self) + } + } // MARK: - UITabBarControllerDelegate @@ -225,10 +376,10 @@ extension MainTabBarController: UITabBarControllerDelegate { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: select %s", ((#file as NSString).lastPathComponent), #line, #function, viewController.debugDescription) defer { if let tab = Tab(rawValue: tabBarController.selectedIndex) { - currentTab = tab + currentTab.value = tab } } - guard currentTab.rawValue == tabBarController.selectedIndex, + guard currentTab.value.rawValue == tabBarController.selectedIndex, let navigationController = viewController as? UINavigationController, navigationController.viewControllers.count == 1, let scrollViewContainer = navigationController.topViewController as? ScrollViewContainer else { @@ -239,6 +390,59 @@ extension MainTabBarController: UITabBarControllerDelegate { } } +// MARK: - WizardViewControllerDelegate +extension MainTabBarController: WizardViewControllerDelegate { + func readyToLayoutItem(_ wizardViewController: WizardViewController, item: WizardViewController.Item) -> Bool { + switch item { + case .multipleAccountSwitch: + return isReadyForWizardAvatarButton + } + } + + func layoutSpotlight(_ wizardViewController: WizardViewController, item: WizardViewController.Item) -> UIBezierPath { + switch item { + case .multipleAccountSwitch: + guard let avatarButtonFrameInView = avatarButtonFrameInWizardView(wizardView: wizardViewController.view) else { + return UIBezierPath() + } + return UIBezierPath(ovalIn: avatarButtonFrameInView) + } + } + + func layoutWizardCard(_ wizardViewController: WizardViewController, item: WizardViewController.Item) { + switch item { + case .multipleAccountSwitch: + guard let avatarButtonFrameInView = avatarButtonFrameInWizardView(wizardView: wizardViewController.view) else { + return + } + let anchorView = UIView() + anchorView.frame = avatarButtonFrameInView + wizardViewController.backgroundView.addSubview(anchorView) + + let wizardCardView = WizardCardView() + wizardCardView.arrowRectCorner = view.traitCollection.layoutDirection == .leftToRight ? .bottomRight : .bottomLeft + wizardCardView.titleLabel.text = item.title + wizardCardView.descriptionLabel.text = item.description + + wizardCardView.translatesAutoresizingMaskIntoConstraints = false + wizardViewController.backgroundView.addSubview(wizardCardView) + NSLayoutConstraint.activate([ + anchorView.topAnchor.constraint(equalTo: wizardCardView.bottomAnchor, constant: 13), // 13pt spacing + wizardCardView.trailingAnchor.constraint(equalTo: anchorView.centerXAnchor), + wizardCardView.widthAnchor.constraint(equalTo: wizardViewController.view.widthAnchor, multiplier: 2.0/3.0).priority(.required - 1), + ]) + wizardCardView.setContentHuggingPriority(.defaultLow, for: .vertical) + } + } + + private func avatarButtonFrameInWizardView(wizardView: UIView) -> CGRect? { + guard let superview = avatarButton.superview else { + assertionFailure() + return nil + } + return superview.convert(avatarButton.frame, to: wizardView) + } +} // HIG: keyboard UX // https://developer.apple.com/design/human-interface-guidelines/macos/user-interaction/keyboard/ @@ -352,7 +556,7 @@ extension MainTabBarController { let previousTab = Tab(rawValue: selectedIndex) selectedIndex = index if let tab = Tab(rawValue: index) { - currentTab = tab + currentTab.value = tab } if let previousTab = previousTab { diff --git a/Mastodon/Scene/Root/RootSplitViewController.swift b/Mastodon/Scene/Root/RootSplitViewController.swift new file mode 100644 index 000000000..e9d7549bd --- /dev/null +++ b/Mastodon/Scene/Root/RootSplitViewController.swift @@ -0,0 +1,336 @@ +// +// RootSplitViewController.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-9-22. +// + +import os.log +import UIKit +import Combine +import CoreDataStack + +final class RootSplitViewController: UISplitViewController, NeedsDependency { + + var disposeBag = Set() + + static let sidebarWidth: CGFloat = 89 + + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + private var isPrimaryDisplay = false + + private(set) lazy var contentSplitViewController: ContentSplitViewController = { + let contentSplitViewController = ContentSplitViewController() + contentSplitViewController.context = context + contentSplitViewController.coordinator = coordinator + contentSplitViewController.delegate = self + return contentSplitViewController + }() + + private(set) lazy var searchViewController: SearchViewController = { + let searchViewController = SearchViewController() + searchViewController.context = context + searchViewController.coordinator = coordinator + return searchViewController + }() + + lazy var compactMainTabBarViewController = MainTabBarController(context: context, coordinator: coordinator) + + let separatorLine = UIView.separatorLine + + init(context: AppContext, coordinator: SceneCoordinator) { + self.context = context + self.coordinator = coordinator + super.init(style: .doubleColumn) + + primaryEdge = .trailing + primaryBackgroundStyle = .sidebar + preferredDisplayMode = .twoBesideSecondary + preferredSplitBehavior = .tile + delegate = self + + // disable edge swipe gesture + presentsWithGesture = false + + if #available(iOS 14.5, *) { + displayModeButtonVisibility = .never + } else { + // Fallback on earlier versions + } + + setViewController(searchViewController, for: .primary) + setViewController(contentSplitViewController, for: .secondary) + setViewController(compactMainTabBarViewController, for: .compact) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension RootSplitViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + updateBehavior(size: view.frame.size) + + setupBackground(theme: ThemeService.shared.currentTheme.value) + ThemeService.shared.currentTheme + .receive(on: DispatchQueue.main) + .sink { [weak self] theme in + guard let self = self else { return } + self.setupBackground(theme: theme) + } + .store(in: &disposeBag) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + updateBehavior(size: view.frame.size) + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + + coordinator.animate { [weak self] context in + guard let self = self else { return } + self.updateBehavior(size: size) + } completion: { context in + // do nothing + } + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + setupBackground(theme: ThemeService.shared.currentTheme.value) + } + + private func updateBehavior(size: CGSize) { + if size.width > 960 { + show(.primary) + isPrimaryDisplay = true + + } else { + hide(.primary) + isPrimaryDisplay = false + } + + switch (contentSplitViewController.currentSupplementaryTab, isPrimaryDisplay) { + case (.search, true): + // needs switch to other tab when primary display + // use FIFO queue save tab history + contentSplitViewController.currentSupplementaryTab = .home + default: + // do nothing + break + } + } + +} + +extension RootSplitViewController { + + private func setupBackground(theme: Theme) { + // this set column separator line color + view.backgroundColor = theme.separator + } + +} + +// MARK: - ContentSplitViewControllerDelegate +extension RootSplitViewController: ContentSplitViewControllerDelegate { + func contentSplitViewController(_ contentSplitViewController: ContentSplitViewController, sidebarViewController: SidebarViewController, didSelectTab tab: MainTabBarController.Tab) { + guard let _ = MainTabBarController.Tab.allCases.firstIndex(of: tab) else { + assertionFailure() + return + } + switch tab { + case .search: + guard isPrimaryDisplay else { + // only control search tab behavior when primary display + fallthrough + } + guard let navigationController = searchViewController.navigationController else { return } + if navigationController.viewControllers.count == 1 { + searchViewController.searchBarTapPublisher.send() + } else { + navigationController.popToRootViewController(animated: true) + } + + default: + let previousTab = contentSplitViewController.currentSupplementaryTab + contentSplitViewController.currentSupplementaryTab = tab + + if previousTab == tab, + let navigationController = contentSplitViewController.mainTabBarController.selectedViewController as? UINavigationController + { + navigationController.popToRootViewController(animated: true) + } + + } + } +} + +// MARK: - UISplitViewControllerDelegate +extension RootSplitViewController: UISplitViewControllerDelegate { + + private static func transform(from: UITabBarController, to: UITabBarController) { + let sourceNavigationControllers = from.viewControllers ?? [] + let targetNavigationControllers = to.viewControllers ?? [] + + for (source, target) in zip(sourceNavigationControllers, targetNavigationControllers) { + guard let source = source as? UINavigationController, + let target = target as? UINavigationController + else { continue } + let viewControllers = source.popToRootViewController(animated: false) ?? [] + _ = target.popToRootViewController(animated: false) + target.viewControllers.append(contentsOf: viewControllers) + } + + to.selectedIndex = from.selectedIndex + } + + private static func transform(from: UINavigationController, to: UINavigationController) { + let viewControllers = from.popToRootViewController(animated: false) ?? [] + to.viewControllers.append(contentsOf: viewControllers) + } + + // .regular to .compact + func splitViewController( + _ svc: UISplitViewController, + topColumnForCollapsingToProposedTopColumn proposedTopColumn: UISplitViewController.Column + ) -> UISplitViewController.Column { + switch proposedTopColumn { + case .compact: + RootSplitViewController.transform(from: contentSplitViewController.mainTabBarController, to: compactMainTabBarViewController) + compactMainTabBarViewController.currentTab.value = contentSplitViewController.currentSupplementaryTab + + default: + assertionFailure() + } + + return proposedTopColumn + } + + // .compact to .regular + func splitViewController( + _ svc: UISplitViewController, + displayModeForExpandingToProposedDisplayMode proposedDisplayMode: UISplitViewController.DisplayMode + ) -> UISplitViewController.DisplayMode { + let compactNavigationController = compactMainTabBarViewController.selectedViewController as? UINavigationController + + if let topMost = compactNavigationController?.topMost, + topMost is AccountListViewController { + topMost.dismiss(animated: false, completion: nil) + } + + RootSplitViewController.transform(from: compactMainTabBarViewController, to: contentSplitViewController.mainTabBarController) + + let tab = compactMainTabBarViewController.currentTab.value + if tab == .search { + contentSplitViewController.currentSupplementaryTab = .home + } else { + contentSplitViewController.currentSupplementaryTab = compactMainTabBarViewController.currentTab.value + } + + return proposedDisplayMode + } + +} + +// MARK: - WizardViewControllerDelegate +extension RootSplitViewController: WizardViewControllerDelegate { + + func readyToLayoutItem(_ wizardViewController: WizardViewController, item: WizardViewController.Item) -> Bool { + guard traitCollection.horizontalSizeClass != .compact else { + return compactMainTabBarViewController.readyToLayoutItem(wizardViewController, item: item) + } + + switch item { + case .multipleAccountSwitch: + return contentSplitViewController.sidebarViewController.viewModel.isReadyForWizardAvatarButton + } + } + + + func layoutSpotlight(_ wizardViewController: WizardViewController, item: WizardViewController.Item) -> UIBezierPath { + guard traitCollection.horizontalSizeClass != .compact else { + return compactMainTabBarViewController.layoutSpotlight(wizardViewController, item: item) + } + + switch item { + case .multipleAccountSwitch: + guard let frame = avatarButtonFrameInWizardView(wizardView: wizardViewController.view) + else { + assertionFailure() + return UIBezierPath() + } + return UIBezierPath(ovalIn: frame) + } + } + + func layoutWizardCard(_ wizardViewController: WizardViewController, item: WizardViewController.Item) { + guard traitCollection.horizontalSizeClass != .compact else { + return compactMainTabBarViewController.layoutWizardCard(wizardViewController, item: item) + } + + guard let frame = avatarButtonFrameInWizardView(wizardView: wizardViewController.view) else { + return + } + + let anchorView = UIView() + anchorView.frame = frame + wizardViewController.backgroundView.addSubview(anchorView) + + let wizardCardView = WizardCardView() + wizardCardView.arrowRectCorner = .allCorners // no arrow + wizardCardView.titleLabel.text = item.title + wizardCardView.descriptionLabel.text = item.description + + wizardCardView.translatesAutoresizingMaskIntoConstraints = false + wizardViewController.backgroundView.addSubview(wizardCardView) + NSLayoutConstraint.activate([ + wizardCardView.centerYAnchor.constraint(equalTo: anchorView.centerYAnchor), + wizardCardView.leadingAnchor.constraint(equalTo: anchorView.trailingAnchor, constant: 20), // 20pt spacing + wizardCardView.widthAnchor.constraint(equalToConstant: 320), + ]) + wizardCardView.setContentHuggingPriority(.defaultLow, for: .vertical) + } + + private func avatarButtonFrameInWizardView(wizardView: UIView) -> CGRect? { + guard let diffableDataSource = contentSplitViewController.sidebarViewController.viewModel.diffableDataSource, + let indexPath = diffableDataSource.indexPath(for: .tab(.me)), + let cell = contentSplitViewController.sidebarViewController.collectionView.cellForItem(at: indexPath) as? SidebarListCollectionViewCell, + let contentView = cell._contentView, + let frame = sourceViewFrameInTargetView( + sourceView: contentView.avatarButton, + targetView: wizardView + ) + else { + assertionFailure() + return nil + } + return frame + } + + private func sourceViewFrameInTargetView( + sourceView: UIView, + targetView: UIView + ) -> CGRect? { + guard let superview = sourceView.superview else { + assertionFailure() + return nil + } + return superview.convert(sourceView.frame, to: targetView) + } +} diff --git a/Mastodon/Scene/Root/Sidebar/SecondaryPlaceholderViewController.swift b/Mastodon/Scene/Root/Sidebar/SecondaryPlaceholderViewController.swift new file mode 100644 index 000000000..a381844df --- /dev/null +++ b/Mastodon/Scene/Root/Sidebar/SecondaryPlaceholderViewController.swift @@ -0,0 +1,36 @@ +// +// SecondaryPlaceholderViewController.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-9-29. +// + +import UIKit +import Combine + +final class SecondaryPlaceholderViewController: UIViewController { + var disposeBag = Set() +} + +extension SecondaryPlaceholderViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + setupBackground(theme: ThemeService.shared.currentTheme.value) + ThemeService.shared.currentTheme + .receive(on: DispatchQueue.main) + .sink { [weak self] theme in + guard let self = self else { return } + self.setupBackground(theme: theme) + } + .store(in: &disposeBag) + } + +} + +extension SecondaryPlaceholderViewController { + private func setupBackground(theme: Theme) { + view.backgroundColor = theme.secondarySystemBackgroundColor + } +} diff --git a/Mastodon/Scene/Root/Sidebar/SidebarViewController.swift b/Mastodon/Scene/Root/Sidebar/SidebarViewController.swift new file mode 100644 index 000000000..b5f67e769 --- /dev/null +++ b/Mastodon/Scene/Root/Sidebar/SidebarViewController.swift @@ -0,0 +1,213 @@ +// +// SidebarViewController.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-9-22. +// + +import os.log +import UIKit +import Combine +import CoreDataStack + +protocol SidebarViewControllerDelegate: AnyObject { + func sidebarViewController(_ sidebarViewController: SidebarViewController, didSelectTab tab: MainTabBarController.Tab) + func sidebarViewController(_ sidebarViewController: SidebarViewController, didLongPressItem item: SidebarViewModel.Item, sourceView: UIView) +} + +final class SidebarViewController: UIViewController, NeedsDependency { + + let logger = Logger(subsystem: "SidebarViewController", category: "ViewController") + + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + var disposeBag = Set() + var observations = Set() + var viewModel: SidebarViewModel! + + weak var delegate: SidebarViewControllerDelegate? + + static func createLayout() -> UICollectionViewLayout { + let layout = UICollectionViewCompositionalLayout() { (sectionIndex, layoutEnvironment) -> NSCollectionLayoutSection? in + var configuration = UICollectionLayoutListConfiguration(appearance: .sidebar) + configuration.backgroundColor = .clear + configuration.showsSeparators = false + let section = NSCollectionLayoutSection.list(using: configuration, layoutEnvironment: layoutEnvironment) + switch sectionIndex { + case 0: + let header = NSCollectionLayoutBoundarySupplementaryItem( + layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), + heightDimension: .estimated(100)), + elementKind: UICollectionView.elementKindSectionHeader, + alignment: .top + ) + section.boundarySupplementaryItems = [header] + default: + break + } + return section + } + return layout + } + + let collectionView: UICollectionView = { + let layout = SidebarViewController.createLayout() + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + collectionView.alwaysBounceVertical = false + collectionView.backgroundColor = .clear + return collectionView + }() + + static func createSecondaryLayout() -> UICollectionViewLayout { + let layout = UICollectionViewCompositionalLayout() { (sectionIndex, layoutEnvironment) -> NSCollectionLayoutSection? in + var configuration = UICollectionLayoutListConfiguration(appearance: .sidebar) + configuration.backgroundColor = .clear + configuration.showsSeparators = false + let section = NSCollectionLayoutSection.list(using: configuration, layoutEnvironment: layoutEnvironment) + return section + } + return layout + } + + let secondaryCollectionView: UICollectionView = { + let layout = SidebarViewController.createSecondaryLayout() + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + collectionView.isScrollEnabled = false + collectionView.alwaysBounceVertical = false + collectionView.backgroundColor = .clear + return collectionView + }() + var secondaryCollectionViewHeightLayoutConstraint: NSLayoutConstraint! +} + +extension SidebarViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + navigationController?.setNavigationBarHidden(true, animated: false) + + setupBackground(theme: ThemeService.shared.currentTheme.value) + ThemeService.shared.currentTheme + .receive(on: DispatchQueue.main) + .sink { [weak self] theme in + guard let self = self else { return } + self.setupBackground(theme: theme) + } + .store(in: &disposeBag) + + collectionView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(collectionView) + NSLayoutConstraint.activate([ + collectionView.topAnchor.constraint(equalTo: view.topAnchor), + collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + secondaryCollectionView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(secondaryCollectionView) + secondaryCollectionViewHeightLayoutConstraint = secondaryCollectionView.heightAnchor.constraint(equalToConstant: 44).priority(.required - 1) + NSLayoutConstraint.activate([ + secondaryCollectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + secondaryCollectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + view.bottomAnchor.constraint(equalTo: secondaryCollectionView.bottomAnchor), + secondaryCollectionViewHeightLayoutConstraint, + ]) + + collectionView.delegate = self + secondaryCollectionView.delegate = self + viewModel.setupDiffableDataSource( + collectionView: collectionView, + secondaryCollectionView: secondaryCollectionView + ) + + secondaryCollectionView.observe(\.contentSize, options: [.initial, .new]) { [weak self] secondaryCollectionView, _ in + guard let self = self else { return } + + let contentHeight = secondaryCollectionView.contentSize.height + guard contentHeight > 0 else { return } + self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): secondaryCollectionView contentSize: \(secondaryCollectionView.contentSize.debugDescription)") + + let currentFrameHeight = secondaryCollectionView.frame.height + guard currentFrameHeight < contentHeight else { return } + + self.secondaryCollectionViewHeightLayoutConstraint.constant = contentHeight + self.collectionView.contentInset.bottom = contentHeight + } + .store(in: &observations) + + let sidebarLongPressGestureRecognizer = UILongPressGestureRecognizer() + sidebarLongPressGestureRecognizer.addTarget(self, action: #selector(SidebarViewController.sidebarLongPressGestureRecognizerHandler(_:))) + collectionView.addGestureRecognizer(sidebarLongPressGestureRecognizer) + } + + private func setupBackground(theme: Theme) { + let color: UIColor = theme.sidebarBackgroundColor + view.backgroundColor = color + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + + coordinator.animate { context in + self.collectionView.collectionViewLayout.invalidateLayout() + } completion: { [weak self] context in +// guard let self = self else { return } + } + + } + +} + +extension SidebarViewController { + @objc private func sidebarLongPressGestureRecognizerHandler(_ sender: UILongPressGestureRecognizer) { + guard sender.state == .began else { return } + + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") + assert(sender.view === collectionView) + + let position = sender.location(in: collectionView) + guard let indexPath = collectionView.indexPathForItem(at: position) else { return } + guard let diffableDataSource = viewModel.diffableDataSource else { return } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } + guard let cell = collectionView.cellForItem(at: indexPath) else { return } + delegate?.sidebarViewController(self, didLongPressItem: item, sourceView: cell) + } + +} + +// MARK: - UICollectionViewDelegate +extension SidebarViewController: UICollectionViewDelegate { + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + switch collectionView { + case self.collectionView: + guard let diffableDataSource = viewModel.diffableDataSource else { return } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } + switch item { + case .tab(let tab): + delegate?.sidebarViewController(self, didSelectTab: tab) + case .setting: + guard let setting = context.settingService.currentSetting.value else { return } + let settingsViewModel = SettingsViewModel(context: context, setting: setting) + coordinator.present(scene: .settings(viewModel: settingsViewModel), from: self, transition: .modal(animated: true, completion: nil)) + case .compose: + assertionFailure() + } + case secondaryCollectionView: + guard let diffableDataSource = viewModel.secondaryDiffableDataSource else { return } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } + switch item { + case .compose: + let composeViewModel = ComposeViewModel(context: context, composeKind: .post) + coordinator.present(scene: .compose(viewModel: composeViewModel), from: self, transition: .modal(animated: true, completion: nil)) + default: + assertionFailure() + } + default: + assertionFailure() + } + } +} diff --git a/Mastodon/Scene/Root/Sidebar/SidebarViewModel.swift b/Mastodon/Scene/Root/Sidebar/SidebarViewModel.swift new file mode 100644 index 000000000..37b46932b --- /dev/null +++ b/Mastodon/Scene/Root/Sidebar/SidebarViewModel.swift @@ -0,0 +1,215 @@ +// +// SidebarViewModel.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-9-22. +// + +import UIKit +import Combine +import CoreData +import CoreDataStack +import Meta +import MastodonMeta + +final class SidebarViewModel { + + var disposeBag = Set() + + // input + let context: AppContext + @Published private var isSidebarDataSourceReady = false + @Published private var isAvatarButtonDataReady = false + + // output + var diffableDataSource: UICollectionViewDiffableDataSource? + var secondaryDiffableDataSource: UICollectionViewDiffableDataSource? + @Published private(set) var isReadyForWizardAvatarButton = false + + let activeMastodonAuthenticationObjectID = CurrentValueSubject(nil) + + init(context: AppContext) { + self.context = context + + Publishers.CombineLatest( + $isSidebarDataSourceReady, + $isAvatarButtonDataReady + ) + .map { $0 && $1 } + .assign(to: &$isReadyForWizardAvatarButton) + + context.authenticationService.activeMastodonAuthentication + .sink { [weak self] authentication in + guard let self = self else { return } + + // bind objectID + self.activeMastodonAuthenticationObjectID.value = authentication?.objectID + + self.isAvatarButtonDataReady = authentication != nil + } + .store(in: &disposeBag) + } + +} + +extension SidebarViewModel { + enum Section: Int, Hashable, CaseIterable { + case main + case secondary + } + + enum Item: Hashable { + case tab(MainTabBarController.Tab) + case setting + case compose + } + +} + +extension SidebarViewModel { + func setupDiffableDataSource( + collectionView: UICollectionView, + secondaryCollectionView: UICollectionView + ) { + let tabCellRegistration = UICollectionView.CellRegistration { [weak self] cell, indexPath, item in + guard let self = self else { return } + + let imageURL: URL? = { + switch item { + case .me: + let authentication = self.context.authenticationService.activeMastodonAuthentication.value + return authentication?.user.avatarImageURL() + default: + return nil + } + }() + cell.item = SidebarListContentView.Item( + title: item.title, + image: item.sidebarImage, + imageURL: imageURL + ) + cell.setNeedsUpdateConfiguration() + cell.isAccessibilityElement = true + cell.accessibilityLabel = item.title + + switch item { + case .notification: + Publishers.CombineLatest( + self.context.authenticationService.activeMastodonAuthentication, + self.context.notificationService.unreadNotificationCountDidUpdate + ) + .receive(on: DispatchQueue.main) + .sink { [weak cell] authentication, _ in + guard let cell = cell else { return } + let hasUnreadPushNotification: Bool = authentication.flatMap { authentication in + let count = UserDefaults.shared.getNotificationCountWithAccessToken(accessToken: authentication.userAccessToken) + return count > 0 + } ?? false + + let image = hasUnreadPushNotification ? UIImage(systemName: "bell.badge")! : UIImage(systemName: "bell")! + cell._contentView?.imageView.image = image + } + .store(in: &cell.disposeBag) + case .me: + guard let authentication = self.context.authenticationService.activeMastodonAuthentication.value else { break } + let currentUserDisplayName = authentication.user.displayNameWithFallback ?? "no user" + cell.accessibilityHint = L10n.Scene.AccountList.tabBarHint(currentUserDisplayName) + default: + break + } + } + + let cellRegistration = UICollectionView.CellRegistration { [weak self] cell, indexPath, item in + guard let self = self else { return } + cell.item = item + cell.setNeedsUpdateConfiguration() + cell.isAccessibilityElement = true + cell.accessibilityLabel = item.title + } + + // header + let headerRegistration = UICollectionView.SupplementaryRegistration(elementKind: UICollectionView.elementKindSectionHeader) { supplementaryView, elementKind, indexPath in + // do nothing + } + + let _diffableDataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, item in + switch item { + case .tab(let tab): + return collectionView.dequeueConfiguredReusableCell(using: tabCellRegistration, for: indexPath, item: tab) + case .setting: + let item = SidebarListContentView.Item( + title: L10n.Common.Controls.Actions.settings, + image: UIImage(systemName: "gear")!, + imageURL: nil + ) + return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: item) + case .compose: + let item = SidebarListContentView.Item( + title: L10n.Common.Controls.Actions.compose, + image: UIImage(systemName: "square.and.pencil")!, + imageURL: nil + ) + return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: item) + } + } + _diffableDataSource.supplementaryViewProvider = { collectionView, elementKind, indexPath in + switch elementKind { + case UICollectionView.elementKindSectionHeader: + return collectionView.dequeueConfiguredReusableSupplementary(using: headerRegistration, for: indexPath) + default: + assertionFailure() + return UICollectionReusableView() + } + } + diffableDataSource = _diffableDataSource + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + + var sectionSnapshot = NSDiffableDataSourceSectionSnapshot() + let items: [Item] = [ + .tab(.home), + .tab(.search), + .tab(.notification), + .tab(.me), + .setting, + ] + sectionSnapshot.append(items, to: nil) + // animatingDifferences must to be `true` + // otherwise the UI layout will infinity loop + _diffableDataSource.apply(sectionSnapshot, to: .main, animatingDifferences: true) { [weak self] in + guard let self = self else { return } + self.isSidebarDataSourceReady = true + } + + // secondary + let _secondaryDiffableDataSource = UICollectionViewDiffableDataSource(collectionView: secondaryCollectionView) { collectionView, indexPath, item in + guard case .compose = item else { + assertionFailure() + return UICollectionViewCell() + } + + let item = SidebarListContentView.Item( + title: L10n.Common.Controls.Actions.compose, + image: UIImage(systemName: "square.and.pencil")!, + imageURL: nil + ) + return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: item) + } +// _secondaryDiffableDataSource.supplementaryViewProvider = { collectionView, elementKind, indexPath in +// return nil +// } + secondaryDiffableDataSource = _secondaryDiffableDataSource + + var secondarySnapshot = NSDiffableDataSourceSnapshot() + secondarySnapshot.appendSections([.secondary]) + + var secondarySectionSnapshot = NSDiffableDataSourceSectionSnapshot() + let secondarySectionItems: [Item] = [ + .compose, + ] + secondarySectionSnapshot.append(secondarySectionItems, to: nil) + _secondaryDiffableDataSource.apply(secondarySectionSnapshot, to: .secondary) + } + +} diff --git a/Mastodon/Scene/Root/Sidebar/View/SidebarAddAccountCollectionViewCell.swift b/Mastodon/Scene/Root/Sidebar/View/SidebarAddAccountCollectionViewCell.swift new file mode 100644 index 000000000..72b2577f1 --- /dev/null +++ b/Mastodon/Scene/Root/Sidebar/View/SidebarAddAccountCollectionViewCell.swift @@ -0,0 +1,43 @@ +// +// SidebarAddAccountCollectionViewCell.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-9-27. +// + +import UIKit + +final class SidebarAddAccountCollectionViewCell: UICollectionViewListCell { + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension SidebarAddAccountCollectionViewCell { + + private func _init() { } + + override func updateConfiguration(using state: UICellConfigurationState) { + super.updateConfiguration(using: state) + + var newBackgroundConfiguration = UIBackgroundConfiguration.listSidebarCell().updated(for: state) + + // Customize the background color to use the tint color when the cell is highlighted or selected. + if state.isSelected || state.isHighlighted { + newBackgroundConfiguration.backgroundColor = Asset.Colors.brandBlue.color + } + if state.isHighlighted { + newBackgroundConfiguration.backgroundColorTransformer = .init { $0.withAlphaComponent(0.8) } + } + + backgroundConfiguration = newBackgroundConfiguration + } +} diff --git a/Mastodon/Scene/Root/Sidebar/View/SidebarListCollectionViewCell.swift b/Mastodon/Scene/Root/Sidebar/View/SidebarListCollectionViewCell.swift new file mode 100644 index 000000000..998d3f9e2 --- /dev/null +++ b/Mastodon/Scene/Root/Sidebar/View/SidebarListCollectionViewCell.swift @@ -0,0 +1,59 @@ +// +// SidebarListTableViewCell.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-9-24. +// + +import UIKit +import Combine + +final class SidebarListCollectionViewCell: UICollectionViewListCell { + + var disposeBag = Set() + + var item: SidebarListContentView.Item? + + var _contentView: SidebarListContentView? { + guard let view = contentView as? SidebarListContentView else { + assertionFailure() + return nil + } + + return view + } + + override func prepareForReuse() { + super.prepareForReuse() + + disposeBag.removeAll() + } + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension SidebarListCollectionViewCell { + private func _init() { + + } + + override func updateConfiguration(using state: UICellConfigurationState) { + var newConfiguration = SidebarListContentView.ContentConfiguration().updated(for: state) + newConfiguration.item = item + contentConfiguration = newConfiguration + + // remove background + var newBackgroundConfiguration = UIBackgroundConfiguration.listSidebarCell().updated(for: state) + newBackgroundConfiguration.backgroundColor = .clear + backgroundConfiguration = newBackgroundConfiguration + } +} diff --git a/Mastodon/Scene/Root/Sidebar/View/SidebarListContentView.swift b/Mastodon/Scene/Root/Sidebar/View/SidebarListContentView.swift new file mode 100644 index 000000000..d6ae40e17 --- /dev/null +++ b/Mastodon/Scene/Root/Sidebar/View/SidebarListContentView.swift @@ -0,0 +1,170 @@ +// +// SidebarListContentView.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-9-24. +// + +import os.log +import UIKit +import MetaTextKit +import FLAnimatedImage + +final class SidebarListContentView: UIView, UIContentView { + + let logger = Logger(subsystem: "SidebarListContentView", category: "UI") + + let imageView = UIImageView() + let avatarButton: CircleAvatarButton = { + let button = CircleAvatarButton() + button.borderWidth = 2 + button.borderColor = UIColor.label + return button + }() + + private var currentConfiguration: ContentConfiguration! + var configuration: UIContentConfiguration { + get { + currentConfiguration + } + set { + guard let newConfiguration = newValue as? ContentConfiguration else { return } + apply(configuration: newConfiguration) + } + } + + init(configuration: ContentConfiguration) { + super.init(frame: .zero) + + _init() + apply(configuration: configuration) + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension SidebarListContentView { + private func _init() { + imageView.translatesAutoresizingMaskIntoConstraints = false + addSubview(imageView) + NSLayoutConstraint.activate([ + imageView.topAnchor.constraint(equalTo: topAnchor, constant: 16), + imageView.centerXAnchor.constraint(equalTo: centerXAnchor), + bottomAnchor.constraint(equalTo: imageView.bottomAnchor, constant: 16), + imageView.widthAnchor.constraint(equalToConstant: 40).priority(.required - 1), + imageView.heightAnchor.constraint(equalToConstant: 40).priority(.required - 1), + ]) + + avatarButton.translatesAutoresizingMaskIntoConstraints = false + addSubview(avatarButton) + NSLayoutConstraint.activate([ + avatarButton.centerXAnchor.constraint(equalTo: imageView.centerXAnchor), + avatarButton.centerYAnchor.constraint(equalTo: imageView.centerYAnchor), + avatarButton.widthAnchor.constraint(equalTo: imageView.widthAnchor, multiplier: 1.0).priority(.required - 2), + avatarButton.heightAnchor.constraint(equalTo: imageView.heightAnchor, multiplier: 1.0).priority(.required - 2), + ]) + avatarButton.setContentHuggingPriority(.defaultLow - 10, for: .vertical) + avatarButton.setContentHuggingPriority(.defaultLow - 10, for: .horizontal) + + imageView.contentMode = .scaleAspectFit + avatarButton.contentMode = .scaleAspectFit + + imageView.isUserInteractionEnabled = false + avatarButton.isUserInteractionEnabled = false + } + + private func apply(configuration: ContentConfiguration) { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") + + guard currentConfiguration != configuration else { return } + currentConfiguration = configuration + + guard let item = configuration.item else { return } + + // configure state + let tintColor = item.isHighlighted ? ThemeService.tintColor.withAlphaComponent(0.5) : ThemeService.tintColor + imageView.tintColor = tintColor + avatarButton.tintColor = tintColor + + // configure model + imageView.isHidden = item.imageURL != nil + avatarButton.isHidden = item.imageURL == nil + imageView.image = item.image.withRenderingMode(.alwaysTemplate) + avatarButton.avatarImageView.setImage( + url: item.imageURL, + placeholder: avatarButton.avatarImageView.image ?? .placeholder(color: .systemFill), // reuse to avoid blink + scaleToSize: nil + ) + } +} + +extension SidebarListContentView { + struct Item: Hashable { + // state + var isSelected: Bool = false + var isHighlighted: Bool = false + + // model + let title: String + let image: UIImage + let imageURL: URL? + + static func == (lhs: SidebarListContentView.Item, rhs: SidebarListContentView.Item) -> Bool { + return lhs.isSelected == rhs.isSelected + && lhs.isHighlighted == rhs.isHighlighted + && lhs.title == rhs.title + && lhs.image == rhs.image + && lhs.imageURL == rhs.imageURL + } + + func hash(into hasher: inout Hasher) { + hasher.combine(isSelected) + hasher.combine(isHighlighted) + hasher.combine(title) + hasher.combine(image) + imageURL.flatMap { hasher.combine($0) } + } + } + + struct ContentConfiguration: UIContentConfiguration, Hashable { + let logger = Logger(subsystem: "SidebarListContentView.ContentConfiguration", category: "ContentConfiguration") + + var item: Item? + + func makeContentView() -> UIView & UIContentView { + SidebarListContentView(configuration: self) + } + + func updated(for state: UIConfigurationState) -> ContentConfiguration { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") + + var updatedConfiguration = self + + if let state = state as? UICellConfigurationState { + updatedConfiguration.item?.isSelected = state.isHighlighted || state.isSelected + updatedConfiguration.item?.isHighlighted = state.isHighlighted + } else { + assertionFailure() + updatedConfiguration.item?.isSelected = false + updatedConfiguration.item?.isHighlighted = false + } + + return updatedConfiguration + } + + static func == ( + lhs: ContentConfiguration, + rhs: ContentConfiguration + ) -> Bool { + return lhs.item == rhs.item + } + + func hash(into hasher: inout Hasher) { + hasher.combine(item) + } + } +} diff --git a/Mastodon/Scene/Root/Sidebar/View/SidebarListHeaderView.swift b/Mastodon/Scene/Root/Sidebar/View/SidebarListHeaderView.swift new file mode 100644 index 000000000..6a1bb3ddf --- /dev/null +++ b/Mastodon/Scene/Root/Sidebar/View/SidebarListHeaderView.swift @@ -0,0 +1,43 @@ +// +// SidebarListHeaderView.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-10-28. +// + +import UIKit + +final class SidebarListHeaderView: UICollectionReusableView { + + let imageView: UIImageView = { + let imageView = UIImageView() + imageView.image = Asset.Scene.Sidebar.logo.image.withRenderingMode(.alwaysTemplate) + imageView.tintColor = .label + return imageView + }() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension SidebarListHeaderView { + private func _init() { + imageView.translatesAutoresizingMaskIntoConstraints = false + addSubview(imageView) + NSLayoutConstraint.activate([ + imageView.topAnchor.constraint(equalTo: topAnchor, constant: 8), + imageView.centerXAnchor.constraint(equalTo: centerXAnchor), + bottomAnchor.constraint(equalTo: imageView.bottomAnchor, constant: 16), + imageView.widthAnchor.constraint(equalToConstant: 44).priority(.required - 1), + imageView.heightAnchor.constraint(equalToConstant: 44).priority(.required - 1), + ]) + } +} diff --git a/Mastodon/Scene/Search/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift b/Mastodon/Scene/Search/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift index 365c1ee72..2b0c4736d 100644 --- a/Mastodon/Scene/Search/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift +++ b/Mastodon/Scene/Search/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift @@ -167,6 +167,8 @@ extension SearchRecommendAccountsCollectionViewCell { containerStackView.addArrangedSubview(followButton) followButton.addTarget(self, action: #selector(SearchRecommendAccountsCollectionViewCell.followButtonDidPressed(_:)), for: .touchUpInside) + + displayNameLabel.isUserInteractionEnabled = false } } diff --git a/Mastodon/Scene/Search/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift b/Mastodon/Scene/Search/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift index e555e1fac..3a20788b5 100644 --- a/Mastodon/Scene/Search/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift +++ b/Mastodon/Scene/Search/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift @@ -27,16 +27,11 @@ class SearchRecommendTagsCollectionViewCell: UICollectionViewCell { let label = UILabel() label.textColor = .white label.font = .preferredFont(forTextStyle: .body) + label.numberOfLines = 2 return label }() - let flameIconView: UIImageView = { - let imageView = UIImageView() - let image = UIImage(systemName: "flame.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .semibold))!.withRenderingMode(.alwaysTemplate) - imageView.image = image - imageView.tintColor = .white - return imageView - }() + let lineChartView = LineChartView() override func prepareForReuse() { super.prepareForReuse() @@ -98,41 +93,52 @@ extension SearchRecommendTagsCollectionViewCell { containerStackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), containerStackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor) ]) - - - let horizontalStackView = UIStackView() - horizontalStackView.axis = .horizontal - horizontalStackView.translatesAutoresizingMaskIntoConstraints = false - horizontalStackView.distribution = .fill - hashtagTitleLabel.translatesAutoresizingMaskIntoConstraints = false - hashtagTitleLabel.setContentHuggingPriority(.defaultLow - 1, for: .horizontal) - horizontalStackView.addArrangedSubview(hashtagTitleLabel) - horizontalStackView.setContentHuggingPriority(.required - 1, for: .vertical) - - flameIconView.translatesAutoresizingMaskIntoConstraints = false - horizontalStackView.addArrangedSubview(flameIconView) - flameIconView.setContentHuggingPriority(.required - 1, for: .horizontal) - - containerStackView.addArrangedSubview(horizontalStackView) - peopleLabel.translatesAutoresizingMaskIntoConstraints = false - peopleLabel.setContentHuggingPriority(.defaultLow - 1, for: .vertical) + containerStackView.addArrangedSubview(hashtagTitleLabel) containerStackView.addArrangedSubview(peopleLabel) - containerStackView.setCustomSpacing(SearchViewController.hashtagPeopleTalkingLabelTop, after: horizontalStackView) + + let lineChartContainer = UIView() + lineChartContainer.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(lineChartContainer) + NSLayoutConstraint.activate([ + lineChartContainer.topAnchor.constraint(equalTo: containerStackView.bottomAnchor, constant: 12), + lineChartContainer.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: lineChartContainer.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: lineChartContainer.bottomAnchor, constant: 12), + ]) + lineChartContainer.layer.masksToBounds = true + + lineChartView.translatesAutoresizingMaskIntoConstraints = false + lineChartContainer.addSubview(lineChartView) + NSLayoutConstraint.activate([ + lineChartView.topAnchor.constraint(equalTo: lineChartContainer.topAnchor, constant: 4), + lineChartView.leadingAnchor.constraint(equalTo: lineChartContainer.leadingAnchor), + lineChartView.trailingAnchor.constraint(equalTo: lineChartContainer.trailingAnchor), + lineChartContainer.bottomAnchor.constraint(equalTo: lineChartView.bottomAnchor, constant: 4), + ]) + } func config(with tag: Mastodon.Entity.Tag) { hashtagTitleLabel.text = "# " + tag.name - guard let historys = tag.history else { + guard let history = tag.history else { peopleLabel.text = "" return } - let recentHistory = historys.prefix(2) + let recentHistory = history.prefix(2) let peopleAreTalking = recentHistory.compactMap({ Int($0.accounts) }).reduce(0, +) let string = L10n.Scene.Search.Recommend.HashTag.peopleTalking(String(peopleAreTalking)) peopleLabel.text = string - + + lineChartView.data = history + .sorted(by: { $0.day < $1.day }) // latest last + .map { entry in + guard let point = Int(entry.accounts) else { + return .zero + } + return CGFloat(point) + } } } diff --git a/Mastodon/Scene/Search/Search/SearchViewController.swift b/Mastodon/Scene/Search/Search/SearchViewController.swift index a3d84cd6a..8dcf9cd3b 100644 --- a/Mastodon/Scene/Search/Search/SearchViewController.swift +++ b/Mastodon/Scene/Search/Search/SearchViewController.swift @@ -11,6 +11,12 @@ import GameplayKit import MastodonSDK import UIKit +final class HeightFixedSearchBar: UISearchBar { + override var intrinsicContentSize: CGSize { + return CGSize(width: CGFloat.greatestFiniteMagnitude, height: 44) + } +} + final class SearchViewController: UIViewController, NeedsDependency { let logger = Logger(subsystem: "Search", category: "UI") @@ -41,6 +47,11 @@ final class SearchViewController: UIViewController, NeedsDependency { var disposeBag = Set() private(set) lazy var viewModel = SearchViewModel(context: context) + + // use AutoLayout could set search bar margin automatically to + // layout alongside with split mode button (on iPad) + let titleViewContainer = UIView() + let searchBar = HeightFixedSearchBar() // recommend let scrollView: UIScrollView = { @@ -83,6 +94,10 @@ final class SearchViewController: UIViewController, NeedsDependency { }() let searchBarTapPublisher = PassthroughSubject() + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } } @@ -112,6 +127,10 @@ extension SearchViewController { super.viewDidAppear(animated) viewModel.viewDidAppeared.send() + + // note: + // need set alpha because (maybe) SDK forget set alpha back + titleViewContainer.alpha = 1 } } @@ -121,10 +140,17 @@ extension SearchViewController { } private func setupSearchBar() { - let searchBar = UISearchBar() searchBar.placeholder = L10n.Scene.Search.SearchBar.placeholder searchBar.delegate = self - navigationItem.titleView = searchBar + searchBar.translatesAutoresizingMaskIntoConstraints = false + titleViewContainer.addSubview(searchBar) + NSLayoutConstraint.activate([ + searchBar.topAnchor.constraint(equalTo: titleViewContainer.topAnchor), + searchBar.leadingAnchor.constraint(equalTo: titleViewContainer.leadingAnchor), + searchBar.trailingAnchor.constraint(equalTo: titleViewContainer.trailingAnchor), + searchBar.bottomAnchor.constraint(equalTo: titleViewContainer.bottomAnchor), + ]) + navigationItem.titleView = titleViewContainer searchBarTapPublisher .throttle(for: 0.5, scheduler: DispatchQueue.main, latest: false) diff --git a/Mastodon/Scene/Search/Search/View/LineChartView.swift b/Mastodon/Scene/Search/Search/View/LineChartView.swift new file mode 100644 index 000000000..a64aa270d --- /dev/null +++ b/Mastodon/Scene/Search/Search/View/LineChartView.swift @@ -0,0 +1,120 @@ +// +// LineChartView.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-10-18. +// + +import UIKit +import Accelerate +import simd + +final class LineChartView: UIView { + + var data: [CGFloat] = [] { + didSet { + setNeedsLayout() + } + } + + let lineShapeLayer = CAShapeLayer() + let gradientLayer = CAGradientLayer() +// let dotShapeLayer = CAShapeLayer() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension LineChartView { + private func _init() { + lineShapeLayer.frame = bounds + gradientLayer.frame = bounds +// dotShapeLayer.frame = bounds + layer.addSublayer(lineShapeLayer) + layer.addSublayer(gradientLayer) +// layer.addSublayer(dotShapeLayer) + + gradientLayer.colors = [ + UIColor.white.withAlphaComponent(0.5).cgColor, + UIColor.white.withAlphaComponent(0).cgColor, + ] + gradientLayer.startPoint = CGPoint(x: 0.5, y: 0) + gradientLayer.endPoint = CGPoint(x: 0.5, y: 1) + } + + override func layoutSubviews() { + super.layoutSubviews() + + lineShapeLayer.frame = bounds + gradientLayer.frame = bounds +// dotShapeLayer.frame = bounds + + guard data.count > 1 else { + lineShapeLayer.path = nil +// dotShapeLayer.path = nil + gradientLayer.isHidden = true + return + } + gradientLayer.isHidden = false + + // Draw smooth chart + guard let maxDataPoint = data.max() else { + return + } + func calculateY(for point: CGFloat, in frame: CGRect) -> CGFloat { + guard maxDataPoint > 0 else { return .zero } + return (1 - point / maxDataPoint) * frame.height + } + + let segmentCount = data.count - 1 + let segmentWidth = bounds.width / CGFloat(segmentCount) + + let points: [CGPoint] = { + var points: [CGPoint] = [] + var x: CGFloat = 0 + for value in data { + let point = CGPoint(x: x, y: calculateY(for: value, in: bounds)) + points.append(point) + x += segmentWidth + } + return points + }() + + guard let linePath = CurveAlgorithm.shared.createCurvedPath(points) else { return } + let dotPath = UIBezierPath() + + if let last = points.last { + dotPath.addArc(withCenter: last, radius: 3, startAngle: 0, endAngle: 2 * .pi, clockwise: true) + } + + lineShapeLayer.lineWidth = 3 + lineShapeLayer.strokeColor = UIColor.white.cgColor + lineShapeLayer.fillColor = UIColor.clear.cgColor + lineShapeLayer.lineJoin = .round + lineShapeLayer.lineCap = .round + lineShapeLayer.path = linePath.cgPath + + let maskPath = UIBezierPath(cgPath: linePath.cgPath) + maskPath.addLine(to: CGPoint(x: bounds.maxX, y: bounds.maxY)) + maskPath.addLine(to: CGPoint(x: bounds.minX, y: bounds.maxY)) + maskPath.close() + let maskLayer = CAShapeLayer() + maskLayer.path = maskPath.cgPath + maskLayer.fillColor = UIColor.red.cgColor + maskLayer.strokeColor = UIColor.clear.cgColor + maskLayer.lineWidth = 0.0 + gradientLayer.mask = maskLayer + +// dotShapeLayer.lineWidth = 3 +// dotShapeLayer.fillColor = Asset.Colors.brandBlue.color.cgColor +// dotShapeLayer.path = dotPath.cgPath + } +} diff --git a/Mastodon/Scene/Search/SearchDetail/SearchDetailViewController.swift b/Mastodon/Scene/Search/SearchDetail/SearchDetailViewController.swift index 1debc188c..486a3b48a 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchDetailViewController.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchDetailViewController.swift @@ -10,6 +10,8 @@ import UIKit import Combine import Pageboy +// Fake search bar not works on iPad with UISplitViewController +// check device and fallback to standard UISearchController final class SearchDetailViewController: PageboyViewController, NeedsDependency { let logger = Logger(subsystem: "SearchDetail", category: "UI") @@ -19,6 +21,10 @@ final class SearchDetailViewController: PageboyViewController, NeedsDependency { weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + let isPhoneDevice: Bool = { + return UIDevice.current.userInterfaceIdiom == .phone + }() var viewModel: SearchDetailViewModel! var viewControllers: [SearchResultViewController]! @@ -39,8 +45,22 @@ final class SearchDetailViewController: PageboyViewController, NeedsDependency { navigationBar.setItems([navigationItem], animated: false) return navigationBar }() - let searchBar: UISearchBar = { - let searchBar = UISearchBar() + + let searchController: UISearchController = { + let searchController = UISearchController() + searchController.automaticallyShowsScopeBar = false + searchController.dimsBackgroundDuringPresentation = false + return searchController + }() + private(set) lazy var searchBar: UISearchBar = { + let searchBar: UISearchBar + if isPhoneDevice { + searchBar = UISearchBar(frame: CGRect(x: 0, y: 0, width: 320, height: 44)) + } else { + searchBar = searchController.searchBar + searchController.automaticallyShowsScopeBar = false + searchController.searchBar.setShowsScope(true, animated: false) + } searchBar.placeholder = L10n.Scene.Search.SearchBar.placeholder searchBar.scopeButtonTitles = SearchDetailViewModel.SearchScope.allCases.map { $0.segmentedControlTitle } searchBar.sizeToFit() @@ -71,48 +91,27 @@ extension SearchDetailViewController { } .store(in: &disposeBag) - navigationBar.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(navigationBar) - NSLayoutConstraint.activate([ - navigationBar.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor), - navigationBar.leadingAnchor.constraint(equalTo: view.leadingAnchor), - navigationBar.trailingAnchor.constraint(equalTo: view.trailingAnchor), - ]) setupSearchBar() - navigationBar.layer.observe(\.bounds, options: [.new]) { [weak self] navigationBar, _ in - guard let self = self else { return } - self.viewModel.navigationBarFrame.value = navigationBar.frame - } - .store(in: &observations) - - navigationBarBackgroundView.translatesAutoresizingMaskIntoConstraints = false - view.insertSubview(navigationBarBackgroundView, belowSubview: navigationBar) - NSLayoutConstraint.activate([ - navigationBarBackgroundView.topAnchor.constraint(equalTo: view.topAnchor), - navigationBarBackgroundView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - navigationBarBackgroundView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - navigationBarBackgroundView.bottomAnchor.constraint(equalTo: navigationBar.bottomAnchor), - ]) - - navigationBarVisualEffectBackgroundView.translatesAutoresizingMaskIntoConstraints = false - view.insertSubview(navigationBarVisualEffectBackgroundView, belowSubview: navigationBarBackgroundView) - NSLayoutConstraint.activate([ - navigationBarVisualEffectBackgroundView.topAnchor.constraint(equalTo: navigationBarBackgroundView.topAnchor), - navigationBarVisualEffectBackgroundView.leadingAnchor.constraint(equalTo: navigationBarBackgroundView.leadingAnchor), - navigationBarVisualEffectBackgroundView.trailingAnchor.constraint(equalTo: navigationBarBackgroundView.trailingAnchor), - navigationBarVisualEffectBackgroundView.bottomAnchor.constraint(equalTo: navigationBarBackgroundView.bottomAnchor), - ]) - + addChild(searchHistoryViewController) searchHistoryViewController.view.translatesAutoresizingMaskIntoConstraints = false view.addSubview(searchHistoryViewController.view) searchHistoryViewController.didMove(toParent: self) - NSLayoutConstraint.activate([ - searchHistoryViewController.view.topAnchor.constraint(equalTo: navigationBarBackgroundView.bottomAnchor), - searchHistoryViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), - searchHistoryViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), - searchHistoryViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), - ]) + if isPhoneDevice { + NSLayoutConstraint.activate([ + searchHistoryViewController.view.topAnchor.constraint(equalTo: navigationBarBackgroundView.bottomAnchor), + searchHistoryViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + searchHistoryViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + searchHistoryViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + } else { + NSLayoutConstraint.activate([ + searchHistoryViewController.view.topAnchor.constraint(equalTo: view.topAnchor), + searchHistoryViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + searchHistoryViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + searchHistoryViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + } transition = Transition(style: .fade, duration: 0.1) isScrollEnabled = false @@ -215,33 +214,85 @@ extension SearchDetailViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - navigationController?.setNavigationBarHidden(true, animated: animated) - searchBar.setShowsScope(true, animated: false) - searchBar.setNeedsLayout() - searchBar.layoutIfNeeded() + if isPhoneDevice { + navigationController?.setNavigationBarHidden(true, animated: animated) + searchBar.setShowsScope(true, animated: false) + searchBar.setNeedsLayout() + searchBar.layoutIfNeeded() + } else { + // do nothing + } } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) - if !isModal { - // prevent bar restore conflict with modal style issue - navigationController?.setNavigationBarHidden(false, animated: animated) + if isPhoneDevice { + if !isModal { + // prevent bar restore conflict with modal style issue + navigationController?.setNavigationBarHidden(false, animated: animated) + } + } else { + // do nothing } } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - searchBar.setShowsCancelButton(true, animated: animated) - searchBar.becomeFirstResponder() + if isPhoneDevice { + searchBar.setShowsCancelButton(true, animated: animated) + searchBar.becomeFirstResponder() + } else { + searchController.isActive = true + DispatchQueue.main.asyncAfter(deadline: .now() + 0.33) { + self.searchController.searchBar.becomeFirstResponder() + } + } } } extension SearchDetailViewController { private func setupSearchBar() { - navigationBar.topItem?.titleView = searchBar + if isPhoneDevice { + navigationBar.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(navigationBar) + NSLayoutConstraint.activate([ + navigationBar.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor), + navigationBar.leadingAnchor.constraint(equalTo: view.leadingAnchor), + navigationBar.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + navigationBar.topItem?.titleView = searchBar + navigationBar.layer.observe(\.bounds, options: [.new]) { [weak self] navigationBar, _ in + guard let self = self else { return } + self.viewModel.navigationBarFrame.value = navigationBar.frame + } + .store(in: &observations) + + navigationBarBackgroundView.translatesAutoresizingMaskIntoConstraints = false + view.insertSubview(navigationBarBackgroundView, belowSubview: navigationBar) + NSLayoutConstraint.activate([ + navigationBarBackgroundView.topAnchor.constraint(equalTo: view.topAnchor), + navigationBarBackgroundView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + navigationBarBackgroundView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + navigationBarBackgroundView.bottomAnchor.constraint(equalTo: navigationBar.bottomAnchor), + ]) + + navigationBarVisualEffectBackgroundView.translatesAutoresizingMaskIntoConstraints = false + view.insertSubview(navigationBarVisualEffectBackgroundView, belowSubview: navigationBarBackgroundView) + NSLayoutConstraint.activate([ + navigationBarVisualEffectBackgroundView.topAnchor.constraint(equalTo: navigationBarBackgroundView.topAnchor), + navigationBarVisualEffectBackgroundView.leadingAnchor.constraint(equalTo: navigationBarBackgroundView.leadingAnchor), + navigationBarVisualEffectBackgroundView.trailingAnchor.constraint(equalTo: navigationBarBackgroundView.trailingAnchor), + navigationBarVisualEffectBackgroundView.bottomAnchor.constraint(equalTo: navigationBarBackgroundView.bottomAnchor), + ]) + } else { + navigationItem.setHidesBackButton(true, animated: false) + navigationItem.titleView = nil + navigationItem.searchController = searchController + searchController.searchBar.sizeToFit() + } searchBar.delegate = self } diff --git a/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewModel.swift b/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewModel.swift index 934c55b1b..0ed58b07e 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewModel.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewModel.swift @@ -98,14 +98,19 @@ extension SearchHistoryViewModel { let managedObjectContext = context.backgroundManagedObjectContext managedObjectContext.performChanges { guard let user = try? managedObjectContext.existingObject(with: objectID) as? MastodonUser else { return } - if let searchHistory = user.searchHistory { + if let searchHistory = user.findSearchHistory(domain: box.domain, userID: box.userID) { searchHistory.update(updatedAt: Date()) } else { SearchHistory.insert(into: managedObjectContext, property: property, account: user) } } .sink { result in - // do nothing + switch result { + case .failure(let error): + assertionFailure(error.localizedDescription) + case .success: + break + } } .store(in: &context.disposeBag) @@ -113,14 +118,19 @@ extension SearchHistoryViewModel { let managedObjectContext = context.backgroundManagedObjectContext managedObjectContext.performChanges { guard let hashtag = try? managedObjectContext.existingObject(with: objectID) as? Tag else { return } - if let searchHistory = hashtag.searchHistory { + if let searchHistory = hashtag.findSearchHistory(domain: box.domain, userID: box.userID) { searchHistory.update(updatedAt: Date()) } else { - SearchHistory.insert(into: managedObjectContext, property: property, hashtag: hashtag) + _ = SearchHistory.insert(into: managedObjectContext, property: property, hashtag: hashtag) } } .sink { result in - // do nothing + switch result { + case .failure(let error): + assertionFailure(error.localizedDescription) + case .success: + break + } } .store(in: &context.disposeBag) diff --git a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel.swift b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel.swift index 181302a24..b22e91c8d 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel.swift @@ -146,44 +146,57 @@ extension SearchResultViewModel { let domain = box.domain switch item { - case .account(let account): + case .account(let entity): let managedObjectContext = context.backgroundManagedObjectContext managedObjectContext.performChanges { let (user, _) = APIService.CoreData.createOrMergeMastodonUser( into: managedObjectContext, for: nil, in: domain, - entity: account, + entity: entity, userCache: nil, networkDate: Date(), log: OSLog.api ) - if let searchHistory = user.searchHistory { + if let searchHistory = user.findSearchHistory(domain: box.domain, userID: box.userID) { searchHistory.update(updatedAt: Date()) } else { SearchHistory.insert(into: managedObjectContext, property: property, account: user) } } .sink { result in - // do nothing + switch result { + case .failure(let error): + assertionFailure(error.localizedDescription) + case .success: + break + } } .store(in: &context.disposeBag) - case .hashtag(let hashtag): + case .hashtag(let entity): let managedObjectContext = context.backgroundManagedObjectContext + var tag: Tag? managedObjectContext.performChanges { let (hashtag, _) = APIService.CoreData.createOrMergeTag( into: managedObjectContext, - entity: hashtag + entity: entity ) - if let searchHistory = hashtag.searchHistory { + tag = hashtag + if let searchHistory = hashtag.findSearchHistory(domain: box.domain, userID: box.userID) { searchHistory.update(updatedAt: Date()) } else { - SearchHistory.insert(into: managedObjectContext, property: property, hashtag: hashtag) + _ = SearchHistory.insert(into: managedObjectContext, property: property, hashtag: hashtag) } } .sink { result in - // do nothing + switch result { + case .failure(let error): + assertionFailure(error.localizedDescription) + case .success: + print(tag?.searchHistories) + break + } } .store(in: &context.disposeBag) diff --git a/Mastodon/Scene/Search/SearchDetail/TableViewCell/SearchResultTableViewCell.swift b/Mastodon/Scene/Search/SearchDetail/TableViewCell/SearchResultTableViewCell.swift index d0106a334..0c919e7d5 100644 --- a/Mastodon/Scene/Search/SearchDetail/TableViewCell/SearchResultTableViewCell.swift +++ b/Mastodon/Scene/Search/SearchDetail/TableViewCell/SearchResultTableViewCell.swift @@ -16,7 +16,7 @@ import MastodonMeta final class SearchResultTableViewCell: UITableViewCell { - let _imageView: AvatarImageView = { + let avatarImageView: AvatarImageView = { let imageView = AvatarImageView() imageView.tintColor = Asset.Colors.Label.primary.color imageView.layer.cornerRadius = 4 @@ -24,6 +24,13 @@ final class SearchResultTableViewCell: UITableViewCell { return imageView }() + let hashtagImageView: UIImageView = { + let imageView = UIImageView() + imageView.image = UIImage(systemName: "number.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 34, weight: .regular))!.withRenderingMode(.alwaysTemplate) + imageView.tintColor = Asset.Colors.Label.primary.color + return imageView + }() + let _titleLabel = MetaLabel(style: .statusName) let _subTitleLabel: UILabel = { @@ -43,7 +50,8 @@ final class SearchResultTableViewCell: UITableViewCell { override func prepareForReuse() { super.prepareForReuse() - _imageView.af.cancelImageRequest() + avatarImageView.af.cancelImageRequest() + setDisplayAvatarImage() } override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { @@ -74,11 +82,20 @@ extension SearchResultTableViewCell { containerStackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) ]) - _imageView.translatesAutoresizingMaskIntoConstraints = false - containerStackView.addArrangedSubview(_imageView) + avatarImageView.translatesAutoresizingMaskIntoConstraints = false + containerStackView.addArrangedSubview(avatarImageView) NSLayoutConstraint.activate([ - _imageView.widthAnchor.constraint(equalToConstant: 42).priority(.required - 1), - _imageView.heightAnchor.constraint(equalToConstant: 42).priority(.required - 1), + avatarImageView.widthAnchor.constraint(equalToConstant: 42).priority(.required - 1), + avatarImageView.heightAnchor.constraint(equalToConstant: 42).priority(.required - 1), + ]) + + hashtagImageView.translatesAutoresizingMaskIntoConstraints = false + containerStackView.addSubview(hashtagImageView) + NSLayoutConstraint.activate([ + hashtagImageView.centerXAnchor.constraint(equalTo: avatarImageView.centerXAnchor), + hashtagImageView.centerYAnchor.constraint(equalTo: avatarImageView.centerYAnchor), + hashtagImageView.widthAnchor.constraint(equalToConstant: 42).priority(.required - 1), + hashtagImageView.heightAnchor.constraint(equalToConstant: 42).priority(.required - 1), ]) let textStackView = UIStackView() @@ -104,6 +121,12 @@ extension SearchResultTableViewCell { separatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView)), ]) resetSeparatorLineLayout() + + _titleLabel.isUserInteractionEnabled = false + _subTitleLabel.isUserInteractionEnabled = false + avatarImageView.isUserInteractionEnabled = false + + setDisplayAvatarImage() } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { @@ -178,8 +201,7 @@ extension SearchResultTableViewCell { func config(with tag: Mastodon.Entity.Tag) { configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: nil)) - let image = UIImage(systemName: "number.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 34, weight: .regular))!.withRenderingMode(.alwaysTemplate) - _imageView.image = image + setDisplayHashtagImage() let metaContent = PlaintextMetaContent(string: "#" + tag.name) _titleLabel.configure(content: metaContent) guard let histories = tag.history else { @@ -194,8 +216,7 @@ extension SearchResultTableViewCell { func config(with tag: Tag) { configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: nil)) - let image = UIImage(systemName: "number.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 34, weight: .regular))!.withRenderingMode(.alwaysTemplate) - _imageView.image = image + setDisplayHashtagImage() let metaContent = PlaintextMetaContent(string: "#" + tag.name) _titleLabel.configure(content: metaContent) guard let histories = tag.histories?.sorted(by: { @@ -211,11 +232,23 @@ extension SearchResultTableViewCell { } } +extension SearchResultTableViewCell { + func setDisplayAvatarImage() { + avatarImageView.alpha = 1 + hashtagImageView.alpha = 0 + } + + func setDisplayHashtagImage() { + avatarImageView.alpha = 0 + hashtagImageView.alpha = 1 + } +} + // MARK: - AvatarStackedImageView extension SearchResultTableViewCell: AvatarConfigurableView { static var configurableAvatarImageSize: CGSize { CGSize(width: 42, height: 42) } static var configurableAvatarImageCornerRadius: CGFloat { 4 } - var configurableAvatarImageView: FLAnimatedImageView? { _imageView } + var configurableAvatarImageView: FLAnimatedImageView? { avatarImageView } } #if canImport(SwiftUI) && DEBUG @@ -227,7 +260,7 @@ struct SearchResultTableViewCell_Previews: PreviewProvider { UIViewPreview { let cell = SearchResultTableViewCell() cell.backgroundColor = .white - cell._imageView.image = UIImage(systemName: "number.circle.fill") + cell.setDisplayHashtagImage() cell._titleLabel.text = "Electronic Frontier Foundation" cell._subTitleLabel.text = "@eff@mastodon.social" return cell diff --git a/Mastodon/Scene/Settings/SettingsViewController.swift b/Mastodon/Scene/Settings/SettingsViewController.swift index e93668801..04c343647 100644 --- a/Mastodon/Scene/Settings/SettingsViewController.swift +++ b/Mastodon/Scene/Settings/SettingsViewController.swift @@ -98,7 +98,15 @@ class SettingsViewController: UIViewController, NeedsDependency { private(set) lazy var tableView: UITableView = { // init with a frame to fix a conflict ('UIView-Encapsulated-Layout-Width' UIStackView:0x7f8c2b6c0590.width == 0) - let tableView = UITableView(frame: CGRect(x: 0, y: 0, width: 320, height: 320), style: .grouped) + let style: UITableView.Style = { + switch UIDevice.current.userInterfaceIdiom { + case .phone: + return .grouped + default: + return .insetGrouped + } + }() + let tableView = UITableView(frame: CGRect(x: 0, y: 0, width: 320, height: 320), style: style) tableView.translatesAutoresizingMaskIntoConstraints = false tableView.delegate = self tableView.rowHeight = UITableView.automaticDimension @@ -189,7 +197,7 @@ class SettingsViewController: UIViewController, NeedsDependency { if let activeSubscription = setting.activeSubscription { self.whoButton.setTitle(activeSubscription.policy.title, for: .normal) } else { - assertionFailure() + // assertionFailure() } } } @@ -199,27 +207,6 @@ class SettingsViewController: UIViewController, NeedsDependency { let footer = "Mastodon v\(UIApplication.appVersion()) (\(UIApplication.appBuild()))" let metaContent = PlaintextMetaContent(string: footer) tableFooterLabel.configure(content: metaContent) - - // FIXME: - // needs a workaround for GitHub link -// viewModel.currentInstance -// .receive(on: RunLoop.main) -// .sink { [weak self] instance in -// guard let self = self else { return } -// let version = instance?.version ?? "-" -// let link = #"mastodon/mastodon"# -// let content = L10n.Scene.Settings.Footer.mastodonDescription(link, version) -// let mastodonContent = MastodonContent(content: content, emojis: [:]) -// do { -// let metaContent = try MastodonMetaContent.convert(document: mastodonContent) -// self.tableFooterLabel.configure(content: metaContent) -// } catch { -// let metaContent = PlaintextMetaContent(string: "") -// self.tableFooterLabel.configure(content: metaContent) -// assertionFailure() -// } -// } -// .store(in: &disposeBag) } private func setupView() { @@ -276,7 +263,7 @@ class SettingsViewController: UIViewController, NeedsDependency { tableView.tableFooterView = tableFooterView } - func alertToSignout() { + func alertToSignOut() { let alertController = UIAlertController( title: L10n.Common.Alerts.SignOut.title, message: L10n.Common.Alerts.SignOut.message, @@ -302,6 +289,9 @@ class SettingsViewController: UIViewController, NeedsDependency { return } + // clear badge before sign-out + context.notificationService.clearNotificationCountForActiveUser() + context.authenticationService.signOutMastodonUser( domain: activeMastodonAuthenticationBox.domain, userID: activeMastodonAuthenticationBox.userID @@ -423,7 +413,7 @@ extension SettingsViewController: UITableViewDelegate { .store(in: &disposeBag) case .signOut: feedbackGenerator.impactOccurred() - alertToSignout() + alertToSignOut() } } } @@ -449,7 +439,7 @@ extension SettingsViewController { .sink { _ in // do nothing } receiveValue: { _ in - // do nohting + // do nothing } .store(in: &disposeBag) } @@ -461,16 +451,19 @@ extension SettingsViewController: SettingsAppearanceTableViewCellDelegate { guard let dataSource = viewModel.dataSource else { return } guard let indexPath = tableView.indexPath(for: cell) else { return } let item = dataSource.itemIdentifier(for: indexPath) - guard case let .appearance(settingObjectID) = item else { return } + guard case .appearance = item else { return } - context.managedObjectContext.performChanges { - let setting = self.context.managedObjectContext.object(with: settingObjectID) as! Setting - setting.update(appearanceRaw: appearanceMode.rawValue) + switch appearanceMode { + case .automatic: + UserDefaults.shared.customUserInterfaceStyle = .unspecified + case .light: + UserDefaults.shared.customUserInterfaceStyle = .light + case .dark: + UserDefaults.shared.customUserInterfaceStyle = .dark } - .sink { _ in - let feedbackGenerator = UIImpactFeedbackGenerator(style: .light) - feedbackGenerator.impactOccurred() - }.store(in: &disposeBag) + + let feedbackGenerator = UIImpactFeedbackGenerator(style: .light) + feedbackGenerator.impactOccurred() } } @@ -566,7 +559,7 @@ extension SettingsViewController: ASWebAuthenticationPresentationContextProvidin // MARK: - UIAdaptivePresentationControllerDelegate extension SettingsViewController: UIAdaptivePresentationControllerDelegate { func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle { - return .formSheet + return .pageSheet } } diff --git a/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift b/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift index c4eb998e4..a4904136b 100644 --- a/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift +++ b/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift @@ -15,6 +15,7 @@ protocol SettingsAppearanceTableViewCellDelegate: AnyObject { class SettingsAppearanceTableViewCell: UITableViewCell { var disposeBag = Set() + var observations = Set() static let spacing: CGFloat = 18 @@ -59,6 +60,7 @@ class SettingsAppearanceTableViewCell: UITableViewCell { super.prepareForReuse() disposeBag.removeAll() + observations.removeAll() } // MARK: - Methods diff --git a/Mastodon/Scene/Share/View/Button/AvatarButton.swift b/Mastodon/Scene/Share/View/Button/AvatarButton.swift index a8f7212ae..6249ea373 100644 --- a/Mastodon/Scene/Share/View/Button/AvatarButton.swift +++ b/Mastodon/Scene/Share/View/Button/AvatarButton.swift @@ -28,6 +28,7 @@ class AvatarButton: UIControl { } func _init() { + avatarImageView.frame = bounds avatarImageView.translatesAutoresizingMaskIntoConstraints = false addSubview(avatarImageView) NSLayoutConstraint.activate([ @@ -37,6 +38,18 @@ class AvatarButton: UIControl { avatarImageView.bottomAnchor.constraint(equalTo: bottomAnchor), ]) } + + override func layoutSubviews() { + super.layoutSubviews() + + updateAppearance() + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + updateAppearance() + } func updateAppearance() { avatarImageView.alpha = primaryActionState.contains(.highlighted) ? 0.6 : 1.0 diff --git a/Mastodon/Scene/Share/View/Button/CircleAvatarButton.swift b/Mastodon/Scene/Share/View/Button/CircleAvatarButton.swift new file mode 100644 index 000000000..74591dda9 --- /dev/null +++ b/Mastodon/Scene/Share/View/Button/CircleAvatarButton.swift @@ -0,0 +1,26 @@ +// +// CircleAvatarButton.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-9-15. +// + +import UIKit + +final class CircleAvatarButton: AvatarButton { + + @Published var needsHighlighted = false + + var borderColor: UIColor = UIColor.systemFill + var borderWidth: CGFloat = 1.0 + + override func updateAppearance() { + super.updateAppearance() + + layer.masksToBounds = true + layer.cornerRadius = frame.width * 0.5 + layer.borderColor = borderColor.cgColor + layer.borderWidth = borderWidth + } + +} diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index 7afabd3a9..62eb3d6b0 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -73,9 +73,10 @@ final class StatusView: UIView { return attributedString } - let headerIconLabel: UILabel = { - let label = UILabel() - label.attributedText = StatusView.iconAttributedString(image: StatusView.reblogIconImage) + let headerIconLabel: MetaLabel = { + let label = MetaLabel(style: .statusHeader) + let attributedString = StatusView.iconAttributedString(image: StatusView.reblogIconImage) + label.configure(attributedString: attributedString) return label }() @@ -125,7 +126,7 @@ final class StatusView: UIView { let revealContentWarningButton: UIButton = { let button = HighlightDimmableButton() button.setImage(UIImage(systemName: "eye", withConfiguration: UIImage.SymbolConfiguration(pointSize: 17, weight: .medium)), for: .normal) - button.tintColor = Asset.Colors.brandBlue.color + // button.tintColor = Asset.Colors.brandBlue.color return button }() @@ -202,6 +203,9 @@ final class StatusView: UIView { return actionToolbarContainer }() + // set display when needs bottom padding + let actionToolbarPlaceholderPaddingView = UIView() + let contentMetaText: MetaText = { let metaText = MetaText() metaText.textView.backgroundColor = .clear @@ -217,6 +221,7 @@ final class StatusView: UIView { let style = NSMutableParagraphStyle() style.lineSpacing = 5 style.paragraphSpacing = 8 + style.alignment = .natural return style }() metaText.textAttributes = [ @@ -449,6 +454,13 @@ extension StatusView { containerStackView.sendSubviewToBack(actionToolbarContainer) actionToolbarContainer.setContentCompressionResistancePriority(.defaultHigh, for: .vertical) actionToolbarContainer.setContentHuggingPriority(.required - 1, for: .vertical) + + actionToolbarPlaceholderPaddingView.translatesAutoresizingMaskIntoConstraints = false + containerStackView.addArrangedSubview(actionToolbarPlaceholderPaddingView) + NSLayoutConstraint.activate([ + actionToolbarPlaceholderPaddingView.heightAnchor.constraint(equalToConstant: 12).priority(.required - 1), + ]) + actionToolbarPlaceholderPaddingView.isHidden = true headerContainerView.isHidden = true statusMosaicImageViewContainer.isHidden = true diff --git a/Mastodon/Scene/Share/View/Content/ThreadMetaView.swift b/Mastodon/Scene/Share/View/Content/ThreadMetaView.swift index 4bda525ae..c339654f5 100644 --- a/Mastodon/Scene/Share/View/Content/ThreadMetaView.swift +++ b/Mastodon/Scene/Share/View/Content/ThreadMetaView.swift @@ -23,8 +23,8 @@ final class ThreadMetaView: UIView { let button = UIButton() button.titleLabel?.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .semibold)) button.setTitle("0 reblog", for: .normal) - button.setTitleColor(Asset.Colors.brandBlue.color, for: .normal) - button.setTitleColor(Asset.Colors.brandBlue.color.withAlphaComponent(0.5), for: .highlighted) + button.setTitleColor(ThemeService.tintColor, for: .normal) + button.setTitleColor(ThemeService.tintColor.withAlphaComponent(0.5), for: .highlighted) return button }() @@ -32,8 +32,8 @@ final class ThreadMetaView: UIView { let button = UIButton() button.titleLabel?.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .semibold)) button.setTitle("0 favorite", for: .normal) - button.setTitleColor(Asset.Colors.brandBlue.color, for: .normal) - button.setTitleColor(Asset.Colors.brandBlue.color.withAlphaComponent(0.5), for: .highlighted) + button.setTitleColor(ThemeService.tintColor, for: .normal) + button.setTitleColor(ThemeService.tintColor.withAlphaComponent(0.5), for: .highlighted) return button }() diff --git a/Mastodon/Scene/Share/View/Node/Status/StatusNode.swift b/Mastodon/Scene/Share/View/Node/Status/StatusNode.swift index 2f640a140..170543482 100644 --- a/Mastodon/Scene/Share/View/Node/Status/StatusNode.swift +++ b/Mastodon/Scene/Share/View/Node/Status/StatusNode.swift @@ -121,7 +121,7 @@ final class StatusNode: ASCellNode { // } for imageNode in mediaMultiplexImageNodes { - imageNode.dataSource = self + imageNode.delegate = self } } diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index 8e9f4a641..38c86c112 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -111,6 +111,10 @@ final class StatusTableViewCell: UITableViewCell, StatusCell { _init() } + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + } extension StatusTableViewCell { diff --git a/Mastodon/Scene/Share/View/TableviewCell/ThreadReplyLoaderTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/ThreadReplyLoaderTableViewCell.swift index 5e5ac88d7..a819f301c 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/ThreadReplyLoaderTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/ThreadReplyLoaderTableViewCell.swift @@ -23,7 +23,7 @@ final class ThreadReplyLoaderTableViewCell: UITableViewCell { let loadMoreButton: UIButton = { let button = HighlightDimmableButton() button.titleLabel?.font = TimelineLoaderTableViewCell.labelFont - button.setTitleColor(Asset.Colors.brandBlue.color, for: .normal) + button.setTitleColor(ThemeService.tintColor, for: .normal) button.setTitle(L10n.Common.Controls.Timeline.Loader.showMoreReplies, for: .normal) return button }() diff --git a/Mastodon/Scene/Share/View/TableviewCell/TimelineFooterTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/TimelineFooterTableViewCell.swift new file mode 100644 index 000000000..43dd2c6fa --- /dev/null +++ b/Mastodon/Scene/Share/View/TableviewCell/TimelineFooterTableViewCell.swift @@ -0,0 +1,51 @@ +// +// TimelineFooterTableViewCell.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-11-1. +// + +import UIKit + +final class TimelineFooterTableViewCell: UITableViewCell { + + let messageLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 17) + label.textAlignment = .center + label.textColor = Asset.Colors.Label.secondary.color + label.text = "info" + label.numberOfLines = 0 + return label + }() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension TimelineFooterTableViewCell { + + private func _init() { + selectionStyle = .none + backgroundColor = .clear + + messageLabel.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(messageLabel) + NSLayoutConstraint.activate([ + messageLabel.topAnchor.constraint(equalTo: contentView.topAnchor), + messageLabel.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), + messageLabel.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), + messageLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + messageLabel.heightAnchor.constraint(greaterThanOrEqualToConstant: 68).priority(.required - 1), // same height to bottom loader + ]) + } + +} diff --git a/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift index 8c329d31e..da0b80fb4 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift @@ -24,7 +24,7 @@ class TimelineLoaderTableViewCell: UITableViewCell { let loadMoreButton: UIButton = { let button = HighlightDimmableButton() button.titleLabel?.font = TimelineLoaderTableViewCell.labelFont - button.setTitleColor(Asset.Colors.brandBlue.color, for: .normal) + button.setTitleColor(ThemeService.tintColor, for: .normal) button.setTitle(L10n.Common.Controls.Timeline.Loader.loadMissingPosts, for: .normal) button.setTitle("", for: .disabled) return button @@ -68,7 +68,7 @@ class TimelineLoaderTableViewCell: UITableViewCell { func stopAnimating() { activityIndicatorView.stopAnimating() self.loadMoreButton.isEnabled = true - self.loadMoreLabel.textColor = Asset.Colors.brandBlue.color + self.loadMoreLabel.textColor = ThemeService.tintColor self.loadMoreLabel.text = "" } diff --git a/Mastodon/Scene/Share/View/TableviewCell/UserTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/UserTableViewCell.swift new file mode 100644 index 000000000..29e28415e --- /dev/null +++ b/Mastodon/Scene/Share/View/TableviewCell/UserTableViewCell.swift @@ -0,0 +1,131 @@ +// +// UserTableViewCell.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-11-1. +// + +import CoreData +import CoreDataStack +import MastodonSDK +import UIKit +import MetaTextKit +import MastodonMeta +import FLAnimatedImage + +protocol UserTableViewCellDelegate: AnyObject { } + +final class UserTableViewCell: UITableViewCell { + + weak var delegate: UserTableViewCellDelegate? + + let avatarImageView: AvatarImageView = { + let imageView = AvatarImageView() + imageView.tintColor = Asset.Colors.Label.primary.color + imageView.layer.cornerRadius = 4 + imageView.clipsToBounds = true + return imageView + }() + + let nameLabel = MetaLabel(style: .statusName) + + let usernameLabel: UILabel = { + let label = UILabel() + label.textColor = Asset.Colors.Label.secondary.color + label.font = .preferredFont(forTextStyle: .body) + return label + }() + + let separatorLine = UIView.separatorLine + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension UserTableViewCell { + + private func _init() { + let containerStackView = UIStackView() + containerStackView.axis = .horizontal + containerStackView.distribution = .fill + containerStackView.spacing = 12 + containerStackView.layoutMargins = UIEdgeInsets(top: 12, left: 0, bottom: 12, right: 0) + containerStackView.isLayoutMarginsRelativeArrangement = true + containerStackView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(containerStackView) + NSLayoutConstraint.activate([ + containerStackView.topAnchor.constraint(equalTo: contentView.topAnchor), + containerStackView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), + containerStackView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), + containerStackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) + ]) + + avatarImageView.translatesAutoresizingMaskIntoConstraints = false + containerStackView.addArrangedSubview(avatarImageView) + NSLayoutConstraint.activate([ + avatarImageView.widthAnchor.constraint(equalToConstant: 42).priority(.required - 1), + avatarImageView.heightAnchor.constraint(equalToConstant: 42).priority(.required - 1), + ]) + + let textStackView = UIStackView() + textStackView.axis = .vertical + textStackView.distribution = .fill + textStackView.translatesAutoresizingMaskIntoConstraints = false + nameLabel.translatesAutoresizingMaskIntoConstraints = false + textStackView.addArrangedSubview(nameLabel) + usernameLabel.translatesAutoresizingMaskIntoConstraints = false + textStackView.addArrangedSubview(usernameLabel) + usernameLabel.setContentHuggingPriority(.defaultLow - 1, for: .vertical) + + containerStackView.addArrangedSubview(textStackView) + + separatorLine.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(separatorLine) + NSLayoutConstraint.activate([ + separatorLine.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), + separatorLine.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), + separatorLine.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + separatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView)), + ]) + + + nameLabel.isUserInteractionEnabled = false + usernameLabel.isUserInteractionEnabled = false + avatarImageView.isUserInteractionEnabled = false + } + +} + +// MARK: - AvatarStackedImageView +extension UserTableViewCell: AvatarConfigurableView { + static var configurableAvatarImageSize: CGSize { CGSize(width: 42, height: 42) } + static var configurableAvatarImageCornerRadius: CGFloat { 4 } + var configurableAvatarImageView: FLAnimatedImageView? { avatarImageView } +} + +extension UserTableViewCell { + func configure(user: MastodonUser) { + // avatar + configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: user.avatarImageURL())) + // name + let name = user.displayNameWithFallback + do { + let mastodonContent = MastodonContent(content: name, emojis: user.emojiMeta) + let metaContent = try MastodonMetaContent.convert(document: mastodonContent) + nameLabel.configure(content: metaContent) + } catch { + let metaContent = PlaintextMetaContent(string: name) + nameLabel.configure(content: metaContent) + } + // username + usernameLabel.text = "@" + user.acct + } +} diff --git a/Mastodon/Scene/Share/ViewModel/ListBatchFetchViewModel.swift b/Mastodon/Scene/Share/ViewModel/ListBatchFetchViewModel.swift new file mode 100644 index 000000000..78eaf6ae3 --- /dev/null +++ b/Mastodon/Scene/Share/ViewModel/ListBatchFetchViewModel.swift @@ -0,0 +1,68 @@ +// +// ListBatchFetchViewModel.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-11-10. +// + +import UIKit +import Combine + +// ref: Texture.ASBatchFetchingDelegate +final class ListBatchFetchViewModel { + + var disposeBag = Set() + + // timer running on `common` mode + let timerPublisher = Timer.publish(every: 1.0, on: .main, in: .common) + .autoconnect() + .share() + .eraseToAnyPublisher() + + // input + private(set) weak var scrollView: UIScrollView? + let hasMore = CurrentValueSubject(true) + + // output + let shouldFetch = PassthroughSubject() + + init() { + Publishers.CombineLatest( + hasMore, + timerPublisher + ) + .sink { [weak self] hasMore, _ in + guard let self = self else { return } + guard hasMore else { return } + guard let scrollView = self.scrollView else { return } + + // skip trigger if user interacting + if scrollView.isDragging || scrollView.isTracking { return } + + // send fetch request + if scrollView.contentSize.height < scrollView.frame.height { + self.shouldFetch.send() + } else { + let frame = scrollView.frame + let contentOffset = scrollView.contentOffset + let contentSize = scrollView.contentSize + + let visibleBottomY = contentOffset.y + frame.height + let offset = 2 * frame.height + let fetchThrottleOffsetY = contentSize.height - offset + + if visibleBottomY > fetchThrottleOffsetY { + self.shouldFetch.send() + } + } + } + .store(in: &disposeBag) + } + +} + +extension ListBatchFetchViewModel { + func setup(scrollView: UIScrollView) { + self.scrollView = scrollView + } +} diff --git a/Mastodon/Scene/Thread/RemoteThreadViewModel.swift b/Mastodon/Scene/Thread/RemoteThreadViewModel.swift index e6e111018..f8f5d3e7e 100644 --- a/Mastodon/Scene/Thread/RemoteThreadViewModel.swift +++ b/Mastodon/Scene/Thread/RemoteThreadViewModel.swift @@ -48,7 +48,6 @@ final class RemoteThreadViewModel: ThreadViewModel { .store(in: &disposeBag) } - // FIXME: multiple account supports init(context: AppContext, notificationID: Mastodon.Entity.Notification.ID) { super.init(context: context, optionalStatus: nil) diff --git a/Mastodon/Scene/Thread/ThreadViewModel+Diffable.swift b/Mastodon/Scene/Thread/ThreadViewModel+Diffable.swift index e09d6acc1..853bee9da 100644 --- a/Mastodon/Scene/Thread/ThreadViewModel+Diffable.swift +++ b/Mastodon/Scene/Thread/ThreadViewModel+Diffable.swift @@ -19,17 +19,11 @@ extension ThreadViewModel { statusTableViewCellDelegate: StatusTableViewCellDelegate, threadReplyLoaderTableViewCellDelegate: ThreadReplyLoaderTableViewCellDelegate ) { - let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common) - .autoconnect() - .share() - .eraseToAnyPublisher() - diffableDataSource = StatusSection.tableViewDiffableDataSource( for: tableView, timelineContext: .thread, dependency: dependency, managedObjectContext: context.managedObjectContext, - timestampUpdatePublisher: timestampUpdatePublisher, statusTableViewCellDelegate: statusTableViewCellDelegate, timelineMiddleLoaderTableViewCellDelegate: nil, threadReplyLoaderTableViewCellDelegate: threadReplyLoaderTableViewCellDelegate @@ -141,7 +135,7 @@ extension ThreadViewModel { // save height before cell reuse let oldRootCellHeight = oldRootCell?.frame.height - diffableDataSource.apply(newSnapshot, animatingDifferences: false) { + diffableDataSource.reloadData(snapshot: newSnapshot) { guard let _ = rootItem else { return } diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift b/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift index 9fbbbd888..ec4ac35ad 100644 --- a/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift +++ b/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift @@ -184,9 +184,13 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { let maskLayerToPath = maskLayerToRect.flatMap { UIBezierPath(rect: $0) }?.cgPath let maskLayerToFinalRect: CGRect? = { guard case .mosaic = transitionItem.source else { return nil } - guard let tabBarController = toVC.tabBarController, let tabBarSuperView = tabBarController.tabBar.superview else { return nil } - let tabBarFrameInWindow = tabBarSuperView.convert(tabBarController.tabBar.frame, to: nil) var rect = maskLayerToRect ?? transitionMaskView.frame + // clip tabBar when bar visible + guard let tabBarController = toVC.tabBarController, + !tabBarController.tabBar.isHidden, + let tabBarSuperView = tabBarController.tabBar.superview + else { return rect } + let tabBarFrameInWindow = tabBarSuperView.convert(tabBarController.tabBar.frame, to: nil) let offset = rect.maxY - tabBarFrameInWindow.minY guard offset > 0 else { return rect } rect.size.height -= offset @@ -473,9 +477,13 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { let maskLayerToFinalRect: CGRect? = { guard case .mosaic = transitionItem.source else { return nil } - guard let tabBarController = toVC.tabBarController, let tabBarSuperView = tabBarController.tabBar.superview else { return nil } - let tabBarFrameInWindow = tabBarSuperView.convert(tabBarController.tabBar.frame, to: nil) var rect = maskLayerToRect ?? transitionMaskView.frame + // clip rect bottom when tabBar visible + guard let tabBarController = toVC.tabBarController, + !tabBarController.tabBar.isHidden, + let tabBarSuperView = tabBarController.tabBar.superview + else { return rect } + let tabBarFrameInWindow = tabBarSuperView.convert(tabBarController.tabBar.frame, to: nil) let offset = rect.maxY - tabBarFrameInWindow.minY guard offset > 0 else { return rect } rect.size.height -= offset diff --git a/Mastodon/Scene/Wizard/WizardViewController.swift b/Mastodon/Scene/Wizard/WizardViewController.swift new file mode 100644 index 000000000..2678c712d --- /dev/null +++ b/Mastodon/Scene/Wizard/WizardViewController.swift @@ -0,0 +1,211 @@ +// +// WizardViewController.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-11-2. +// + +import os.log +import UIKit +import Combine + +protocol WizardViewControllerDelegate: AnyObject { + func readyToLayoutItem(_ wizardViewController: WizardViewController, item: WizardViewController.Item) -> Bool + func layoutSpotlight(_ wizardViewController: WizardViewController, item: WizardViewController.Item) -> UIBezierPath + func layoutWizardCard(_ wizardViewController: WizardViewController, item: WizardViewController.Item) +} + +class WizardViewController: UIViewController { + + let logger = Logger(subsystem: "Wizard", category: "UI") + + var disposeBag = Set() + weak var delegate: WizardViewControllerDelegate? + + private(set) var items: [Item] = { + var items: [Item] = [] + if !UserDefaults.shared.didShowMultipleAccountSwitchWizard { + items.append(.multipleAccountSwitch) + } + return items + }() + + let pendingItem = CurrentValueSubject(nil) + let currentItem = CurrentValueSubject(nil) + + let backgroundView: UIView = { + let view = UIView() + view.backgroundColor = UIColor.black.withAlphaComponent(0.7) + return view + }() + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension WizardViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + setup() + + let backgroundTapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer + backgroundTapGestureRecognizer.addTarget(self, action: #selector(WizardViewController.backgroundTapGestureRecognizerHandler(_:))) + backgroundView.addGestureRecognizer(backgroundTapGestureRecognizer) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + // Create a timer to consume pending item + Timer.publish(every: 0.5, on: .main, in: .default) + .autoconnect() + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + guard self.pendingItem.value != nil else { return } + self.consume() + } + .store(in: &disposeBag) + + consume() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + invalidLayoutForCurrentItem() + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + + coordinator.animate { context in + + } completion: { [weak self] context in + guard let self = self else { return } + self.invalidLayoutForCurrentItem() + } + + } + +} + +extension WizardViewController { + enum Item { + case multipleAccountSwitch + + var title: String { + return L10n.Scene.Wizard.newInMastodon + } + + var description: String { + switch self { + case .multipleAccountSwitch: + return L10n.Scene.Wizard.multipleAccountSwitchIntroDescription + } + } + + func markAsRead() { + switch self { + case .multipleAccountSwitch: + UserDefaults.shared.didShowMultipleAccountSwitchWizard = true + } + } + } +} + +extension WizardViewController { + + func setup() { + assert(delegate != nil, "need set delegate before use") + + guard !items.isEmpty else { return } + + backgroundView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + backgroundView.frame = view.bounds + view.addSubview(backgroundView) + } + + func destroy() { + view.removeFromSuperview() + } + + func consume() { + guard !items.isEmpty else { + destroy() + return + } + + guard let first = items.first else { return } + guard delegate?.readyToLayoutItem(self, item: first) == true else { + pendingItem.value = first + return + } + pendingItem.value = nil + currentItem.value = nil + + let item = items.removeFirst() + perform(item: item) + } + + private func perform(item: Item) { + guard let delegate = delegate else { + assertionFailure() + return + } + + // prepare for reuse + prepareForReuse() + + // set wizard item read + item.markAsRead() + + // add spotlight + let spotlight = delegate.layoutSpotlight(self, item: item) + let maskLayer = CAShapeLayer() + // expand rect to make sure view always fill the screen when device rotate + let expandRect: CGRect = { + var rect = backgroundView.bounds + rect.size.width *= 2 + rect.size.height *= 2 + return rect + }() + let path = UIBezierPath(rect: expandRect) + path.append(spotlight) + maskLayer.fillRule = .evenOdd + maskLayer.path = path.cgPath + backgroundView.layer.mask = maskLayer + + // layout wizard card + delegate.layoutWizardCard(self, item: item) + + currentItem.value = item + } + + private func prepareForReuse() { + backgroundView.subviews.forEach { subview in + subview.removeFromSuperview() + } + backgroundView.mask = nil + backgroundView.layer.mask = nil + } + + private func invalidLayoutForCurrentItem() { + if let item = currentItem.value { + perform(item: item) + } + } + +} + +extension WizardViewController { + @objc private func backgroundTapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") + + consume() + } +} diff --git a/Mastodon/Service/APIService/APIService+Follower.swift b/Mastodon/Service/APIService/APIService+Follower.swift new file mode 100644 index 000000000..f75d2420d --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Follower.swift @@ -0,0 +1,70 @@ +// +// APIService+Follower.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-11-1. +// + +import UIKit +import Combine +import CoreData +import CoreDataStack +import CommonOSLog +import MastodonSDK + +extension APIService { + + func followers( + userID: Mastodon.Entity.Account.ID, + maxID: String?, + authorizationBox: MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let domain = authorizationBox.domain + let authorization = authorizationBox.userAuthorization + let requestMastodonUserID = authorizationBox.userID + + let query = Mastodon.API.Account.FollowerQuery( + maxID: maxID, + limit: nil + ) + return Mastodon.API.Account.followers( + session: session, + domain: domain, + userID: userID, + query: query, + authorization: authorization + ) + .flatMap { response -> AnyPublisher, Error> in + let managedObjectContext = self.backgroundManagedObjectContext + return managedObjectContext.performChanges { + let requestMastodonUserRequest = MastodonUser.sortedFetchRequest + requestMastodonUserRequest.predicate = MastodonUser.predicate(domain: domain, id: requestMastodonUserID) + requestMastodonUserRequest.fetchLimit = 1 + guard let requestMastodonUser = managedObjectContext.safeFetch(requestMastodonUserRequest).first else { return } + + for entity in response.value { + _ = APIService.CoreData.createOrMergeMastodonUser( + into: managedObjectContext, + for: requestMastodonUser, + in: domain, + entity: entity, + userCache: nil, + networkDate: response.networkDate, + log: .api + ) + } + } + .tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Account]> in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + +} diff --git a/Mastodon/Service/APIService/APIService+Following.swift b/Mastodon/Service/APIService/APIService+Following.swift new file mode 100644 index 000000000..8f477d6ec --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Following.swift @@ -0,0 +1,70 @@ +// +// APIService+Following.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-11-2. +// + +import UIKit +import Combine +import CoreData +import CoreDataStack +import CommonOSLog +import MastodonSDK + +extension APIService { + + func following( + userID: Mastodon.Entity.Account.ID, + maxID: String?, + authorizationBox: MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let domain = authorizationBox.domain + let authorization = authorizationBox.userAuthorization + let requestMastodonUserID = authorizationBox.userID + + let query = Mastodon.API.Account.FollowingQuery( + maxID: maxID, + limit: nil + ) + return Mastodon.API.Account.following( + session: session, + domain: domain, + userID: userID, + query: query, + authorization: authorization + ) + .flatMap { response -> AnyPublisher, Error> in + let managedObjectContext = self.backgroundManagedObjectContext + return managedObjectContext.performChanges { + let requestMastodonUserRequest = MastodonUser.sortedFetchRequest + requestMastodonUserRequest.predicate = MastodonUser.predicate(domain: domain, id: requestMastodonUserID) + requestMastodonUserRequest.fetchLimit = 1 + guard let requestMastodonUser = managedObjectContext.safeFetch(requestMastodonUserRequest).first else { return } + + for entity in response.value { + _ = APIService.CoreData.createOrMergeMastodonUser( + into: managedObjectContext, + for: requestMastodonUser, + in: domain, + entity: entity, + userCache: nil, + networkDate: response.networkDate, + log: .api + ) + } + } + .tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Account]> in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + +} diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Instance.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Instance.swift new file mode 100644 index 000000000..614d098aa --- /dev/null +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Instance.swift @@ -0,0 +1,76 @@ +// +// APIService+CoreData+Instance.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-10-9. +// + +import os.log +import Foundation +import CoreData +import CoreDataStack +import MastodonSDK + +extension APIService.CoreData { + + static func createOrMergeInstance( + into managedObjectContext: NSManagedObjectContext, + domain: String, + entity: Mastodon.Entity.Instance, + networkDate: Date, + log: OSLog + ) -> (instance: Instance, isCreated: Bool) { + // fetch old mastodon user + let old: Instance? = { + let request = Instance.sortedFetchRequest + request.predicate = Instance.predicate(domain: domain) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + do { + return try managedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + + if let old = old { + // merge old + APIService.CoreData.merge( + instance: old, + entity: entity, + domain: domain, + networkDate: networkDate + ) + return (old, false) + } else { + let instance = Instance.insert( + into: managedObjectContext, + property: Instance.Property(domain: domain) + ) + let configurationRaw = entity.configuration.flatMap { Instance.encode(configuration: $0) } + instance.update(configurationRaw: configurationRaw) + + return (instance, true) + } + } + +} + +extension APIService.CoreData { + + static func merge( + instance: Instance, + entity: Mastodon.Entity.Instance, + domain: String, + networkDate: Date + ) { + guard networkDate > instance.updatedAt else { return } + + let configurationRaw = entity.configuration.flatMap { Instance.encode(configuration: $0) } + instance.update(configurationRaw: configurationRaw) + + instance.didUpdate(at: networkDate) + } + +} diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Status.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Status.swift index 201e400d1..673cb4de3 100644 --- a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Status.swift +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Status.swift @@ -86,15 +86,9 @@ extension APIService.CoreData { let object = Poll.insert(into: managedObjectContext, property: Poll.Property(id: poll.id, expiresAt: poll.expiresAt, expired: poll.expired, multiple: poll.multiple, votesCount: poll.votesCount, votersCount: poll.votersCount, networkDate: networkDate), votedBy: votedBy, options: options) return object } - let metions = entity.mentions?.enumerated().compactMap { index, mention -> Mention in + let mentions = entity.mentions?.enumerated().compactMap { index, mention -> Mention in Mention.insert(into: managedObjectContext, property: Mention.Property(id: mention.id, username: mention.username, acct: mention.acct, url: mention.url), index: index) } - let tags = entity.tags?.compactMap { tag -> Tag in - let histories = tag.history?.compactMap { history -> History in - History.insert(into: managedObjectContext, property: History.Property(day: history.day, uses: history.uses, accounts: history.accounts)) - } - return Tag.insert(into: managedObjectContext, property: Tag.Property(name: tag.name, url: tag.url, histories: histories)) - } let mediaAttachments: [Attachment]? = { let encoder = JSONEncoder() var attachments: [Attachment] = [] @@ -117,8 +111,7 @@ extension APIService.CoreData { application: application, replyTo: replyTo, poll: poll, - mentions: metions, - tags: tags, + mentions: mentions, mediaAttachments: mediaAttachments, favouritedBy: (entity.favourited ?? false) ? requestMastodonUser : nil, rebloggedBy: (entity.reblogged ?? false) ? requestMastodonUser : nil, diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Subscriptions.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Subscriptions.swift index 6eebc9e56..d5958cf8f 100644 --- a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Subscriptions.swift +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Subscriptions.swift @@ -18,13 +18,9 @@ extension APIService.CoreData { setting: Setting, policy: Mastodon.API.Subscriptions.Policy ) -> (subscription: Subscription, isCreated: Bool) { - let oldSubscription: Subscription? = { - let request = Subscription.sortedFetchRequest - request.predicate = Subscription.predicate(policyRaw: policy.rawValue) - request.fetchLimit = 1 - request.returnsObjectsAsFaults = false - return managedObjectContext.safeFetch(request).first - }() + let oldSubscription = setting.subscriptions?.first(where: { subscription in + subscription.policyRaw == policy.rawValue + }) if let oldSubscription = oldSubscription { oldSubscription.setting = setting diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Tag.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Tag.swift index 9b4319572..bc5718bc0 100644 --- a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Tag.swift +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Tag.swift @@ -15,7 +15,7 @@ extension APIService.CoreData { into managedObjectContext: NSManagedObjectContext, entity: Mastodon.Entity.Tag ) -> (Tag: Tag, isCreated: Bool) { - // fetch old mastodon user + // fetch old hashtag  let oldTag: Tag? = { let request = Tag.sortedFetchRequest request.predicate = Tag.predicate(name: entity.name) diff --git a/Mastodon/Service/AuthenticationService.swift b/Mastodon/Service/AuthenticationService.swift index 0b3c3fa11..9e27caab6 100644 --- a/Mastodon/Service/AuthenticationService.swift +++ b/Mastodon/Service/AuthenticationService.swift @@ -95,8 +95,11 @@ extension AuthenticationService { func activeMastodonUser(domain: String, userID: MastodonUser.ID) -> AnyPublisher, Never> { var isActive = false + var _mastodonAuthentication: MastodonAuthentication? - return backgroundManagedObjectContext.performChanges { + return backgroundManagedObjectContext.performChanges { [weak self] in + guard let self = self else { return } + let request = MastodonAuthentication.sortedFetchRequest request.predicate = MastodonAuthentication.predicate(domain: domain, userID: userID) request.fetchLimit = 1 @@ -104,9 +107,29 @@ extension AuthenticationService { return } mastodonAuthentication.update(activedAt: Date()) + _mastodonAuthentication = mastodonAuthentication isActive = true + } - .map { result in + .receive(on: DispatchQueue.main) + .map { [weak self] result in + switch result { + case .success: + if let self = self, + let mastodonAuthentication = _mastodonAuthentication + { + // force set to avoid delay + self.activeMastodonAuthentication.value = mastodonAuthentication + self.activeMastodonAuthenticationBox.value = MastodonAuthenticationBox( + domain: mastodonAuthentication.domain, + userID: mastodonAuthentication.userID, + appAuthorization: Mastodon.API.OAuth.Authorization(accessToken: mastodonAuthentication.appAccessToken), + userAuthorization: Mastodon.API.OAuth.Authorization(accessToken: mastodonAuthentication.userAccessToken) + ) + } + case .failure: + break + } return result.map { isActive } } .eraseToAnyPublisher() diff --git a/Mastodon/Service/InstanceService.swift b/Mastodon/Service/InstanceService.swift new file mode 100644 index 000000000..4fb6309fd --- /dev/null +++ b/Mastodon/Service/InstanceService.swift @@ -0,0 +1,103 @@ +// +// InstanceService.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-10-9. +// + +import os.log +import Foundation +import Combine +import CoreData +import CoreDataStack +import MastodonSDK + +final class InstanceService { + + var disposeBag = Set() + + let logger = Logger(subsystem: "InstanceService", category: "Logic") + + // input + let backgroundManagedObjectContext: NSManagedObjectContext + weak var apiService: APIService? + weak var authenticationService: AuthenticationService? + + // output + + init( + apiService: APIService, + authenticationService: AuthenticationService + ) { + self.backgroundManagedObjectContext = apiService.backgroundManagedObjectContext + self.apiService = apiService + self.authenticationService = authenticationService + + authenticationService.activeMastodonAuthenticationBox + .receive(on: DispatchQueue.main) + .compactMap { $0?.domain } + .removeDuplicates() // prevent infinity loop + .sink { [weak self] domain in + guard let self = self else { return } + self.updateInstance(domain: domain) + } + .store(in: &disposeBag) + } + +} + +extension InstanceService { + func updateInstance(domain: String) { + guard let apiService = self.apiService else { return } + apiService.instance(domain: domain) + .flatMap { response -> AnyPublisher, Error> in + let managedObjectContext = self.backgroundManagedObjectContext + return managedObjectContext.performChanges { + // get instance + let (instance, _) = APIService.CoreData.createOrMergeInstance( + into: managedObjectContext, + domain: domain, + entity: response.value, + networkDate: response.networkDate, + log: OSLog.api + ) + + // update relationship + let request = MastodonAuthentication.sortedFetchRequest + request.predicate = MastodonAuthentication.predicate(domain: domain) + request.returnsObjectsAsFaults = false + do { + let authentications = try managedObjectContext.fetch(request) + for authentication in authentications { + authentication.update(instance: instance) + } + } catch { + assertionFailure(error.localizedDescription) + } + } + .setFailureType(to: Error.self) + .tryMap { result -> Mastodon.Response.Content in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } + .eraseToAnyPublisher() + } + .sink { [weak self] completion in + guard let self = self else { return } + switch completion { + case .failure(let error): + self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [Instance] update instance failure: \(error.localizedDescription)") + case .finished: + self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [Instance] update instance for domain: \(domain)") + } + } receiveValue: { [weak self] response in + guard let self = self else { return } + // do nothing + } + .store(in: &disposeBag) + } +} diff --git a/Mastodon/Service/NotificationService.swift b/Mastodon/Service/NotificationService.swift index ffe4c9916..6eb3120c7 100644 --- a/Mastodon/Service/NotificationService.swift +++ b/Mastodon/Service/NotificationService.swift @@ -24,12 +24,13 @@ final class NotificationService { weak var authenticationService: AuthenticationService? let isNotificationPermissionGranted = CurrentValueSubject(false) let deviceToken = CurrentValueSubject(nil) + let applicationIconBadgeNeedsUpdate = CurrentValueSubject(Void()) // output - /// [Token: UserID] + /// [Token: NotificationViewModel] let notificationSubscriptionDict: [String: NotificationViewModel] = [:] - let hasUnreadPushNotification = CurrentValueSubject(false) - let requestRevealNotificationPublisher = PassthroughSubject() + let unreadNotificationCountDidUpdate = CurrentValueSubject(Void()) + let requestRevealNotificationPublisher = PassthroughSubject() init( apiService: APIService, @@ -57,6 +58,26 @@ final class NotificationService { os_log(.info, log: .api, "%{public}s[%{public}ld], %{public}s: deviceToken: %s", ((#file as NSString).lastPathComponent), #line, #function, token) } .store(in: &disposeBag) + + Publishers.CombineLatest( + authenticationService.mastodonAuthentications, + applicationIconBadgeNeedsUpdate + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] mastodonAuthentications, _ in + guard let self = self else { return } + + var count = 0 + for authentication in mastodonAuthentications { + count += UserDefaults.shared.getNotificationCountWithAccessToken(accessToken: authentication.userAccessToken) + } + + UserDefaults.shared.notificationBadgeCount = count + UIApplication.shared.applicationIconBadgeNumber = count + + self.unreadNotificationCountDidUpdate.send() + } + .store(in: &disposeBag) } } @@ -101,7 +122,9 @@ extension NotificationService { } func handle(mastodonPushNotification: MastodonPushNotification) { - hasUnreadPushNotification.value = true + defer { + unreadNotificationCountDidUpdate.send() + } // Subscription maybe failed to cancel when sign-out // Try cancel again if receive that kind push notification @@ -154,6 +177,17 @@ extension NotificationService { } +extension NotificationService { + func clearNotificationCountForActiveUser() { + guard let authenticationService = self.authenticationService else { return } + if let accessToken = authenticationService.activeMastodonAuthentication.value?.userAccessToken { + UserDefaults.shared.setNotificationCountWithAccessToken(accessToken: accessToken, value: 0) + } + + applicationIconBadgeNeedsUpdate.send() + } +} + // MARK: - NotificationViewModel extension NotificationService { diff --git a/Mastodon/Service/SettingService.swift b/Mastodon/Service/SettingService.swift index 5985f6fce..79ed47abf 100644 --- a/Mastodon/Service/SettingService.swift +++ b/Mastodon/Service/SettingService.swift @@ -44,19 +44,20 @@ final class SettingService { .compactMap { [weak self] mastodonAuthenticationBoxes -> AnyPublisher<[MastodonAuthenticationBox], Never>? in guard let self = self else { return nil } guard let authenticationService = self.authenticationService else { return nil } - guard let activeMastodonAuthenticationBox = mastodonAuthenticationBoxes.first else { return nil } - let domain = activeMastodonAuthenticationBox.domain - let userID = activeMastodonAuthenticationBox.userID - return authenticationService.backgroundManagedObjectContext.performChanges { - _ = APIService.CoreData.createOrMergeSetting( - into: authenticationService.backgroundManagedObjectContext, - property: Setting.Property( - domain: domain, - userID: userID, - appearanceRaw: SettingsItem.AppearanceMode.automatic.rawValue + let managedObjectContext = authenticationService.backgroundManagedObjectContext + return managedObjectContext.performChanges { + for authenticationBox in mastodonAuthenticationBoxes { + let domain = authenticationBox.domain + let userID = authenticationBox.userID + _ = APIService.CoreData.createOrMergeSetting( + into: managedObjectContext, + property: Setting.Property( + domain: domain, + userID: userID + ) ) - ) + } // end for } .map { _ in mastodonAuthenticationBoxes } .eraseToAnyPublisher() @@ -188,16 +189,16 @@ extension SettingService { static func updatePreference(setting: Setting) { // set appearance - let userInterfaceStyle: UIUserInterfaceStyle = { - switch setting.appearance { - case .automatic: return .unspecified - case .light: return .light - case .dark: return .dark - } - }() - if UserDefaults.shared.customUserInterfaceStyle != userInterfaceStyle { - UserDefaults.shared.customUserInterfaceStyle = userInterfaceStyle - } +// let userInterfaceStyle: UIUserInterfaceStyle = { +// switch setting.appearance { +// case .automatic: return .unspecified +// case .light: return .light +// case .dark: return .dark +// } +// }() +// if UserDefaults.shared.customUserInterfaceStyle != userInterfaceStyle { +// UserDefaults.shared.customUserInterfaceStyle = userInterfaceStyle +// } // set theme let themeName: ThemeName = setting.preferredTrueBlackDarkMode ? .system : .mastodon diff --git a/Mastodon/Service/StatusPublishService.swift b/Mastodon/Service/StatusPublishService.swift index ed894f933..f5c4cb2dd 100644 --- a/Mastodon/Service/StatusPublishService.swift +++ b/Mastodon/Service/StatusPublishService.swift @@ -12,6 +12,7 @@ import Combine import CoreData import CoreDataStack import MastodonSDK +import UIKit final class StatusPublishService { @@ -72,7 +73,6 @@ extension StatusPublishService { self.viewModels.value = viewModels os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: composeViewModel removed", ((#file as NSString).lastPathComponent), #line, #function) - } } diff --git a/Mastodon/Service/ThemeService/MastodonTheme.swift b/Mastodon/Service/ThemeService/MastodonTheme.swift index dbf2324cd..1f0fd4e38 100644 --- a/Mastodon/Service/ThemeService/MastodonTheme.swift +++ b/Mastodon/Service/ThemeService/MastodonTheme.swift @@ -22,9 +22,11 @@ struct MastodonTheme: Theme { let tertiarySystemGroupedBackgroundColor = Asset.Theme.Mastodon.tertiarySystemGroupedBackground.color let navigationBarBackgroundColor = Asset.Theme.Mastodon.navigationBarBackground.color + + let sidebarBackgroundColor = Asset.Theme.Mastodon.sidebarBackground.color let tabBarBackgroundColor = Asset.Theme.Mastodon.tabBarBackground.color - let tabBarItemSelectedIconColor = Asset.Colors.brandBlue.color + let tabBarItemSelectedIconColor = ThemeService.tintColor let tabBarItemFocusedIconColor = Asset.Theme.Mastodon.tabBarItemInactiveIconColor.color let tabBarItemNormalIconColor = Asset.Theme.Mastodon.tabBarItemInactiveIconColor.color let tabBarItemDisabledIconColor = Asset.Theme.Mastodon.tabBarItemInactiveIconColor.color diff --git a/Mastodon/Service/ThemeService/SystemTheme.swift b/Mastodon/Service/ThemeService/SystemTheme.swift index 3a8ddd2d8..26673d57d 100644 --- a/Mastodon/Service/ThemeService/SystemTheme.swift +++ b/Mastodon/Service/ThemeService/SystemTheme.swift @@ -23,8 +23,10 @@ struct SystemTheme: Theme { let navigationBarBackgroundColor = Asset.Theme.System.navigationBarBackground.color + let sidebarBackgroundColor = Asset.Theme.System.sidebarBackground.color + let tabBarBackgroundColor = Asset.Theme.System.tabBarBackground.color - let tabBarItemSelectedIconColor = Asset.Colors.brandBlue.color + let tabBarItemSelectedIconColor = ThemeService.tintColor let tabBarItemFocusedIconColor = Asset.Theme.System.tabBarItemInactiveIconColor.color let tabBarItemNormalIconColor = Asset.Theme.System.tabBarItemInactiveIconColor.color let tabBarItemDisabledIconColor = Asset.Theme.System.tabBarItemInactiveIconColor.color diff --git a/Mastodon/Service/ThemeService/Theme.swift b/Mastodon/Service/ThemeService/Theme.swift index 4074e0904..1a3b3c5d1 100644 --- a/Mastodon/Service/ThemeService/Theme.swift +++ b/Mastodon/Service/ThemeService/Theme.swift @@ -22,6 +22,8 @@ public protocol Theme { var tertiarySystemGroupedBackgroundColor: UIColor { get } var navigationBarBackgroundColor: UIColor { get } + + var sidebarBackgroundColor: UIColor { get } var tabBarBackgroundColor: UIColor { get } var tabBarItemSelectedIconColor: UIColor { get } diff --git a/Mastodon/Service/ThemeService/ThemeService+Appearance.swift b/Mastodon/Service/ThemeService/ThemeService+Appearance.swift index 182fe870a..896ed888e 100644 --- a/Mastodon/Service/ThemeService/ThemeService+Appearance.swift +++ b/Mastodon/Service/ThemeService/ThemeService+Appearance.swift @@ -24,6 +24,9 @@ extension ThemeService { UINavigationBar.appearance().standardAppearance = appearance UINavigationBar.appearance().compactAppearance = appearance UINavigationBar.appearance().scrollEdgeAppearance = appearance + if #available(iOS 15.0, *) { + UINavigationBar.appearance().compactScrollEdgeAppearance = appearance + } // set tab bar appearance let tabBarAppearance = UITabBarAppearance() @@ -43,8 +46,13 @@ extension ThemeService { tabBarAppearance.compactInlineLayoutAppearance = tabBarItemAppearance tabBarAppearance.backgroundColor = theme.tabBarBackgroundColor - tabBarAppearance.selectionIndicatorTintColor = Asset.Colors.brandBlue.color + tabBarAppearance.selectionIndicatorTintColor = ThemeService.tintColor UITabBar.appearance().standardAppearance = tabBarAppearance + if #available(iOS 15.0, *) { + UITabBar.appearance().scrollEdgeAppearance = tabBarAppearance + } else { + // Fallback on earlier versions + } UITabBar.appearance().barTintColor = theme.tabBarBackgroundColor // set table view cell appearance @@ -53,9 +61,9 @@ extension ThemeService { UITableViewCell.appearance().selectionColor = theme.tableViewCellSelectionBackgroundColor // set search bar appearance - UISearchBar.appearance().tintColor = Asset.Colors.brandBlue.color + UISearchBar.appearance().tintColor = ThemeService.tintColor UISearchBar.appearance().barTintColor = theme.navigationBarBackgroundColor - let cancelButtonAttributes: [NSAttributedString.Key : Any] = [NSAttributedString.Key.foregroundColor: Asset.Colors.brandBlue.color] + let cancelButtonAttributes: [NSAttributedString.Key : Any] = [NSAttributedString.Key.foregroundColor: ThemeService.tintColor] UIBarButtonItem.appearance(whenContainedInInstancesOf: [UISearchBar.self]).setTitleTextAttributes(cancelButtonAttributes, for: .normal) } } diff --git a/Mastodon/Service/ThemeService/ThemeService.swift b/Mastodon/Service/ThemeService/ThemeService.swift index 35d5b3491..e3bd7c4ab 100644 --- a/Mastodon/Service/ThemeService/ThemeService.swift +++ b/Mastodon/Service/ThemeService/ThemeService.swift @@ -10,6 +10,8 @@ import Combine // ref: https://zamzam.io/protocol-oriented-themes-for-ios-apps/ final class ThemeService { + + static let tintColor: UIColor = .label // MARK: - Singleton public static let shared = ThemeService() diff --git a/Mastodon/State/AppContext.swift b/Mastodon/State/AppContext.swift index d4682ed5e..d7c08d47f 100644 --- a/Mastodon/State/AppContext.swift +++ b/Mastodon/State/AppContext.swift @@ -31,6 +31,7 @@ class AppContext: ObservableObject { let statusPublishService = StatusPublishService() let notificationService: NotificationService let settingService: SettingService + let instanceService: InstanceService let blockDomainService: BlockDomainService let statusFilterService: StatusFilterService @@ -87,6 +88,11 @@ class AppContext: ObservableObject { notificationService: _notificationService ) + instanceService = InstanceService( + apiService: _apiService, + authenticationService: _authenticationService + ) + blockDomainService = BlockDomainService( backgroundManagedObjectContext: _backgroundManagedObjectContext, authenticationService: _authenticationService diff --git a/Mastodon/Supporting Files/AppDelegate.swift b/Mastodon/Supporting Files/AppDelegate.swift index 56382babf..e2cb7c41b 100644 --- a/Mastodon/Supporting Files/AppDelegate.swift +++ b/Mastodon/Supporting Files/AppDelegate.swift @@ -36,6 +36,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate { UNUserNotificationCenter.current().delegate = self application.registerForRemoteNotifications() + // increase app process count + var count = UserDefaults.shared.processCompletedCount + count += 1 // Int64. could ignore overflow here + UserDefaults.shared.processCompletedCount = count + #if ASDK && DEBUG // PerformanceMonitor.shared().start() // ASDisplayNode.shouldShowRangeDebugOverlay = true @@ -100,11 +105,16 @@ extension AppDelegate: UNUserNotificationCenterDelegate { let notificationID = String(mastodonPushNotification.notificationID) os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification] notification %s", ((#file as NSString).lastPathComponent), #line, #function, notificationID) + + let accessToken = mastodonPushNotification.accessToken + UserDefaults.shared.increaseNotificationCount(accessToken: accessToken) + appContext.notificationService.applicationIconBadgeNeedsUpdate.send() + appContext.notificationService.handle(mastodonPushNotification: mastodonPushNotification) completionHandler([.sound]) } - // response to user action for notification + // response to user action for notification (e.g. redirect to post) func userNotificationCenter( _ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, @@ -120,7 +130,7 @@ extension AppDelegate: UNUserNotificationCenterDelegate { let notificationID = String(mastodonPushNotification.notificationID) os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification] notification %s", ((#file as NSString).lastPathComponent), #line, #function, notificationID) appContext.notificationService.handle(mastodonPushNotification: mastodonPushNotification) - appContext.notificationService.requestRevealNotificationPublisher.send(notificationID) + appContext.notificationService.requestRevealNotificationPublisher.send(mastodonPushNotification) completionHandler() } diff --git a/Mastodon/Supporting Files/SceneDelegate.swift b/Mastodon/Supporting Files/SceneDelegate.swift index d0067129b..4809fe5f9 100644 --- a/Mastodon/Supporting Files/SceneDelegate.swift +++ b/Mastodon/Supporting Files/SceneDelegate.swift @@ -37,7 +37,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { self.window = window // set tint color - window.tintColor = Asset.Colors.brandBlue.color + window.tintColor = UIColor.label ThemeService.shared.currentTheme .receive(on: RunLoop.main) @@ -87,9 +87,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { // Called when the scene has moved from an inactive state to an active state. // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. - // reset notification badge - UserDefaults.shared.notificationBadgeCount = 0 - UIApplication.shared.applicationIconBadgeNumber = 0 + // update application badge + AppContext.shared.notificationService.applicationIconBadgeNeedsUpdate.send() // trigger status filter update AppContext.shared.statusFilterService.filterUpdatePublisher.send() @@ -156,19 +155,3 @@ extension SceneDelegate { return true } } - -#if DEBUG -class TestWindow: UIWindow { - - override func sendEvent(_ event: UIEvent) { - event.allTouches?.forEach({ (touch) in - let location = touch.location(in: self) - let view = hitTest(location, with: event) - print(view.debugDescription) - }) - - super.sendEvent(event) - } -} -#endif - diff --git a/Mastodon/Vender/CurveAlgorithm.swift b/Mastodon/Vender/CurveAlgorithm.swift new file mode 100644 index 000000000..0ca4c8734 --- /dev/null +++ b/Mastodon/Vender/CurveAlgorithm.swift @@ -0,0 +1,47 @@ +// +// CurveAlgorithm.swift +// +// Ref: https://github.com/nhatminh12369/LineChart/blob/master/LineChart/CurveAlgorithm.swift + +import UIKit + +struct CurvedSegment { + var controlPoint1: CGPoint + var controlPoint2: CGPoint +} + +class CurveAlgorithm { + static let shared = CurveAlgorithm() + + private func controlPointsFrom(points: [CGPoint]) -> [CurvedSegment] { + var result: [CurvedSegment] = [] + + let delta: CGFloat = 0.2 + + // only use horizontal control point + for i in 1.. UIBezierPath? { + let path = UIBezierPath() + path.move(to: dataPoints[0]) + + var curveSegments: [CurvedSegment] = [] + curveSegments = controlPointsFrom(points: dataPoints) + + for i in 1.. +#import +#import +#import + +@implementation UIStatusBarManager (CAPHandleTapAction) + ++ (void)load { + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + Class class = [self class]; + SEL originalSelector = NSSelectorFromString(@"handleTapAction:"); + SEL swizzledSelector = @selector(custom_handleTapAction:); + + Method originalMethod = class_getInstanceMethod(self, originalSelector); + Method swizzledMethod = class_getInstanceMethod(self, swizzledSelector); + + BOOL didAddMethod = class_addMethod(class, + originalSelector, + method_getImplementation(swizzledMethod), + method_getTypeEncoding(swizzledMethod)); + if (didAddMethod) { + class_replaceMethod(class, + swizzledSelector, + method_getImplementation(originalMethod), + method_getTypeEncoding(originalMethod)); + } else { + method_exchangeImplementations(originalMethod, swizzledMethod); + } + }); +} + +-(void)custom_handleTapAction:(id)sender { + [[NSNotificationCenter defaultCenter] postNotificationName:@"org.joinmastodon.app.statusBarTapped" object:sender]; + [self custom_handleTapAction:sender]; +} + +@end diff --git a/MastodonIntent/Info.plist b/MastodonIntent/Info.plist index cfc2744b7..8ac3d165b 100644 --- a/MastodonIntent/Info.plist +++ b/MastodonIntent/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.0.8 + 1.2.0 CFBundleVersion - 60 + 88 NSExtension NSExtensionAttributes diff --git a/MastodonIntent/ar.lproj/Intents.strings b/MastodonIntent/ar.lproj/Intents.strings index 6877490ba..cde27dc97 100644 --- a/MastodonIntent/ar.lproj/Intents.strings +++ b/MastodonIntent/ar.lproj/Intents.strings @@ -1,51 +1,51 @@ -"16wxgf" = "Post on Mastodon"; +"16wxgf" = "النَشر على ماستودون"; -"751xkl" = "Text Content"; +"751xkl" = "محتوى نصي"; -"CsR7G2" = "Post on Mastodon"; +"CsR7G2" = "انشر على ماستدون"; -"HZSGTr" = "What content to post?"; +"HZSGTr" = "ما المُحتوى المُراد نشره؟"; -"HdGikU" = "Posting failed"; +"HdGikU" = "فَشَلَ النشر"; -"KDNTJ4" = "Failure Reason"; +"KDNTJ4" = "سبب الإخفاق"; -"RHxKOw" = "Send Post with text content"; +"RHxKOw" = "إرسال مَنشور يَحوي نص"; -"RxSqsb" = "Post"; +"RxSqsb" = "مَنشور"; -"WCIR3D" = "Post ${content} on Mastodon"; +"WCIR3D" = "نَشر ${content} على ماستودون"; -"ZKJSNu" = "Post"; +"ZKJSNu" = "مَنشور"; "ZS1XaK" = "${content}"; -"ZbSjzC" = "Visibility"; +"ZbSjzC" = "مدى الظهور"; -"Zo4jgJ" = "Post Visibility"; +"Zo4jgJ" = "مدى ظهور المنشور"; -"apSxMG-dYQ5NN" = "There are ${count} options matching ‘Public’."; +"apSxMG-dYQ5NN" = "هُناك عدد ${count} خِيار مُطابق لِـ\"عام\"."; -"apSxMG-ehFLjY" = "There are ${count} options matching ‘Followers Only’."; +"apSxMG-ehFLjY" = "هُناك عدد ${count} خِيار مُطابق لِـ\"المُتابِعُون فقط\"."; -"ayoYEb-dYQ5NN" = "${content}, Public"; +"ayoYEb-dYQ5NN" = "${content}، عام"; -"ayoYEb-ehFLjY" = "${content}, Followers Only"; +"ayoYEb-ehFLjY" = "${content}، المُتابِعُون فقط"; -"dUyuGg" = "Post on Mastodon"; +"dUyuGg" = "النشر على ماستدون"; -"dYQ5NN" = "Public"; +"dYQ5NN" = "للعامة"; -"ehFLjY" = "Followers Only"; +"ehFLjY" = "لمتابعيك فقط"; -"gfePDu" = "Posting failed. ${failureReason}"; +"gfePDu" = "فَشَلَ النشر، ${failureReason}"; -"k7dbKQ" = "Post was sent successfully."; +"k7dbKQ" = "تمَّ إرسال المنشور بِنجاح."; -"oGiqmY-dYQ5NN" = "Just to confirm, you wanted ‘Public’?"; +"oGiqmY-dYQ5NN" = "للتأكيد، هل تَريد \"عام\"؟"; -"oGiqmY-ehFLjY" = "Just to confirm, you wanted ‘Followers Only’?"; +"oGiqmY-ehFLjY" = "للتأكيد، هل تُريد \"للمُتابِعين فقط\"؟"; -"rM6dvp" = "URL"; +"rM6dvp" = "عنوان URL"; -"ryJLwG" = "Post was sent successfully. "; +"ryJLwG" = "تم إرسال المنشور بنجاح. "; diff --git a/MastodonIntent/fr.lproj/Intents.strings b/MastodonIntent/fr.lproj/Intents.strings index 6877490ba..f4fec3000 100644 --- a/MastodonIntent/fr.lproj/Intents.strings +++ b/MastodonIntent/fr.lproj/Intents.strings @@ -1,28 +1,28 @@ -"16wxgf" = "Post on Mastodon"; +"16wxgf" = "Publier sur Mastodon"; -"751xkl" = "Text Content"; +"751xkl" = "Contenu textuel"; -"CsR7G2" = "Post on Mastodon"; +"CsR7G2" = "Publier sur Mastodon"; -"HZSGTr" = "What content to post?"; +"HZSGTr" = "Quel contenu à publier ?"; -"HdGikU" = "Posting failed"; +"HdGikU" = "Échec lors de la publication"; -"KDNTJ4" = "Failure Reason"; +"KDNTJ4" = "Raison de l’échec"; -"RHxKOw" = "Send Post with text content"; +"RHxKOw" = "Envoyer une publication avec du contenu texte"; "RxSqsb" = "Post"; -"WCIR3D" = "Post ${content} on Mastodon"; +"WCIR3D" = "Publier du ${content} sur Mastodon"; -"ZKJSNu" = "Post"; +"ZKJSNu" = "Publication"; "ZS1XaK" = "${content}"; -"ZbSjzC" = "Visibility"; +"ZbSjzC" = "Visibilité"; -"Zo4jgJ" = "Post Visibility"; +"Zo4jgJ" = "Visibilité de la publication"; "apSxMG-dYQ5NN" = "There are ${count} options matching ‘Public’."; @@ -30,22 +30,22 @@ "ayoYEb-dYQ5NN" = "${content}, Public"; -"ayoYEb-ehFLjY" = "${content}, Followers Only"; +"ayoYEb-ehFLjY" = "${content}, abonné·e·s seulement"; -"dUyuGg" = "Post on Mastodon"; +"dUyuGg" = "Publier sur Mastodon"; "dYQ5NN" = "Public"; -"ehFLjY" = "Followers Only"; +"ehFLjY" = "Abonné·e·s seulement"; -"gfePDu" = "Posting failed. ${failureReason}"; +"gfePDu" = "Échec lors de la publication. ${failureReason}"; -"k7dbKQ" = "Post was sent successfully."; +"k7dbKQ" = "Message publié avec succès."; -"oGiqmY-dYQ5NN" = "Just to confirm, you wanted ‘Public’?"; +"oGiqmY-dYQ5NN" = "Juste pour confirmer, vous vouliez « Public » ?"; -"oGiqmY-ehFLjY" = "Just to confirm, you wanted ‘Followers Only’?"; +"oGiqmY-ehFLjY" = "Juste pour confirmer, vous vouliez bien diffuser vers « abonné·e·s uniquement » ?"; "rM6dvp" = "URL"; -"ryJLwG" = "Post was sent successfully. "; +"ryJLwG" = "La publication a été envoyée avec succès. "; diff --git a/MastodonIntent/gd-GB.lproj/Intents.strings b/MastodonIntent/gd-GB.lproj/Intents.strings index 0f8ef5edc..526defecd 100644 --- a/MastodonIntent/gd-GB.lproj/Intents.strings +++ b/MastodonIntent/gd-GB.lproj/Intents.strings @@ -24,9 +24,9 @@ "Zo4jgJ" = "Faicsinneachd a’ phuist"; -"apSxMG-dYQ5NN" = "There are ${count} options matching ‘Public’."; +"apSxMG-dYQ5NN" = "Tha ${count} roghainn(ean) dha “Poblach” ann."; -"apSxMG-ehFLjY" = "There are ${count} options matching ‘Followers Only’."; +"apSxMG-ehFLjY" = "Tha ${count} roghainn(ean) dha “Luchd-leantainn a-mhàin” ann."; "ayoYEb-dYQ5NN" = "${content}, poblach"; diff --git a/MastodonIntent/ku-TR.lproj/Intents.strings b/MastodonIntent/ku-TR.lproj/Intents.strings new file mode 100644 index 000000000..13a86e0c0 --- /dev/null +++ b/MastodonIntent/ku-TR.lproj/Intents.strings @@ -0,0 +1,51 @@ +"16wxgf" = "Di Mastodon de biweşîne"; + +"751xkl" = "Naveroka nivîsê"; + +"CsR7G2" = "Di Mastodon de biweşîne"; + +"HZSGTr" = "Kîjan naverok bila bê şandin?"; + +"HdGikU" = "Şandin têkçû"; + +"KDNTJ4" = "Sedema têkçûnê"; + +"RHxKOw" = "Bi naveroka nivîsî şandiyan bişîne"; + +"RxSqsb" = "Şandî"; + +"WCIR3D" = "${content} biweşîne di Mastodon de"; + +"ZKJSNu" = "Şandî"; + +"ZS1XaK" = "${content}"; + +"ZbSjzC" = "Xuyanî"; + +"Zo4jgJ" = "Xuyaniya şandiyê"; + +"apSxMG-dYQ5NN" = "Vebijarkên ${count} hene ku li gorî 'Gelemperî' ne."; + +"apSxMG-ehFLjY" = "Vebijarkên ${count} hene ku li gorî 'Tenê Şopaneran' hene."; + +"ayoYEb-dYQ5NN" = "${content}, Gelemperî"; + +"ayoYEb-ehFLjY" = "${content}, Tenê şopînêr"; + +"dUyuGg" = "Di Mastodon de biweşîne"; + +"dYQ5NN" = "Gelemperî"; + +"ehFLjY" = "Tenê şopîneran"; + +"gfePDu" = "Weşandin bi ser neket. ${failureReason}"; + +"k7dbKQ" = "Şandî bi serkeftî hate şandin."; + +"oGiqmY-dYQ5NN" = "Tenê ji bo pejirandinê, te 'Gelemperî' dixwest?"; + +"oGiqmY-ehFLjY" = "Tenê ji bo pejirandinê, te 'Tenê Şopîner' dixwest?"; + +"rM6dvp" = "Girêdan"; + +"ryJLwG" = "Şandî bi serkeftî hate şandin. "; diff --git a/MastodonIntent/ku-TR.lproj/Intents.stringsdict b/MastodonIntent/ku-TR.lproj/Intents.stringsdict new file mode 100644 index 000000000..5a39d5e64 --- /dev/null +++ b/MastodonIntent/ku-TR.lproj/Intents.stringsdict @@ -0,0 +1,54 @@ + + + + + There are ${count} options matching ‘${content}’. - 2 + + NSStringLocalizedFormatKey + There are %#@count_option@ matching ‘${content}’. + count_option + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + %ld + zero + 0 options + one + 1 option + two + 2 options + few + %ld options + many + %ld options + other + %ld options + + + There are ${count} options matching ‘${visibility}’. + + NSStringLocalizedFormatKey + There are %#@count_option@ matching ‘${visibility}’. + count_option + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + %ld + zero + 0 options + one + 1 option + two + 2 options + few + %ld options + many + %ld options + other + %ld options + + + + diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+FollowRequest.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+FollowRequest.swift index 87c879ea0..7adbcdeff 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+FollowRequest.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+FollowRequest.swift @@ -12,13 +12,15 @@ import Combine extension Mastodon.API.Account { static func acceptFollowRequestEndpointURL(domain: String, userID: Mastodon.Entity.Account.ID) -> URL { - return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("follow_requests") + return Mastodon.API.endpointURL(domain: domain) + .appendingPathComponent("follow_requests") .appendingPathComponent(userID) .appendingPathComponent("authorize") } static func rejectFollowRequestEndpointURL(domain: String, userID: Mastodon.Entity.Account.ID) -> URL { - return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("follow_requests") + return Mastodon.API.endpointURL(domain: domain) + .appendingPathComponent("follow_requests") .appendingPathComponent(userID) .appendingPathComponent("reject") } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Followers.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Followers.swift new file mode 100644 index 000000000..a900a1c12 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Followers.swift @@ -0,0 +1,82 @@ +// +// Mastodon+API+Account+Followers.swift +// +// +// Created by Cirno MainasuK on 2021-11-1. +// + +import Foundation +import Combine + +extension Mastodon.API.Account { + + static func followersEndpointURL(domain: String, userID: Mastodon.Entity.Account.ID) -> URL { + return Mastodon.API.endpointURL(domain: domain) + .appendingPathComponent("accounts") + .appendingPathComponent(userID) + .appendingPathComponent("followers") + } + + /// Followers + /// + /// Accounts which follow the given account, if network is not hidden by the account owner. + /// + /// - Since: 0.0.0 + /// - Version: 3.4.1 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - userID: ID of the account in the database + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `[Account]` nested in the response + public static func followers( + session: URLSession, + domain: String, + userID: Mastodon.Entity.Account.ID, + query: FollowerQuery, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: followersEndpointURL(domain: domain, userID: userID), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: [Mastodon.Entity.Account].self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + public struct FollowerQuery: Codable, GetQuery { + + public let maxID: String? + public let limit: Int? // default 40 + + enum CodingKeys: String, CodingKey { + case maxID = "max_id" + case limit + } + + public init( + maxID: String?, + limit: Int? + ) { + self.maxID = maxID + self.limit = limit + } + + var queryItems: [URLQueryItem]? { + var items: [URLQueryItem] = [] + maxID.flatMap { items.append(URLQueryItem(name: "max_id", value: $0)) } + limit.flatMap { items.append(URLQueryItem(name: "limit", value: String($0))) } + guard !items.isEmpty else { return nil } + return items + } + + } + +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Following.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Following.swift new file mode 100644 index 000000000..c992c7584 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Following.swift @@ -0,0 +1,82 @@ +// +// Mastodon+API+Account+Following.swift +// +// +// Created by Cirno MainasuK on 2021-11-2. +// + +import Foundation +import Combine + +extension Mastodon.API.Account { + + static func followingEndpointURL(domain: String, userID: Mastodon.Entity.Account.ID) -> URL { + return Mastodon.API.endpointURL(domain: domain) + .appendingPathComponent("accounts") + .appendingPathComponent(userID) + .appendingPathComponent("following") + } + + /// Following + /// + /// Accounts which the given account is following, if network is not hidden by the account owner. + /// + /// - Since: 0.0.0 + /// - Version: 3.4.1 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - userID: ID of the account in the database + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `[Account]` nested in the response + public static func following( + session: URLSession, + domain: String, + userID: Mastodon.Entity.Account.ID, + query: FollowingQuery, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: followingEndpointURL(domain: domain, userID: userID), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: [Mastodon.Entity.Account].self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + public struct FollowingQuery: Codable, GetQuery { + + public let maxID: String? + public let limit: Int? // default 40 + + enum CodingKeys: String, CodingKey { + case maxID = "max_id" + case limit + } + + public init( + maxID: String?, + limit: Int? + ) { + self.maxID = maxID + self.limit = limit + } + + var queryItems: [URLQueryItem]? { + var items: [URLQueryItem] = [] + maxID.flatMap { items.append(URLQueryItem(name: "max_id", value: $0)) } + limit.flatMap { items.append(URLQueryItem(name: "limit", value: String($0))) } + guard !items.isEmpty else { return nil } + return items + } + + } + +} diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Instance.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Instance.swift index 226af40f8..d0d16ee4a 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Instance.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Instance.swift @@ -34,6 +34,9 @@ extension Mastodon.Entity { public let thumbnail: String? public let contactAccount: Account? public let rules: [Rule]? + + // https://github.com/mastodon/mastodon/pull/16485 + public let configuration: Configuration? enum CodingKeys: String, CodingKey { case uri @@ -52,6 +55,8 @@ extension Mastodon.Entity { case thumbnail case contactAccount = "contact_account" case rules + + case configuration } } } @@ -86,3 +91,63 @@ extension Mastodon.Entity.Instance { public let text: String } } + +extension Mastodon.Entity.Instance { + public struct Configuration: Codable { + public let statuses: Statuses? + public let mediaAttachments: MediaAttachments? + public let polls: Polls? + + enum CodingKeys: String, CodingKey { + case statuses + case mediaAttachments = "media_attachments" + case polls + } + } +} + +extension Mastodon.Entity.Instance.Configuration { + public struct Statuses: Codable { + public let maxCharacters: Int + public let maxMediaAttachments: Int + public let charactersReservedPerURL: Int + + enum CodingKeys: String, CodingKey { + case maxCharacters = "max_characters" + case maxMediaAttachments = "max_media_attachments" + case charactersReservedPerURL = "characters_reserved_per_url" + } + } + + public struct MediaAttachments: Codable { + public let supportedMIMETypes: [String] + public let imageSizeLimit: Int + public let imageMatrixLimit: Int + public let videoSizeLimit: Int + public let videoFrameRateLimit: Int + public let videoMatrixLimit: Int + + enum CodingKeys: String, CodingKey { + case supportedMIMETypes = "supported_mime_types" + case imageSizeLimit = "image_size_limit" + case imageMatrixLimit = "image_matrix_limit" + case videoSizeLimit = "video_size_limit" + case videoFrameRateLimit = "video_frame_rate_limit" + case videoMatrixLimit = "video_matrix_limit" + } + } + + public struct Polls: Codable { + public let maxOptions: Int + public let maxCharactersPerOption: Int + public let minExpiration: Int + public let maxExpiration: Int + + enum CodingKeys: String, CodingKey { + case maxOptions = "max_options" + case maxCharactersPerOption = "max_characters_per_option" + case minExpiration = "min_expiration" + case maxExpiration = "max_expiration" + } + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Tag.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Tag.swift index e7f095eb3..740001572 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Tag.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Tag.swift @@ -22,6 +22,7 @@ extension Mastodon.Entity { public let url: String public let history: [History]? + enum CodingKeys: String, CodingKey { case name case url diff --git a/MastodonTests/Info.plist b/MastodonTests/Info.plist index 889a6a010..9fe845c60 100644 --- a/MastodonTests/Info.plist +++ b/MastodonTests/Info.plist @@ -15,8 +15,8 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.0.8 + 1.2.0 CFBundleVersion - 60 + 88 diff --git a/MastodonUITests/Info.plist b/MastodonUITests/Info.plist index 889a6a010..9fe845c60 100644 --- a/MastodonUITests/Info.plist +++ b/MastodonUITests/Info.plist @@ -15,8 +15,8 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.0.8 + 1.2.0 CFBundleVersion - 60 + 88 diff --git a/NotificationService/Info.plist b/NotificationService/Info.plist index cd6753939..8e14f3a2a 100644 --- a/NotificationService/Info.plist +++ b/NotificationService/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.0.8 + 1.2.0 CFBundleVersion - 60 + 88 NSExtension NSExtensionPointIdentifier diff --git a/NotificationService/MastodonNotification.swift b/NotificationService/MastodonNotification.swift index f3941b12d..7d6fb034d 100644 --- a/NotificationService/MastodonNotification.swift +++ b/NotificationService/MastodonNotification.swift @@ -9,7 +9,7 @@ import Foundation struct MastodonPushNotification: Codable { - private let _accessToken: String + let _accessToken: String var accessToken: String { return String.normalize(base64String: _accessToken) } @@ -32,4 +32,22 @@ struct MastodonPushNotification: Codable { case body } + public init( + _accessToken: String, + notificationID: Int, + notificationType: String, + preferredLocale: String?, + icon: String?, + title: String, + body: String + ) { + self._accessToken = _accessToken + self.notificationID = notificationID + self.notificationType = notificationType + self.preferredLocale = preferredLocale + self.icon = icon + self.title = title + self.body = body + } + } diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift index e65ea9aca..c3d02933b 100644 --- a/NotificationService/NotificationService.swift +++ b/NotificationService/NotificationService.swift @@ -60,6 +60,9 @@ class NotificationService: UNNotificationServiceExtension { bestAttemptContent.sound = UNNotificationSound.init(named: UNNotificationSoundName(rawValue: "BoopSound.caf")) bestAttemptContent.userInfo["plaintext"] = plaintextData + let accessToken = notification.accessToken + UserDefaults.shared.increaseNotificationCount(accessToken: accessToken) + UserDefaults.shared.notificationBadgeCount += 1 bestAttemptContent.badge = NSNumber(integerLiteral: UserDefaults.shared.notificationBadgeCount) diff --git a/Podfile.lock b/Podfile.lock index b2c540abf..3541289d0 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -1,7 +1,7 @@ PODS: - DateToolsSwift (5.0.0) - FLEX (4.4.1) - - Kanna (5.2.4) + - Kanna (5.2.7) - Keys (1.0.1) - PINCache (3.0.3): - PINCache/Arc-exception-safe (= 3.0.3) @@ -69,7 +69,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: DateToolsSwift: 4207ada6ad615d8dc076323d27037c94916dbfa6 FLEX: 7ca2c8cd3a435ff501ff6d2f2141e9bdc934eaab - Kanna: b9d00d7c11428308c7f95e1f1f84b8205f567a8f + Kanna: 01cfbddc127f5ff0963692f285fcbc8a9d62d234 Keys: a576f4c9c1c641ca913a959a9c62ed3f215a8de9 PINCache: 7a8fc1a691173d21dbddbf86cd515de6efa55086 PINOperation: 00c935935f1e8cf0d1e2d6b542e75b88fc3e5e20 @@ -80,4 +80,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 4db0bdf969729c5758bd923e33d9e097cb892086 -COCOAPODS: 1.10.2 +COCOAPODS: 1.11.2 diff --git a/README.md b/README.md index 917526711..e1686b2e8 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ The app is compatible with [toot-relay](https://github.com/DagAgren/toot-relay) - [Nuke-FLAnimatedImage-Plugin](https://github.com/kean/Nuke-FLAnimatedImage-Plugin) - [Nuke](https://github.com/kean/Nuke) - [Pageboy](https://github.com/uias/Pageboy#the-basics) +- [PanModal](https://github.com/slackhq/PanModal.git) - [SDWebImage](https://github.com/SDWebImage/SDWebImage) - [swift-collections](https://github.com/apple/swift-collections) - [swift-nio](https://github.com/apple/swift-nio) diff --git a/ShareActionExtension/Info.plist b/ShareActionExtension/Info.plist index 7cfb6436e..1b3025474 100644 --- a/ShareActionExtension/Info.plist +++ b/ShareActionExtension/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.0.8 + 1.2.0 CFBundleVersion - 60 + 88 NSExtension NSExtensionAttributes diff --git a/ShareActionExtension/Scene/View/ComposeToolbarView.swift b/ShareActionExtension/Scene/View/ComposeToolbarView.swift index e6842c744..d88bb018c 100644 --- a/ShareActionExtension/Scene/View/ComposeToolbarView.swift +++ b/ShareActionExtension/Scene/View/ComposeToolbarView.swift @@ -190,7 +190,7 @@ extension ComposeToolbarView { extension ComposeToolbarView { private static func configureToolbarButtonAppearance(button: UIButton) { - button.tintColor = Asset.Colors.brandBlue.color + button.tintColor = ThemeService.tintColor button.setBackgroundImage(.placeholder(size: ComposeToolbarView.toolbarButtonSize, color: .systemFill), for: .highlighted) button.layer.masksToBounds = true button.layer.cornerRadius = 5 diff --git a/ShareActionExtension/Scene/View/ComposeView.swift b/ShareActionExtension/Scene/View/ComposeView.swift index 25adf4c5a..a688d6492 100644 --- a/ShareActionExtension/Scene/View/ComposeView.swift +++ b/ShareActionExtension/Scene/View/ComposeView.swift @@ -85,6 +85,7 @@ public struct ComposeView: View { .frame(height: viewModel.toolbarHeight + 20) .listRow(backgroundColor: Color(viewModel.backgroundColor)) } // end List + .listStyle(.plain) .introspectTableView(customize: { tableView in // tableView.keyboardDismissMode = .onDrag tableView.verticalScrollIndicatorInsets.bottom = viewModel.toolbarHeight @@ -101,7 +102,7 @@ public struct ComposeView: View { .introspectTableView(customize: { tableView in tableView.backgroundColor = .clear }) - .background(Color(viewModel.backgroundColor).ignoresSafeArea()) + .overrideBackground(color: Color(viewModel.backgroundColor)) } // end GeometryReader } // end body } @@ -112,10 +113,26 @@ struct ComposeListViewFramePreferenceKey: PreferenceKey { } extension View { + // hack for separator line + @ViewBuilder func listRow(backgroundColor: Color) -> some View { - self.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) - .listRowInsets(EdgeInsets(top: -1, leading: -1, bottom: -1, trailing: -1)) - .background(backgroundColor) + // expand list row to edge (set inset) + // then hide the separator + if #available(iOS 15, *) { + frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .listRowInsets(EdgeInsets(top: -1, leading: -1, bottom: -1, trailing: -1)) + .background(backgroundColor) + .listRowSeparator(.hidden) // new API + } else { + frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .listRowInsets(EdgeInsets(top: -1, leading: -1, bottom: -1, trailing: -1)) // separator line hidden magic + .background(backgroundColor) + } + } + + @ViewBuilder + func overrideBackground(color: Color) -> some View { + background(color.ignoresSafeArea()) } }