diff --git a/.github/ISSUE_TEMPLATE/bug.yaml b/.github/ISSUE_TEMPLATE/bug.yaml new file mode 100644 index 000000000..c4a1f4911 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yaml @@ -0,0 +1,62 @@ +name: 🐞 Bug +description: File a bug/issue +title: "[BUG] " +labels: [Bug, Needs Triage] +body: +- type: checkboxes + attributes: + label: Is there an existing issue for this? + description: Please search to see if an issue already exists for the bug you encountered. + options: + - label: I have searched the existing issues + required: true +- type: textarea + attributes: + label: Current Behavior + description: A concise description of what you're experiencing. + validations: + required: false +- type: textarea + attributes: + label: Expected Behavior + description: A concise description of what you expected to happen. + validations: + required: false +- type: textarea + attributes: + label: Steps To Reproduce + description: Steps to reproduce the behavior. + placeholder: | + 1. In this environment... + 2. With this config... + 3. Tap '...' + 4. See error... + validations: + required: false +- type: textarea + attributes: + label: Environment + description: | + You can check the version and build number in the bottom of in-app settings. + examples: + - **Device**: iPhone X + - **OS**: iOS 15.3 + - **Version**: v1.3.0 + - **Build**: 103 + value: | + - Device: + - OS: + - Version: + - Build: + render: markdown + validations: + required: false +- type: textarea + attributes: + label: Anything else? + description: | + The server domain? Post links? Anything that will give us more context about the issue you are encountering! + + Tip: You can attach images or video or log files by clicking this area to highlight it and then dragging files in. + validations: + required: false \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/issue.md b/.github/ISSUE_TEMPLATE/issue.md deleted file mode 100644 index cc2855ede..000000000 --- a/.github/ISSUE_TEMPLATE/issue.md +++ /dev/null @@ -1,32 +0,0 @@ -## Description -<!--Brief description for bug--> - - -## App version -> You can check the version and build number in app setting footer. - -<!--Version Code here--> -- Version: v0.0.0 -- Build: 0 - -## Detail - -### Steps to reproduce - -<!--How to reproduce this bug?--> - -1. Tap … -2. … - -### Actual Behavior - -<!--What happened?--> - -The app … - -### Expected behavior - -<!--What is the expected behavior--> - -The app … - diff --git a/.github/scripts/build.sh b/.github/scripts/build.sh index 76e65f49f..f5894901a 100755 --- a/.github/scripts/build.sh +++ b/.github/scripts/build.sh @@ -7,7 +7,6 @@ set -eo pipefail xcodebuild -workspace Mastodon.xcworkspace \ -scheme Mastodon \ - -disableAutomaticPackageResolution \ -destination "platform=iOS Simulator,name=iPhone SE (2nd generation)" \ clean \ - build | xcpretty \ No newline at end of file + build | xcpretty diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b2979d002..a2f99d23e 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 13.1 - run: sudo xcode-select -switch /Applications/Xcode_13.1.app + - name: force Xcode 13.2.1 + run: sudo xcode-select -switch /Applications/Xcode_13.2.1.app - name: setup run: exec ./.github/scripts/setup.sh - name: build diff --git a/AppShared/AppSecret.swift b/AppShared/AppSecret.swift index 7ef7a0821..1fc5495c7 100644 --- a/AppShared/AppSecret.swift +++ b/AppShared/AppSecret.swift @@ -11,6 +11,10 @@ import CryptoKit import KeychainAccess import Keys +enum AppName { + public static let groupID = "group.org.joinmastodon.app" +} + public final class AppSecret { public static let keychain = Keychain(service: "org.joinmastodon.app.keychain", accessGroup: AppName.groupID) diff --git a/AppShared/Info.plist b/AppShared/Info.plist index 9fe845c60..73f11cd26 100644 --- a/AppShared/Info.plist +++ b/AppShared/Info.plist @@ -15,8 +15,8 @@ <key>CFBundlePackageType</key> <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> <key>CFBundleShortVersionString</key> - <string>1.2.0</string> + <string>1.3.0</string> <key>CFBundleVersion</key> - <string>88</string> + <string>109</string> </dict> </plist> diff --git a/AppShared/UserDefaults.swift b/AppShared/UserDefaults.swift index 753a3284f..31f8e27ef 100644 --- a/AppShared/UserDefaults.swift +++ b/AppShared/UserDefaults.swift @@ -6,6 +6,7 @@ // import UIKit +import MastodonCommon extension UserDefaults { public static let shared = UserDefaults(suiteName: AppName.groupID)! diff --git a/AppStoreSnapshotTestPlan.xctestplan b/AppStoreSnapshotTestPlan.xctestplan new file mode 100644 index 000000000..8761c4c01 --- /dev/null +++ b/AppStoreSnapshotTestPlan.xctestplan @@ -0,0 +1,34 @@ +{ + "configurations" : [ + { + "id" : "E27ADCCD-D2DF-4255-81D1-21CFC3C33254", + "name" : "Configuration 1", + "options" : { + + } + } + ], + "defaultOptions" : { + "defaultTestExecutionTimeAllowance" : 1800, + "testTimeoutsEnabled" : true + }, + "testTargets" : [ + { + "selectedTests" : [ + "MastodonUISnapshotTests\/testSmoke()", + "MastodonUISnapshotTests\/testSnapshotCompose()", + "MastodonUISnapshotTests\/testSnapshotHome()", + "MastodonUISnapshotTests\/testSnapshotProfile()", + "MastodonUISnapshotTests\/testSnapshotSearch()", + "MastodonUISnapshotTests\/testSnapshotServerRules()", + "MastodonUISnapshotTests\/testSnapshotThread()" + ], + "target" : { + "containerPath" : "container:Mastodon.xcodeproj", + "identifier" : "DB427DF225BAA00100D1B89D", + "name" : "MastodonUITests" + } + } + ], + "version" : 1 +} diff --git a/CoreDataStack/CoreDataStack.h b/CoreDataStack/CoreDataStack.h deleted file mode 100644 index 2e729ae7f..000000000 --- a/CoreDataStack/CoreDataStack.h +++ /dev/null @@ -1,18 +0,0 @@ -// -// CoreDataStack.h -// CoreDataStack -// -// Created by MainasuK Cirno on 2021/1/27. -// - -#import <Foundation/Foundation.h> - -//! Project version number for CoreDataStack. -FOUNDATION_EXPORT double CoreDataStackVersionNumber; - -//! Project version string for CoreDataStack. -FOUNDATION_EXPORT const unsigned char CoreDataStackVersionString[]; - -// In this header, you should import all the public headers of your framework using statements like #import <CoreDataStack/PublicHeader.h> - - diff --git a/CoreDataStack/Entity/Attachment.swift b/CoreDataStack/Entity/Attachment.swift deleted file mode 100644 index f3f5d262d..000000000 --- a/CoreDataStack/Entity/Attachment.swift +++ /dev/null @@ -1,126 +0,0 @@ -// -// Attachment.swift -// CoreDataStack -// -// Created by MainasuK Cirno on 2021-2-23. -// - -import CoreData -import Foundation - -public final class Attachment: NSManagedObject { - public typealias ID = String - - @NSManaged public private(set) var id: ID - @NSManaged public private(set) var domain: String - @NSManaged public private(set) var typeRaw: String - @NSManaged public private(set) var url: String - @NSManaged public private(set) var previewURL: String? - - @NSManaged public private(set) var remoteURL: String? - @NSManaged public private(set) var metaData: Data? - @NSManaged public private(set) var textURL: String? - @NSManaged public private(set) var descriptionString: String? - @NSManaged public private(set) var blurhash: String? - - @NSManaged public private(set) var createdAt: Date - @NSManaged public private(set) var updatedAt: Date - @NSManaged public private(set) var index: NSNumber - - // many-to-one relationship - @NSManaged public private(set) var status: Status? - -} - -public extension Attachment { - - override func awakeFromInsert() { - super.awakeFromInsert() - setPrimitiveValue(Date(), forKey: #keyPath(Attachment.createdAt)) - } - - @discardableResult - static func insert( - into context: NSManagedObjectContext, - property: Property - ) -> Attachment { - let attachment: Attachment = context.insertObject() - - attachment.domain = property.domain - attachment.index = property.index - - attachment.id = property.id - attachment.typeRaw = property.typeRaw - attachment.url = property.url - attachment.previewURL = property.previewURL - - attachment.remoteURL = property.remoteURL - attachment.metaData = property.metaData - attachment.textURL = property.textURL - attachment.descriptionString = property.descriptionString - attachment.blurhash = property.blurhash - - attachment.updatedAt = property.networkDate - - return attachment - } - - func didUpdate(at networkDate: Date) { - self.updatedAt = networkDate - } - -} - -public extension Attachment { - struct Property { - public let domain: String - public let index: NSNumber - - public let id: ID - public let typeRaw: String - public let url: String - - public let previewURL: String? - public let remoteURL: String? - public let metaData: Data? - public let textURL: String? - public let descriptionString: String? - public let blurhash: String? - - public let networkDate: Date - - public init( - domain: String, - index: Int, - id: Attachment.ID, - typeRaw: String, - url: String, - previewURL: String?, - remoteURL: String?, - metaData: Data?, - textURL: String?, - descriptionString: String?, - blurhash: String?, - networkDate: Date - ) { - self.domain = domain - self.index = NSNumber(value: index) - self.id = id - self.typeRaw = typeRaw - self.url = url - self.previewURL = previewURL - self.remoteURL = remoteURL - self.metaData = metaData - self.textURL = textURL - self.descriptionString = descriptionString - self.blurhash = blurhash - self.networkDate = networkDate - } - } -} - -extension Attachment: Managed { - public static var defaultSortDescriptors: [NSSortDescriptor] { - return [NSSortDescriptor(keyPath: \Attachment.createdAt, ascending: false)] - } -} diff --git a/CoreDataStack/Entity/HomeTimelineIndex.swift b/CoreDataStack/Entity/HomeTimelineIndex.swift deleted file mode 100644 index d52d0c3cd..000000000 --- a/CoreDataStack/Entity/HomeTimelineIndex.swift +++ /dev/null @@ -1,102 +0,0 @@ -// -// HomeTimelineIndex.swift -// CoreDataStack -// -// Created by MainasuK Cirno on 2021/1/27. -// - -import Foundation -import CoreData - -final public class HomeTimelineIndex: NSManagedObject { - - public typealias ID = String - @NSManaged public private(set) var identifier: ID - @NSManaged public private(set) var domain: String - @NSManaged public private(set) var userID: String - - @NSManaged public private(set) var hasMore: Bool // default NO - - @NSManaged public private(set) var createdAt: Date - @NSManaged public private(set) var deletedAt: Date? - - - // many-to-one relationship - @NSManaged public private(set) var status: Status - -} - -extension HomeTimelineIndex { - - @discardableResult - public static func insert( - into context: NSManagedObjectContext, - property: Property, - status: Status - ) -> HomeTimelineIndex { - let index: HomeTimelineIndex = context.insertObject() - - index.identifier = property.identifier - index.domain = property.domain - index.userID = property.userID - index.createdAt = status.createdAt - - index.status = status - - return index - } - - public func update(hasMore: Bool) { - if self.hasMore != hasMore { - self.hasMore = hasMore - } - } - - // internal method for status call - func softDelete() { - deletedAt = Date() - } - -} - -extension HomeTimelineIndex { - public struct Property { - public let identifier: String - public let domain: String - public let userID: String - - public init(domain: String, userID: String) { - self.identifier = UUID().uuidString + "@" + domain - self.domain = domain - self.userID = userID - } - } -} - -extension HomeTimelineIndex: Managed { - public static var defaultSortDescriptors: [NSSortDescriptor] { - return [NSSortDescriptor(keyPath: \HomeTimelineIndex.createdAt, ascending: false)] - } -} -extension HomeTimelineIndex { - - static func predicate(domain: String) -> NSPredicate { - return NSPredicate(format: "%K == %@", #keyPath(HomeTimelineIndex.domain), domain) - } - - static func predicate(userID: MastodonUser.ID) -> NSPredicate { - return NSPredicate(format: "%K == %@", #keyPath(HomeTimelineIndex.userID), userID) - } - - public static func predicate(domain: String, userID: MastodonUser.ID) -> NSPredicate { - return NSCompoundPredicate(andPredicateWithSubpredicates: [ - predicate(domain: domain), - predicate(userID: userID) - ]) - } - - public static func notDeleted() -> NSPredicate { - return NSPredicate(format: "%K == nil", #keyPath(HomeTimelineIndex.deletedAt)) - } - -} diff --git a/CoreDataStack/Entity/MastodonUser.swift b/CoreDataStack/Entity/MastodonUser.swift deleted file mode 100644 index 913aa1f16..000000000 --- a/CoreDataStack/Entity/MastodonUser.swift +++ /dev/null @@ -1,407 +0,0 @@ -// -// MastodonUser.swift -// CoreDataStack -// -// Created by MainasuK Cirno on 2021/1/27. -// - -import CoreData -import Foundation - -final public class MastodonUser: NSManagedObject { - - public typealias ID = String - - @NSManaged public private(set) var identifier: ID - @NSManaged public private(set) var domain: String - - @NSManaged public private(set) var id: ID - @NSManaged public private(set) var acct: String - @NSManaged public private(set) var username: String - @NSManaged public private(set) var displayName: String - @NSManaged public private(set) var avatar: String - @NSManaged public private(set) var avatarStatic: String? - @NSManaged public private(set) var header: String - @NSManaged public private(set) var headerStatic: String? - @NSManaged public private(set) var note: String? - @NSManaged public private(set) var url: String? - - @NSManaged public private(set) var emojisData: Data? - @NSManaged public private(set) var fieldsData: Data? - - @NSManaged public private(set) var statusesCount: NSNumber - @NSManaged public private(set) var followingCount: NSNumber - @NSManaged public private(set) var followersCount: NSNumber - - @NSManaged public private(set) var locked: Bool - @NSManaged public private(set) var bot: Bool - @NSManaged public private(set) var suspended: Bool - - @NSManaged public private(set) var createdAt: Date - @NSManaged public private(set) var updatedAt: Date - - // one-to-one relationship - @NSManaged public private(set) var pinnedStatus: Status? - @NSManaged public private(set) var mastodonAuthentication: MastodonAuthentication? - - // one-to-many relationship - @NSManaged public private(set) var statuses: Set<Status>? - @NSManaged public private(set) var notifications: Set<MastodonNotification>? - @NSManaged public private(set) var searchHistories: Set<SearchHistory> - - // many-to-many relationship - @NSManaged public private(set) var favourite: Set<Status>? - @NSManaged public private(set) var reblogged: Set<Status>? - @NSManaged public private(set) var muted: Set<Status>? - @NSManaged public private(set) var bookmarked: Set<Status>? - @NSManaged public private(set) var votePollOptions: Set<PollOption>? - @NSManaged public private(set) var votePolls: Set<Poll>? - // relationships - @NSManaged public private(set) var following: Set<MastodonUser>? - @NSManaged public private(set) var followingBy: Set<MastodonUser>? - @NSManaged public private(set) var followRequested: Set<MastodonUser>? - @NSManaged public private(set) var followRequestedBy: Set<MastodonUser>? - @NSManaged public private(set) var muting: Set<MastodonUser>? - @NSManaged public private(set) var mutingBy: Set<MastodonUser>? - @NSManaged public private(set) var blocking: Set<MastodonUser>? - @NSManaged public private(set) var blockingBy: Set<MastodonUser>? - @NSManaged public private(set) var endorsed: Set<MastodonUser>? - @NSManaged public private(set) var endorsedBy: Set<MastodonUser>? - @NSManaged public private(set) var domainBlocking: Set<MastodonUser>? - @NSManaged public private(set) var domainBlockingBy: Set<MastodonUser>? - -} - -extension MastodonUser { - - @discardableResult - public static func insert( - into context: NSManagedObjectContext, - property: Property - ) -> MastodonUser { - let user: MastodonUser = context.insertObject() - - user.identifier = property.identifier - user.domain = property.domain - - user.id = property.id - user.acct = property.acct - user.username = property.username - user.displayName = property.displayName - user.avatar = property.avatar - user.avatarStatic = property.avatarStatic - user.header = property.header - user.headerStatic = property.headerStatic - user.note = property.note - user.url = property.url - user.emojisData = property.emojisData - user.fieldsData = property.fieldsData - - user.statusesCount = NSNumber(value: property.statusesCount) - user.followingCount = NSNumber(value: property.followingCount) - user.followersCount = NSNumber(value: property.followersCount) - - user.locked = property.locked - user.bot = property.bot ?? false - user.suspended = property.suspended ?? false - - // Mastodon do not provide relationship on the `Account` - // Update relationship via attribute updating interface - - user.createdAt = property.createdAt - user.updatedAt = property.networkDate - - return user - } - - - public func update(acct: String) { - if self.acct != acct { - self.acct = acct - } - } - public func update(username: String) { - if self.username != username { - self.username = username - } - } - public func update(displayName: String) { - if self.displayName != displayName { - self.displayName = displayName - } - } - public func update(avatar: String) { - if self.avatar != avatar { - self.avatar = avatar - } - } - public func update(avatarStatic: String?) { - if self.avatarStatic != avatarStatic { - self.avatarStatic = avatarStatic - } - } - public func update(header: String) { - if self.header != header { - self.header = header - } - } - public func update(headerStatic: String?) { - if self.headerStatic != headerStatic { - self.headerStatic = headerStatic - } - } - public func update(note: String?) { - if self.note != note { - self.note = note - } - } - public func update(url: String?) { - if self.url != url { - self.url = url - } - } - public func update(emojisData: Data?) { - if self.emojisData != emojisData { - self.emojisData = emojisData - } - } - public func update(fieldsData: Data?) { - if self.fieldsData != fieldsData { - self.fieldsData = fieldsData - } - } - public func update(statusesCount: Int) { - if self.statusesCount.intValue != statusesCount { - self.statusesCount = NSNumber(value: statusesCount) - } - } - public func update(followingCount: Int) { - if self.followingCount.intValue != followingCount { - self.followingCount = NSNumber(value: followingCount) - } - } - public func update(followersCount: Int) { - if self.followersCount.intValue != followersCount { - self.followersCount = NSNumber(value: followersCount) - } - } - public func update(locked: Bool) { - if self.locked != locked { - self.locked = locked - } - } - public func update(bot: Bool) { - if self.bot != bot { - self.bot = bot - } - } - public func update(suspended: Bool) { - if self.suspended != suspended { - self.suspended = suspended - } - } - - public func update(isFollowing: Bool, by mastodonUser: MastodonUser) { - if isFollowing { - if !(self.followingBy ?? Set()).contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(MastodonUser.followingBy)).add(mastodonUser) - } - } else { - if (self.followingBy ?? Set()).contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(MastodonUser.followingBy)).remove(mastodonUser) - } - } - } - public func update(isFollowRequested: Bool, by mastodonUser: MastodonUser) { - if isFollowRequested { - if !(self.followRequestedBy ?? Set()).contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(MastodonUser.followRequestedBy)).add(mastodonUser) - } - } else { - if (self.followRequestedBy ?? Set()).contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(MastodonUser.followRequestedBy)).remove(mastodonUser) - } - } - } - public func update(isMuting: Bool, by mastodonUser: MastodonUser) { - if isMuting { - if !(self.mutingBy ?? Set()).contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(MastodonUser.mutingBy)).add(mastodonUser) - } - } else { - if (self.mutingBy ?? Set()).contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(MastodonUser.mutingBy)).remove(mastodonUser) - } - } - } - public func update(isBlocking: Bool, by mastodonUser: MastodonUser) { - if isBlocking { - if !(self.blockingBy ?? Set()).contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(MastodonUser.blockingBy)).add(mastodonUser) - } - } else { - if (self.blockingBy ?? Set()).contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(MastodonUser.blockingBy)).remove(mastodonUser) - } - } - } - public func update(isEndorsed: Bool, by mastodonUser: MastodonUser) { - if isEndorsed { - if !(self.endorsedBy ?? Set()).contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(MastodonUser.endorsedBy)).add(mastodonUser) - } - } else { - if (self.endorsedBy ?? Set()).contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(MastodonUser.endorsedBy)).remove(mastodonUser) - } - } - } - public func update(isDomainBlocking: Bool, by mastodonUser: MastodonUser) { - if isDomainBlocking { - if !(self.domainBlockingBy ?? Set()).contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(MastodonUser.domainBlockingBy)).add(mastodonUser) - } - } else { - if (self.domainBlockingBy ?? Set()).contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(MastodonUser.domainBlockingBy)).remove(mastodonUser) - } - } - } - - public func didUpdate(at networkDate: Date) { - self.updatedAt = networkDate - } - -} - -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 - public let domain: String - - public let id: String - public let acct: String - public let username: String - public let displayName: String - public let avatar: String - public let avatarStatic: String? - public let header: String - public let headerStatic: String? - public let note: String? - public let url: String? - public let emojisData: Data? - public let fieldsData: Data? - public let statusesCount: Int - public let followingCount: Int - public let followersCount: Int - public let locked: Bool - public let bot: Bool? - public let suspended: Bool? - - public let createdAt: Date - public let networkDate: Date - - public init( - id: String, - domain: String, - acct: String, - username: String, - displayName: String, - avatar: String, - avatarStatic: String?, - header: String, - headerStatic: String?, - note: String?, - url: String?, - emojisData: Data?, - fieldsData: Data?, - statusesCount: Int, - followingCount: Int, - followersCount: Int, - locked: Bool, - bot: Bool?, - suspended: Bool?, - createdAt: Date, - networkDate: Date - ) { - self.identifier = id + "@" + domain - self.domain = domain - self.id = id - self.acct = acct - self.username = username - self.displayName = displayName - self.avatar = avatar - self.avatarStatic = avatarStatic - self.header = header - self.headerStatic = headerStatic - self.note = note - self.url = url - self.emojisData = emojisData - self.fieldsData = fieldsData - self.statusesCount = statusesCount - self.followingCount = followingCount - self.followersCount = followersCount - self.locked = locked - self.bot = bot - self.suspended = suspended - self.createdAt = createdAt - self.networkDate = networkDate - } - } -} - -extension MastodonUser: Managed { - public static var defaultSortDescriptors: [NSSortDescriptor] { - return [NSSortDescriptor(keyPath: \MastodonUser.createdAt, ascending: false)] - } -} - -extension MastodonUser { - - static func predicate(domain: String) -> NSPredicate { - return NSPredicate(format: "%K == %@", #keyPath(MastodonUser.domain), domain) - } - - static func predicate(id: String) -> NSPredicate { - return NSPredicate(format: "%K == %@", #keyPath(MastodonUser.id), id) - } - - public static func predicate(domain: String, id: String) -> NSPredicate { - return NSCompoundPredicate(andPredicateWithSubpredicates: [ - MastodonUser.predicate(domain: domain), - MastodonUser.predicate(id: id) - ]) - } - - static func predicate(ids: [String]) -> NSPredicate { - return NSPredicate(format: "%K IN %@", #keyPath(MastodonUser.id), ids) - } - - public static func predicate(domain: String, ids: [String]) -> NSPredicate { - return NSCompoundPredicate(andPredicateWithSubpredicates: [ - MastodonUser.predicate(domain: domain), - MastodonUser.predicate(ids: ids) - ]) - } - - static func predicate(username: String) -> NSPredicate { - return NSPredicate(format: "%K == %@", #keyPath(MastodonUser.username), username) - } - - public static func predicate(domain: String, username: String) -> NSPredicate { - return NSCompoundPredicate(andPredicateWithSubpredicates: [ - MastodonUser.predicate(domain: domain), - MastodonUser.predicate(username: username) - ]) - } - -} diff --git a/CoreDataStack/Entity/Mention.swift b/CoreDataStack/Entity/Mention.swift deleted file mode 100644 index 864ca4948..000000000 --- a/CoreDataStack/Entity/Mention.swift +++ /dev/null @@ -1,71 +0,0 @@ -// -// Mention.swift -// CoreDataStack -// -// Created by sxiaojian on 2021/2/1. -// - -import CoreData -import Foundation - -public final class Mention: NSManagedObject { - public typealias ID = UUID - - @NSManaged public private(set) var index: NSNumber - - @NSManaged public private(set) var identifier: ID - @NSManaged public private(set) var id: String - @NSManaged public private(set) var createAt: Date - - @NSManaged public private(set) var username: String - @NSManaged public private(set) var acct: String - @NSManaged public private(set) var url: String - - // many-to-one relationship - @NSManaged public private(set) var status: Status -} - -public extension Mention { - override func awakeFromInsert() { - super.awakeFromInsert() - - setPrimitiveValue(UUID(), forKey: #keyPath(Mention.identifier)) - } - - @discardableResult - static func insert( - into context: NSManagedObjectContext, - property: Property, - index: Int - ) -> Mention { - let mention: Mention = context.insertObject() - mention.index = NSNumber(value: index) - mention.id = property.id - mention.username = property.username - mention.acct = property.acct - mention.url = property.url - return mention - } -} - -public extension Mention { - struct Property { - public let id: String - public let username: String - public let acct: String - public let url: String - - public init(id: String, username: String, acct: String, url: String) { - self.id = id - self.username = username - self.acct = acct - self.url = url - } - } -} - -extension Mention: Managed { - public static var defaultSortDescriptors: [NSSortDescriptor] { - return [NSSortDescriptor(keyPath: \Mention.createAt, ascending: false)] - } -} diff --git a/CoreDataStack/Entity/Notification.swift b/CoreDataStack/Entity/Notification.swift deleted file mode 100644 index 04f8e9fdf..000000000 --- a/CoreDataStack/Entity/Notification.swift +++ /dev/null @@ -1,115 +0,0 @@ -// -// MastodonNotification.swift -// CoreDataStack -// -// Created by sxiaojian on 2021/4/13. -// - -import Foundation -import CoreData - -public final class MastodonNotification: NSManagedObject { - public typealias ID = UUID - @NSManaged public private(set) var identifier: ID - @NSManaged public private(set) var id: String - @NSManaged public private(set) var createAt: Date - @NSManaged public private(set) var updatedAt: Date - @NSManaged public private(set) var typeRaw: String - @NSManaged public private(set) var account: MastodonUser - @NSManaged public private(set) var status: Status? - - @NSManaged public private(set) var domain: String - @NSManaged public private(set) var userID: String -} - -extension MastodonNotification { - public override func awakeFromInsert() { - super.awakeFromInsert() - setPrimitiveValue(UUID(), forKey: #keyPath(MastodonNotification.identifier)) - } -} - -public extension MastodonNotification { - @discardableResult - static func insert( - into context: NSManagedObjectContext, - domain: String, - userID: String, - networkDate: Date, - property: Property - ) -> MastodonNotification { - let notification: MastodonNotification = context.insertObject() - notification.id = property.id - notification.createAt = property.createdAt - notification.updatedAt = networkDate - notification.typeRaw = property.typeRaw - notification.account = property.account - notification.status = property.status - notification.domain = domain - notification.userID = userID - return notification - } -} - -public extension MastodonNotification { - struct Property { - public init(id: String, - typeRaw: String, - account: MastodonUser, - status: Status?, - createdAt: Date - ) { - self.id = id - self.typeRaw = typeRaw - self.account = account - self.status = status - self.createdAt = createdAt - } - - public let id: String - public let typeRaw: String - public let account: MastodonUser - public let status: Status? - public let createdAt: Date - } -} - -extension MastodonNotification { - static func predicate(domain: String) -> NSPredicate { - return NSPredicate(format: "%K == %@", #keyPath(MastodonNotification.domain), domain) - } - - static func predicate(userID: String) -> NSPredicate { - return NSPredicate(format: "%K == %@", #keyPath(MastodonNotification.userID), userID) - } - - static func predicate(typeRaw: String) -> NSPredicate { - return NSPredicate(format: "%K == %@", #keyPath(MastodonNotification.typeRaw), typeRaw) - } - - public static func predicate(domain: String, userID: String, typeRaw: String? = nil) -> NSPredicate { - if let typeRaw = typeRaw { - return NSCompoundPredicate(andPredicateWithSubpredicates: [ - MastodonNotification.predicate(domain: domain), - MastodonNotification.predicate(typeRaw: typeRaw), - MastodonNotification.predicate(userID: userID), - ]) - } else { - return NSCompoundPredicate(andPredicateWithSubpredicates: [ - MastodonNotification.predicate(domain: domain), - MastodonNotification.predicate(userID: userID) - ]) - } - } - - public static func predicate(validTypesRaws types: [String]) -> NSPredicate { - return NSPredicate(format: "%K IN %@", #keyPath(MastodonNotification.typeRaw), types) - } - -} - -extension MastodonNotification: Managed { - public static var defaultSortDescriptors: [NSSortDescriptor] { - return [NSSortDescriptor(keyPath: \MastodonNotification.createAt, ascending: false)] - } -} diff --git a/CoreDataStack/Entity/Poll.swift b/CoreDataStack/Entity/Poll.swift deleted file mode 100644 index 3ab48b444..000000000 --- a/CoreDataStack/Entity/Poll.swift +++ /dev/null @@ -1,145 +0,0 @@ -// -// Poll.swift -// CoreDataStack -// -// Created by MainasuK Cirno on 2021-3-2. -// - -import Foundation -import CoreData - -public final class Poll: NSManagedObject { - public typealias ID = String - - @NSManaged public private(set) var id: ID - @NSManaged public private(set) var expiresAt: Date? - @NSManaged public private(set) var expired: Bool - @NSManaged public private(set) var multiple: Bool - @NSManaged public private(set) var votesCount: NSNumber - @NSManaged public private(set) var votersCount: NSNumber? - - @NSManaged public private(set) var createdAt: Date - @NSManaged public private(set) var updatedAt: Date - - // one-to-one relationship - @NSManaged public private(set) var status: Status - - // one-to-many relationship - @NSManaged public private(set) var options: Set<PollOption> - - // many-to-many relationship - @NSManaged public private(set) var votedBy: Set<MastodonUser>? -} - -extension Poll { - - public override func awakeFromInsert() { - super.awakeFromInsert() - setPrimitiveValue(Date(), forKey: #keyPath(Poll.createdAt)) - } - - @discardableResult - public static func insert( - into context: NSManagedObjectContext, - property: Property, - votedBy: MastodonUser?, - options: [PollOption] - ) -> Poll { - let poll: Poll = context.insertObject() - - poll.id = property.id - poll.expiresAt = property.expiresAt - poll.expired = property.expired - poll.multiple = property.multiple - poll.votesCount = property.votesCount - poll.votersCount = property.votersCount - - - poll.updatedAt = property.networkDate - - if let votedBy = votedBy { - poll.mutableSetValue(forKey: #keyPath(Poll.votedBy)).add(votedBy) - } - poll.mutableSetValue(forKey: #keyPath(Poll.options)).addObjects(from: options) - - return poll - } - - public func update(expiresAt: Date?) { - if self.expiresAt != expiresAt { - self.expiresAt = expiresAt - } - } - - public func update(expired: Bool) { - if self.expired != expired { - self.expired = expired - } - } - - public func update(votesCount: Int) { - if self.votesCount.intValue != votesCount { - self.votesCount = NSNumber(value: votesCount) - } - } - - public func update(votersCount: Int?) { - if self.votersCount?.intValue != votersCount { - self.votersCount = votersCount.flatMap { NSNumber(value: $0) } - } - } - - public func update(voted: Bool, by: MastodonUser) { - if voted { - if !(votedBy ?? Set()).contains(by) { - mutableSetValue(forKey: #keyPath(Poll.votedBy)).add(by) - } - } else { - if (votedBy ?? Set()).contains(by) { - mutableSetValue(forKey: #keyPath(Poll.votedBy)).remove(by) - } - } - } - - public func didUpdate(at networkDate: Date) { - self.updatedAt = networkDate - } - -} - -extension Poll { - public struct Property { - public let id: ID - public let expiresAt: Date? - public let expired: Bool - public let multiple: Bool - public let votesCount: NSNumber - public let votersCount: NSNumber? - - public let networkDate: Date - - public init( - id: Poll.ID, - expiresAt: Date?, - expired: Bool, - multiple: Bool, - votesCount: Int, - votersCount: Int?, - networkDate: Date - ) { - self.id = id - self.expiresAt = expiresAt - self.expired = expired - self.multiple = multiple - self.votesCount = NSNumber(value: votesCount) - self.votersCount = votersCount.flatMap { NSNumber(value: $0) } - self.networkDate = networkDate - } - } -} - -extension Poll: Managed { - public static var defaultSortDescriptors: [NSSortDescriptor] { - return [NSSortDescriptor(keyPath: \Poll.createdAt, ascending: false)] - } -} diff --git a/CoreDataStack/Entity/PollOption.swift b/CoreDataStack/Entity/PollOption.swift deleted file mode 100644 index 8917a7533..000000000 --- a/CoreDataStack/Entity/PollOption.swift +++ /dev/null @@ -1,98 +0,0 @@ -// -// PollOption.swift -// CoreDataStack -// -// Created by MainasuK Cirno on 2021-3-2. -// - -import Foundation -import CoreData - -public final class PollOption: NSManagedObject { - @NSManaged public private(set) var index: NSNumber - @NSManaged public private(set) var title: String - @NSManaged public private(set) var votesCount: NSNumber? - - @NSManaged public private(set) var createdAt: Date - @NSManaged public private(set) var updatedAt: Date - - // many-to-one relationship - @NSManaged public private(set) var poll: Poll - - // many-to-many relationship - @NSManaged public private(set) var votedBy: Set<MastodonUser>? -} - -extension PollOption { - - public override func awakeFromInsert() { - super.awakeFromInsert() - setPrimitiveValue(Date(), forKey: #keyPath(PollOption.createdAt)) - } - - @discardableResult - public static func insert( - into context: NSManagedObjectContext, - property: Property, - votedBy: MastodonUser? - ) -> PollOption { - let option: PollOption = context.insertObject() - - option.index = property.index - option.title = property.title - option.votesCount = property.votesCount - option.updatedAt = property.networkDate - - if let votedBy = votedBy { - option.mutableSetValue(forKey: #keyPath(PollOption.votedBy)).add(votedBy) - } - - return option - } - - public func update(votesCount: Int?) { - if self.votesCount?.intValue != votesCount { - self.votesCount = votesCount.flatMap { NSNumber(value: $0) } - } - } - - public func update(voted: Bool, by: MastodonUser) { - if voted { - if !(self.votedBy ?? Set()).contains(by) { - self.mutableSetValue(forKey: #keyPath(PollOption.votedBy)).add(by) - } - } else { - if (self.votedBy ?? Set()).contains(by) { - self.mutableSetValue(forKey: #keyPath(PollOption.votedBy)).remove(by) - } - } - } - - public func didUpdate(at networkDate: Date) { - self.updatedAt = networkDate - } - -} - -extension PollOption { - public struct Property { - public let index: NSNumber - public let title: String - public let votesCount: NSNumber? - - public let networkDate: Date - - public init(index: Int, title: String, votesCount: Int?, networkDate: Date) { - self.index = NSNumber(value: index) - self.title = title - self.votesCount = votesCount.flatMap { NSNumber(value: $0) } - self.networkDate = networkDate - } - } -} - -extension PollOption: Managed { - public static var defaultSortDescriptors: [NSSortDescriptor] { - return [NSSortDescriptor(keyPath: \PollOption.createdAt, ascending: false)] - } -} diff --git a/CoreDataStack/Entity/SearchHistory.swift b/CoreDataStack/Entity/SearchHistory.swift deleted file mode 100644 index 05e441906..000000000 --- a/CoreDataStack/Entity/SearchHistory.swift +++ /dev/null @@ -1,118 +0,0 @@ -// -// SearchHistory.swift -// CoreDataStack -// -// Created by sxiaojian on 2021/4/7. -// - -import Foundation -import CoreData - -public final class SearchHistory: NSManagedObject { - public typealias ID = UUID - @NSManaged public private(set) var identifier: ID - @NSManaged public private(set) var domain: String - @NSManaged public private(set) var userID: MastodonUser.ID - @NSManaged public private(set) var createAt: Date - @NSManaged public private(set) var updatedAt: Date - - // 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? - -} - -extension SearchHistory { - public override func awakeFromInsert() { - super.awakeFromInsert() - setPrimitiveValue(UUID(), forKey: #keyPath(SearchHistory.identifier)) - setPrimitiveValue(Date(), forKey: #keyPath(SearchHistory.createAt)) - setPrimitiveValue(Date(), forKey: #keyPath(SearchHistory.updatedAt)) - } - -// public override func willSave() { -// super.willSave() -// setPrimitiveValue(Date(), forKey: #keyPath(SearchHistory.updatedAt)) -// } - - @discardableResult - public static func insert( - into context: NSManagedObjectContext, - property: Property, - account: MastodonUser - ) -> SearchHistory { - let searchHistory: SearchHistory = context.insertObject() - searchHistory.domain = property.domain - searchHistory.userID = property.userID - searchHistory.account = account - return searchHistory - } - - @discardableResult - public static func insert( - into context: NSManagedObjectContext, - property: Property, - hashtag: Tag - ) -> SearchHistory { - let searchHistory: SearchHistory = context.insertObject() - searchHistory.domain = property.domain - searchHistory.userID = property.userID - searchHistory.hashtag = hashtag - return searchHistory - } - - @discardableResult - public static func insert( - into context: NSManagedObjectContext, - property: Property, - status: Status - ) -> SearchHistory { - let searchHistory: SearchHistory = context.insertObject() - searchHistory.domain = property.domain - searchHistory.userID = property.userID - searchHistory.status = status - return searchHistory - } -} - -extension SearchHistory { - public func update(updatedAt: Date) { - setValue(updatedAt, forKey: #keyPath(SearchHistory.updatedAt)) - } -} - -extension SearchHistory { - public struct Property { - public let domain: String - public let userID: MastodonUser.ID - - public init(domain: String, userID: MastodonUser.ID) { - self.domain = domain - self.userID = userID - } - } -} - -extension SearchHistory: Managed { - public static var defaultSortDescriptors: [NSSortDescriptor] { - return [NSSortDescriptor(keyPath: \SearchHistory.updatedAt, ascending: false)] - } -} - -extension SearchHistory { - static func predicate(domain: String) -> NSPredicate { - return NSPredicate(format: "%K == %@", #keyPath(SearchHistory.domain), domain) - } - - static func predicate(userID: String) -> NSPredicate { - return NSPredicate(format: "%K == %@", #keyPath(SearchHistory.userID), userID) - } - - public static func predicate(domain: String, userID: String) -> NSPredicate { - return NSCompoundPredicate(andPredicateWithSubpredicates: [ - predicate(domain: domain), - predicate(userID: userID) - ]) - } -} diff --git a/CoreDataStack/Entity/Status.swift b/CoreDataStack/Entity/Status.swift deleted file mode 100644 index ee168e418..000000000 --- a/CoreDataStack/Entity/Status.swift +++ /dev/null @@ -1,355 +0,0 @@ -// -// Status.swift -// CoreDataStack -// -// Created by MainasuK Cirno on 2021/1/27. -// - -import CoreData -import Foundation - -public final class Status: NSManagedObject { - public typealias ID = String - - @NSManaged public private(set) var identifier: ID - @NSManaged public private(set) var domain: String - - @NSManaged public private(set) var id: String - @NSManaged public private(set) var uri: String - @NSManaged public private(set) var createdAt: Date - @NSManaged public private(set) var content: String - - @NSManaged public private(set) var visibility: String? - @NSManaged public private(set) var sensitive: Bool - @NSManaged public private(set) var spoilerText: String? - @NSManaged public private(set) var application: Application? - - @NSManaged public private(set) var emojisData: Data? - - // Informational - @NSManaged public private(set) var reblogsCount: NSNumber - @NSManaged public private(set) var favouritesCount: NSNumber - @NSManaged public private(set) var repliesCount: NSNumber? - - @NSManaged public private(set) var url: String? - @NSManaged public private(set) var inReplyToID: Status.ID? - @NSManaged public private(set) var inReplyToAccountID: MastodonUser.ID? - - @NSManaged public private(set) var language: String? // (ISO 639 Part 1 two-letter language code) - @NSManaged public private(set) var text: String? - - // many-to-one relationship - @NSManaged public private(set) var author: MastodonUser - @NSManaged public private(set) var reblog: Status? - @NSManaged public private(set) var replyTo: Status? - - // many-to-many relationship - @NSManaged public private(set) var favouritedBy: Set<MastodonUser>? - @NSManaged public private(set) var rebloggedBy: Set<MastodonUser>? - @NSManaged public private(set) var mutedBy: Set<MastodonUser>? - @NSManaged public private(set) var bookmarkedBy: Set<MastodonUser>? - - // one-to-one relationship - @NSManaged public private(set) var pinnedBy: MastodonUser? - @NSManaged public private(set) var poll: Poll? - - // one-to-many relationship - @NSManaged public private(set) var reblogFrom: Set<Status>? - @NSManaged public private(set) var mentions: Set<Mention>? - @NSManaged public private(set) var homeTimelineIndexes: Set<HomeTimelineIndex>? - @NSManaged public private(set) var mediaAttachments: Set<Attachment>? - @NSManaged public private(set) var replyFrom: Set<Status>? - - @NSManaged public private(set) var inNotifications: Set<MastodonNotification>? - - @NSManaged public private(set) var searchHistories: Set<SearchHistory> - - @NSManaged public private(set) var updatedAt: Date - @NSManaged public private(set) var deletedAt: Date? - @NSManaged public private(set) var revealedAt: Date? -} - -extension Status { - - @discardableResult - public static func insert( - into context: NSManagedObjectContext, - property: Property, - author: MastodonUser, - reblog: Status?, - application: Application?, - replyTo: Status?, - poll: Poll?, - mentions: [Mention]?, - mediaAttachments: [Attachment]?, - favouritedBy: MastodonUser?, - rebloggedBy: MastodonUser?, - mutedBy: MastodonUser?, - bookmarkedBy: MastodonUser?, - pinnedBy: MastodonUser? - ) -> Status { - let status: Status = context.insertObject() - - status.identifier = property.identifier - status.domain = property.domain - - status.id = property.id - status.uri = property.uri - status.createdAt = property.createdAt - status.content = property.content - - status.visibility = property.visibility - status.sensitive = property.sensitive - status.spoilerText = property.spoilerText - status.application = application - - status.emojisData = property.emojisData - - status.reblogsCount = property.reblogsCount - status.favouritesCount = property.favouritesCount - status.repliesCount = property.repliesCount - - status.url = property.url - status.inReplyToID = property.inReplyToID - status.inReplyToAccountID = property.inReplyToAccountID - - status.language = property.language - status.text = property.text - - status.author = author - status.reblog = reblog - - status.pinnedBy = pinnedBy - status.poll = poll - - if let mentions = mentions { - status.mutableSetValue(forKey: #keyPath(Status.mentions)).addObjects(from: mentions) - } - if let mediaAttachments = mediaAttachments { - status.mutableSetValue(forKey: #keyPath(Status.mediaAttachments)).addObjects(from: mediaAttachments) - } - if let favouritedBy = favouritedBy { - status.mutableSetValue(forKey: #keyPath(Status.favouritedBy)).add(favouritedBy) - } - if let rebloggedBy = rebloggedBy { - status.mutableSetValue(forKey: #keyPath(Status.rebloggedBy)).add(rebloggedBy) - } - if let mutedBy = mutedBy { - status.mutableSetValue(forKey: #keyPath(Status.mutedBy)).add(mutedBy) - } - if let bookmarkedBy = bookmarkedBy { - status.mutableSetValue(forKey: #keyPath(Status.bookmarkedBy)).add(bookmarkedBy) - } - - status.updatedAt = property.networkDate - - return status - } - - public func update(emojisData: Data?) { - if self.emojisData != emojisData { - self.emojisData = emojisData - } - } - - public func update(reblogsCount: NSNumber) { - if self.reblogsCount.intValue != reblogsCount.intValue { - self.reblogsCount = reblogsCount - } - } - - public func update(favouritesCount: NSNumber) { - if self.favouritesCount.intValue != favouritesCount.intValue { - self.favouritesCount = favouritesCount - } - } - - public func update(repliesCount: NSNumber?) { - guard let count = repliesCount else { - return - } - if self.repliesCount?.intValue != count.intValue { - self.repliesCount = repliesCount - } - } - - public func update(replyTo: Status?) { - if self.replyTo != replyTo { - self.replyTo = replyTo - } - } - - public func update(liked: Bool, by mastodonUser: MastodonUser) { - if liked { - if !(self.favouritedBy ?? Set()).contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(Status.favouritedBy)).add(mastodonUser) - } - } else { - if (self.favouritedBy ?? Set()).contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(Status.favouritedBy)).remove(mastodonUser) - } - } - } - - public func update(reblogged: Bool, by mastodonUser: MastodonUser) { - if reblogged { - if !(self.rebloggedBy ?? Set()).contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(Status.rebloggedBy)).add(mastodonUser) - } - } else { - if (self.rebloggedBy ?? Set()).contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(Status.rebloggedBy)).remove(mastodonUser) - } - } - } - - public func update(muted: Bool, by mastodonUser: MastodonUser) { - if muted { - if !(self.mutedBy ?? Set()).contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(Status.mutedBy)).add(mastodonUser) - } - } else { - if (self.mutedBy ?? Set()).contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(Status.mutedBy)).remove(mastodonUser) - } - } - } - - public func update(bookmarked: Bool, by mastodonUser: MastodonUser) { - if bookmarked { - if !(self.bookmarkedBy ?? Set()).contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(Status.bookmarkedBy)).add(mastodonUser) - } - } else { - if (self.bookmarkedBy ?? Set()).contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(Status.bookmarkedBy)).remove(mastodonUser) - } - } - } - - public func update(isReveal: Bool) { - revealedAt = isReveal ? Date() : nil - } - - public func didUpdate(at networkDate: Date) { - self.updatedAt = networkDate - } - -} - -extension Status { - public struct Property { - - public let identifier: ID - public let domain: String - - public let id: String - public let uri: String - public let createdAt: Date - public let content: String - - public let visibility: String? - public let sensitive: Bool - public let spoilerText: String? - - public let emojisData: Data? - - public let reblogsCount: NSNumber - public let favouritesCount: NSNumber - public let repliesCount: NSNumber? - - public let url: String? - public let inReplyToID: Status.ID? - public let inReplyToAccountID: MastodonUser.ID? - public let language: String? // (ISO 639 Part @1 two-letter language code) - public let text: String? - - public let networkDate: Date - - public init( - domain: String, - id: String, - uri: String, - createdAt: Date, - content: String, - visibility: String?, - sensitive: Bool, - spoilerText: String?, - emojisData: Data?, - reblogsCount: NSNumber, - favouritesCount: NSNumber, - repliesCount: NSNumber?, - url: String?, - inReplyToID: Status.ID?, - inReplyToAccountID: MastodonUser.ID?, - language: String?, - text: String?, - networkDate: Date - ) { - self.identifier = id + "@" + domain - self.domain = domain - self.id = id - self.uri = uri - self.createdAt = createdAt - self.content = content - self.visibility = visibility - self.sensitive = sensitive - self.spoilerText = spoilerText - self.emojisData = emojisData - self.reblogsCount = reblogsCount - self.favouritesCount = favouritesCount - self.repliesCount = repliesCount - self.url = url - self.inReplyToID = inReplyToID - self.inReplyToAccountID = inReplyToAccountID - self.language = language - self.text = text - self.networkDate = networkDate - } - - } -} - -extension Status: Managed { - public static var defaultSortDescriptors: [NSSortDescriptor] { - return [NSSortDescriptor(keyPath: \Status.createdAt, ascending: false)] - } -} - -extension Status { - - static func predicate(domain: String) -> NSPredicate { - return NSPredicate(format: "%K == %@", #keyPath(Status.domain), domain) - } - - static func predicate(id: String) -> NSPredicate { - return NSPredicate(format: "%K == %@", #keyPath(Status.id), id) - } - - public static func predicate(domain: String, id: String) -> NSPredicate { - return NSCompoundPredicate(andPredicateWithSubpredicates: [ - predicate(domain: domain), - predicate(id: id) - ]) - } - - static func predicate(ids: [String]) -> NSPredicate { - return NSPredicate(format: "%K IN %@", #keyPath(Status.id), ids) - } - - public static func predicate(domain: String, ids: [String]) -> NSPredicate { - return NSCompoundPredicate(andPredicateWithSubpredicates: [ - predicate(domain: domain), - predicate(ids: ids) - ]) - } - - public static func notDeleted() -> NSPredicate { - return NSPredicate(format: "%K == nil", #keyPath(Status.deletedAt)) - } - - public static func deleted() -> NSPredicate { - return NSPredicate(format: "%K != nil", #keyPath(Status.deletedAt)) - } - -} diff --git a/CoreDataStack/Entity/Tag.swift b/CoreDataStack/Entity/Tag.swift deleted file mode 100644 index fa9e098de..000000000 --- a/CoreDataStack/Entity/Tag.swift +++ /dev/null @@ -1,112 +0,0 @@ -// -// Tag.swift -// CoreDataStack -// -// Created by sxiaojian on 2021/2/1. -// - -import CoreData -import Foundation - -public final class Tag: NSManagedObject { - public typealias ID = UUID - @NSManaged public private(set) var identifier: ID - @NSManaged public private(set) var createAt: Date - @NSManaged public private(set) var updatedAt: Date - - @NSManaged public private(set) var name: String - @NSManaged public private(set) var url: String - - // one-to-one relationship - - // many-to-many relationship - - // one-to-many relationship - @NSManaged public private(set) var histories: Set<History>? - @NSManaged public private(set) var searchHistories: Set<SearchHistory> -} - -public extension Tag { - override func awakeFromInsert() { - super.awakeFromInsert() - setPrimitiveValue(UUID(), forKey: #keyPath(Tag.identifier)) - setPrimitiveValue(Date(), forKey: #keyPath(Tag.createAt)) - setPrimitiveValue(Date(), forKey: #keyPath(Tag.updatedAt)) - } - - override func willSave() { - super.willSave() - setPrimitiveValue(Date(), forKey: #keyPath(Tag.updatedAt)) - } - - @discardableResult - static func insert( - into context: NSManagedObjectContext, - property: Property - ) -> Tag { - let tag: Tag = context.insertObject() - tag.name = property.name - tag.url = property.url - if let histories = property.histories { - tag.mutableSetValue(forKey: #keyPath(Tag.histories)).addObjects(from: histories) - } - return 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 - public let url: String - public let histories: [History]? - - public init(name: String, url: String, histories: [History]?) { - self.name = name - self.url = url - self.histories = histories - } - } -} - -public extension Tag { - func updateHistory(index: Int, day: Date, uses: String, account: String) { - guard let histories = self.histories?.sorted(by: { - $0.createAt.compare($1.createAt) == .orderedAscending - }) else { return } - let history = histories[index] - history.update(day: day) - history.update(uses: uses) - history.update(accounts: account) - } - - func appendHistory(history: History) { - self.mutableSetValue(forKeyPath: #keyPath(Tag.histories)).add(history) - } - - func update(url: String) { - if self.url != url { - self.url = url - } - } -} - -extension Tag: Managed { - public static var defaultSortDescriptors: [NSSortDescriptor] { - [NSSortDescriptor(keyPath: \Tag.createAt, ascending: false)] - } -} - -public extension Tag { - static func predicate(name: String) -> NSPredicate { - NSPredicate(format: "%K == %@", #keyPath(Tag.name), name) - } -} diff --git a/CoreDataStack/Extension/NSManagedObjectContext.swift b/CoreDataStack/Extension/NSManagedObjectContext.swift deleted file mode 100644 index e3f6600c7..000000000 --- a/CoreDataStack/Extension/NSManagedObjectContext.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// NSManagedObjectContext.swift -// CoreDataStack -// -// Created by Cirno MainasuK on 2020-8-10. -// - -import os -import Foundation -import Combine -import CoreData - -extension NSManagedObjectContext { - public func insert<T: NSManagedObject>() -> T where T: Managed { - guard let object = NSEntityDescription.insertNewObject(forEntityName: T.entityName, into: self) as? T else { - fatalError("cannot insert object: \(T.self)") - } - - return object - } - - public func saveOrRollback() throws { - do { - guard hasChanges else { - return - } - try save() - } catch { - rollback() - - os_log("%{public}s[%{public}ld], %{public}s: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) - throw error - } - } - - public func performChanges(block: @escaping () -> Void) -> Future<Result<Void, Error>, Never> { - Future { promise in - self.perform { - block() - do { - try self.saveOrRollback() - promise(.success(Result.success(()))) - } catch { - promise(.success(Result.failure(error))) - } - } - } - } -} diff --git a/CoreDataStack/Info.plist b/CoreDataStack/Info.plist deleted file mode 100644 index 9fe845c60..000000000 --- a/CoreDataStack/Info.plist +++ /dev/null @@ -1,22 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> -<plist version="1.0"> -<dict> - <key>CFBundleDevelopmentRegion</key> - <string>$(DEVELOPMENT_LANGUAGE)</string> - <key>CFBundleExecutable</key> - <string>$(EXECUTABLE_NAME)</string> - <key>CFBundleIdentifier</key> - <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> - <key>CFBundleInfoDictionaryVersion</key> - <string>6.0</string> - <key>CFBundleName</key> - <string>$(PRODUCT_NAME)</string> - <key>CFBundlePackageType</key> - <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> - <key>CFBundleShortVersionString</key> - <string>1.2.0</string> - <key>CFBundleVersion</key> - <string>88</string> -</dict> -</plist> diff --git a/CoreDataStackTests/CoreDataStackTests.swift b/CoreDataStackTests/CoreDataStackTests.swift deleted file mode 100644 index 7248e3b9a..000000000 --- a/CoreDataStackTests/CoreDataStackTests.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// CoreDataStackTests.swift -// CoreDataStackTests -// -// Created by MainasuK Cirno on 2021/1/27. -// - -import XCTest -@testable import CoreDataStack - -class CoreDataStackTests: XCTestCase { - - override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - } - - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. - } - - func testExample() throws { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct results. - } - - func testPerformanceExample() throws { - // This is an example of a performance test case. - self.measure { - // Put the code you want to measure the time of here. - } - } - -} diff --git a/CoreDataStackTests/Info.plist b/CoreDataStackTests/Info.plist deleted file mode 100644 index 9fe845c60..000000000 --- a/CoreDataStackTests/Info.plist +++ /dev/null @@ -1,22 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> -<plist version="1.0"> -<dict> - <key>CFBundleDevelopmentRegion</key> - <string>$(DEVELOPMENT_LANGUAGE)</string> - <key>CFBundleExecutable</key> - <string>$(EXECUTABLE_NAME)</string> - <key>CFBundleIdentifier</key> - <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> - <key>CFBundleInfoDictionaryVersion</key> - <string>6.0</string> - <key>CFBundleName</key> - <string>$(PRODUCT_NAME)</string> - <key>CFBundlePackageType</key> - <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> - <key>CFBundleShortVersionString</key> - <string>1.2.0</string> - <key>CFBundleVersion</key> - <string>88</string> -</dict> -</plist> diff --git a/Documentation/CONTRIBUTING.md b/Documentation/CONTRIBUTING.md new file mode 100644 index 000000000..cc018445d --- /dev/null +++ b/Documentation/CONTRIBUTING.md @@ -0,0 +1,30 @@ +# Contributing + +- File the issue for bug report and feature request +- Translate the project in our [Crowdin](https://crowdin.com/project/mastodon-for-ios) project +- Make the Pull Request to contribute + +## Bug Report +File the issue about the bug. Make sure you are installing the latest version app from TestFlight or App Store. + +## Translation +[![Crowdin](https://badges.crowdin.net/mastodon-for-ios/localized.svg)](https://crowdin.com/project/mastodon-for-ios) + +The translation will update regularly. Please request language if not listed via issue. + +## Pull Request + +You can make a pull request directly with small block code changes for bugfix or feature implementations. Before making a pull request with hundred lines of changes to this repository, please first discuss the change you wish to make via issue. + +Also, there are lots of existing feature request issues that could be a good-first-issue discussing place. + +Follow the git-flow pattern to make your pull request. + +1. Ensure you are checkout on the `develop` branch. +2. Write your codes and test them on **iPad and iPhone**. +3. Merge the `develop` into your branch then make a Pull Request. Please merge the branch and resolve any conflicts when the `develop` updates. **Do not force push your codes.** +4. Make sure the permission for your folk is open to the reviewer. Code style fix, conflict resolution, and other changes may be committed by the reviewer directly. +5. Request a code review and wait for approval. The PR will be merged when it is approved. + +## Documentation +The documents for this app is list under the [Documentation](../Documentation/) folder. We are also welcome contributions for documentation. \ No newline at end of file diff --git a/Documentation/Setup.md b/Documentation/Setup.md new file mode 100644 index 000000000..ede9d4862 --- /dev/null +++ b/Documentation/Setup.md @@ -0,0 +1,83 @@ +# Setup + +## Requirements + +- Xcode 13+ +- Swift 5.5+ +- iOS 14.0+ + + +Intell the latest version of Xcode from the App Store or Apple Developer Download website. Also, we assert you have the [Homebrew](https://brew.sh) package manager. + +This guide may not suit your machine and actually setup procedure may change in the future. Please file the issue or Pull Request if there are any problems. + +## CocoaPods +The app use [CocoaPods]() and [CocoaPods-Keys](https://github.com/orta/cocoapods-keys). The M1 Mac needs virtual ruby env to workaround compatibility issues. + +#### Intel Mac + +```zsh +sudo gem install cocoapods cocoapods-keys +``` + +#### M1 Mac + +```zsh +# install the rbenv +brew install rbenv +which ruby +# > /usr/bin/ruby +echo 'eval "$(rbenv init -)"' >> ~/.zprofile +source ~/.zprofile +which ruby +# > /Users/mainasuk/.rbenv/shims/ruby + +# select ruby +rbenv install --list +# here we use the latest 3.0.x version +rbenv install 3.0.3 +rbenv global 3.0.3 +ruby --version +# > ruby 3.0.3p157 (2021-11-24 revision 3fb7d2cadc) [arm64-darwin21] + +sudo gem install cocoapods cocoapods-keys +``` + +## Bootstrap + +```zsh +# make a clean build +sudo gem install cocoapods-clean +pod clean + +# make install +pod install --repo-update + +# open workspace +open Mastodon.xcworkspace +``` + +The CocoaPods-Key plugin will request the push notification endpoint. You can fufill the empty string and set it later. To setup the push notification. Please check section `Push Notification` below. + +The app requires the `App Group` capability. To make sure it works for your developer membership. Please check [AppSecret.swift](../AppShared/AppSecret.swift) file and set another unique `groupID` and update `App Group` settings. + +#### Push Notification (Optional) +The app is compatible with [toot-relay](https://github.com/DagAgren/toot-relay) APNs. You can set your push notification endpoint via Cocoapod-Keys. There are two endpoints: +- notification_endpoint: for `RELEASE` usage +- notification_endpoint_debug: for `DEBUG` usage + +Please check the [Establishing a Certificate-Based Connection to APNs +](https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/establishing_a_certificate-based_connection_to_apns) document to generate the certificate and exports the p12 file. + +Note: +Please check and set the `notification.Topic` to the app BundleID in [toot-relay.go](https://github.com/DagAgren/toot-relay/blob/f9d6894040509881fee845972cd38ec6cd8f5a11/toot-relay.go#L112). The server needs use a reverse proxy to port this relay on 443 port with valid domain and HTTPS certificate. + +## Start +1. Open `Mastodon.xcworkspace` +2. Wait for the Swift Package Dependencies resolved. +2. Check the signing settings make sure to choose a team. [More info…](https://help.apple.com/xcode/mac/current/#/dev23aab79b4) +3. Select `Mastodon` scheme and device then run it. (Command + R) + +## What's next + +We welcome contributions! And if you have an interest to contribute codes. Here is a document that describes the app architecture and what's tech stack it uses. \ No newline at end of file diff --git a/Documentation/Snapshot.md b/Documentation/Snapshot.md new file mode 100644 index 000000000..7140f7a0b --- /dev/null +++ b/Documentation/Snapshot.md @@ -0,0 +1,137 @@ +# Mastodon App Store Snapshot Guide +This documentation is a guide to create snapshots for App Store. The outer contributor could ignore this. + +## Prepare toolkit +The app use the Xcode UITest generate snapshots attachments. Then use the `xcparse` tool extract the snapshots. + +```zsh +# install xcparse from Homebrew +brew install chargepoint/xcparse/xcparse +``` +## How it works +We use `xcodebuild` CLI tool to trigger UITest. + +Set the `name` in `-destination` option to add device for snapshot. For example: +`-destination 'platform=iOS Simulator,name=iPad Pro (12.9-inch) (5th generation)' \` + +You can list the avaiable simulator: +```zsh +# list the destinations +xcodebuild \ + test \ + -showdestinations \ + -derivedDataPath '~/Downloads/MastodonBuild/Derived' \ + -workspace Mastodon.xcworkspace \ + -scheme 'Mastodon - Snapshot' + +# output +Available destinations for the "Mastodon - Snapshot" scheme: + { platform:iOS Simulator, id:7F6D7727-AD49-4B79-B6F5-AEC538925576, OS:15.2, name:iPad (9th generation) } + { platform:iOS Simulator, id:BEB9533C-F786-40E6-8C38-248F6A11FC37, OS:15.2, name:iPad Air (4th generation) } + … +``` + +#### Note: +Multiple lines for destination will dispatches the parallel snapshot jobs. + + +## Login before make snapshots +This script trigger the `MastodonUITests/MastodonUISnapshotTests/testSignInAccount` test case to sign-in the account. The test case may wait for 2FA code or email code. Please input it if needed. Also, you can skip this and sign-in the test account manually. + +Replace the `<Email>` and `<Password>` for test account. +```zsh +# build and run test case for auto sign-in +TEST_RUNNER_login_domain='<Domain>' \ + TEST_RUNNER_login_email='<Email>' \ + TEST_RUNNER_login_password='<Password>' \ + xcodebuild \ + test \ + -derivedDataPath '~/Downloads/MastodonBuild/Derived' \ + -workspace Mastodon.xcworkspace \ + -scheme 'Mastodon - Snapshot' \ + -sdk iphonesimulator \ + -destination 'platform=iOS Simulator,name=iPhone 13 Pro Max' \ + -destination 'platform=iOS Simulator,name=iPhone 8 Plus' \ + -destination 'platform=iOS Simulator,name=iPad Pro (12.9-inch) (5th generation)' \ + -testPlan 'AppStoreSnapshotTestPlan' \ + -only-testing:MastodonUITests/MastodonUISnapshotTests/testSignInAccount +``` + +Note: +UITest may running silent. Open the Simulator.app to make the device display. + +## Take and extract snapshots + +### 1. Setup status bar +```zsh +# boot devices +xcrun simctl boot 'iPhone 8 Plus' +xcrun simctl boot 'iPhone 13 Pro Max' +xcrun simctl boot 'iPad Pro (12.9-inch) (5th generation)' + +# setup magic status bar +xcrun simctl status_bar 'iPhone 13 Pro Max' override --time "9:41" --batteryState charged --batteryLevel 100 +xcrun simctl status_bar 'iPhone 8 Plus' override --time "9:41" --batteryState charged --batteryLevel 100 +xcrun simctl status_bar 'iPad Pro (12.9-inch) (5th generation)' override --time "9:41" --batteryState charged --batteryLevel 100 +``` + +### 2. Take snapshots +The `TEST_RUNNER_` prefix will sets env value into test runner. + +```zsh +# take snapshots +TEST_RUNNER_login_domain='<domain.com>' \ + TEST_RUNNER_login_email='<email>' \ + TEST_RUNNER_login_password='<email>' \ + TEST_RUNNER_thread_id='<thread_id>' \ + TEST_RUNNER_profile_id='<profile_id>' \ + xcodebuild \ + test \ + -derivedDataPath '~/Downloads/MastodonBuild/Derived' \ + -workspace Mastodon.xcworkspace \ + -scheme 'Mastodon - Snapshot' \ + -sdk iphonesimulator \ + -destination 'platform=iOS Simulator,name=iPhone 13 Pro Max' \ + -destination 'platform=iOS Simulator,name=iPhone 8 Plus' \ + -destination 'platform=iOS Simulator,name=iPad Pro (12.9-inch) (5th generation)' \ + -test-iterations 3 \ + -retry-tests-on-failure \ + -testPlan 'AppStoreSnapshotTestPlan' + +# output: +Test session results, code coverage, and logs: + /Users/Me/Downloads/MastodonBuild/Derived/Logs/Test/Test-Mastodon - Snapshot-2022.03.03_18-00-38-+0800.xcresult + +** TEST SUCCEEDED ** +``` + +#### Note: +Add `-only-testing:MastodonUITests/MastodonUISnapshotTests/testSnapshot…` to run specific test case. + +| Task | key | value | +| ------------------- | -------------- | ----------------------------------------------------- | +| testSignInAccount | login_domain | The server domain for user login | +| testSignInAccount | login_email | The user email for login | +| testSignInAccount | login_password | The user password for login | +| testSnapshotThread | thread_id | The ID for post which used for thread scene snapshot | +| testSnapshotProfile | profile_id | The ID for user which used for profile scene snapshot | + +### 3. Extract snapshots +Use `xcparse screenshots <path_for_xcresult> <path_for_destination>` extracts snapshots. + +```zsh +# scresult path for previous test case +xcparse screenshots '<path_for_xcresult>' ~/Downloads/MastodonBuild/Screenshots/ + +# output +100% [============] +🎊 Export complete! 🎊 + +# group +cd ~/Downloads/MastodonBuild/Screenshots/ +mkdir 'iPhone 8 Plus' 'iPhone 13 Pro Max' 'iPad Pro (12.9-inch) (5th generation)' +find . -name "*iPad*" -type file -print0 | xargs -0 -I {} mv {} './iPad Pro (12.9-inch) (5th generation)' +find . -name "*iPhone 8*" -type file -print0 | xargs -0 -I {} mv {} './iPhone 8 Plus' +find . -name "*iPhone 13*" -type file -print0 | xargs -0 -I {} mv {} './iPhone 13 Pro Max' + +``` diff --git a/Localization/Localizable.stringsdict b/Localization/Localizable.stringsdict index ce358b439..4b9a12762 100644 --- a/Localization/Localizable.stringsdict +++ b/Localization/Localizable.stringsdict @@ -156,6 +156,28 @@ <string>%ld reblogs</string> </dict> </dict> + <key>plural.count.reply</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@reply_count@</string> + <key>reply_count</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>zero</key> + <string>0 replies</string> + <key>one</key> + <string>1 reply</string> + <key>few</key> + <string>%ld replies</string> + <key>many</key> + <string>%ld replies</string> + <key>other</key> + <string>%ld replies</string> + </dict> + </dict> <key>plural.count.vote</key> <dict> <key>NSStringLocalizedFormatKey</key> diff --git a/Localization/StringsConvertor/Intents/input/eu_ES/Intents.strings b/Localization/StringsConvertor/Intents/input/eu_ES/Intents.strings new file mode 100644 index 000000000..dbc27c1cf --- /dev/null +++ b/Localization/StringsConvertor/Intents/input/eu_ES/Intents.strings @@ -0,0 +1,51 @@ +"16wxgf" = "Argitaratu Mastodonen"; + +"751xkl" = "Testu-edukia"; + +"CsR7G2" = "Argitaratu Mastodonen"; + +"HZSGTr" = "Ze eduki argitaratu?"; + +"HdGikU" = "Argitaratzeak huts egin du"; + +"KDNTJ4" = "Hutsegitearen arrazoia"; + +"RHxKOw" = "Argitaratu bidalketa testu-edukiarekin"; + +"RxSqsb" = "Bidali"; + +"WCIR3D" = "Argitaratu ${content} Mastodonen"; + +"ZKJSNu" = "Bidali"; + +"ZS1XaK" = "${content}"; + +"ZbSjzC" = "Ikusgaitasuna"; + +"Zo4jgJ" = "Bidalketaren ikusgaitasuna"; + +"apSxMG-dYQ5NN" = "'Publikoa'-rekin bat datozen ${count} aukera daude."; + +"apSxMG-ehFLjY" = "'Jarraitzaileak soilik'-ekin bat datozen ${count} aukera daude."; + +"ayoYEb-dYQ5NN" = "${content}, publikoa"; + +"ayoYEb-ehFLjY" = "${content}, jarraitzaileak besterik ez"; + +"dUyuGg" = "Argitaratu Mastodonen"; + +"dYQ5NN" = "Publikoa"; + +"ehFLjY" = "Jarraitzaileak soilik"; + +"gfePDu" = "Argitaratzeak huts egin du. ${failureReason}"; + +"k7dbKQ" = "Bidalketa behar bezala bidali da."; + +"oGiqmY-dYQ5NN" = "Berresteagatik, 'Publikoa' izatea nahi duzu?"; + +"oGiqmY-ehFLjY" = "Berresteagatik, 'Jarraitzaileak soilik' izatea nahi duzu?"; + +"rM6dvp" = "URLa"; + +"ryJLwG" = "Bidalketa behar bezala bidali da. "; diff --git a/Localization/StringsConvertor/Intents/input/eu_ES/Intents.stringsdict b/Localization/StringsConvertor/Intents/input/eu_ES/Intents.stringsdict new file mode 100644 index 000000000..9246c3475 --- /dev/null +++ b/Localization/StringsConvertor/Intents/input/eu_ES/Intents.stringsdict @@ -0,0 +1,38 @@ +<?xml version="1.0" encoding="utf-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> + <dict> + <key>There are ${count} options matching ‘${content}’. - 2</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>${content}(e)kin bat datozen %#@count_option@ daude.</string> + <key>count_option</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>%ld</string> + <key>one</key> + <string>Aukera 1</string> + <key>other</key> + <string>%ld aukera</string> + </dict> + </dict> + <key>There are ${count} options matching ‘${visibility}’.</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>${visibility}(e)kin bat datozen %#@count_option@ daude.</string> + <key>count_option</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>%ld</string> + <key>one</key> + <string>Aukera 1</string> + <key>other</key> + <string>%ld aukera</string> + </dict> + </dict> + </dict> +</plist> diff --git a/Localization/StringsConvertor/Intents/input/fr_FR/Intents.strings b/Localization/StringsConvertor/Intents/input/fr_FR/Intents.strings index f4fec3000..2703edd42 100644 --- a/Localization/StringsConvertor/Intents/input/fr_FR/Intents.strings +++ b/Localization/StringsConvertor/Intents/input/fr_FR/Intents.strings @@ -12,7 +12,7 @@ "RHxKOw" = "Envoyer une publication avec du contenu texte"; -"RxSqsb" = "Post"; +"RxSqsb" = "Publication"; "WCIR3D" = "Publier du ${content} sur Mastodon"; @@ -24,9 +24,9 @@ "Zo4jgJ" = "Visibilité de la publication"; -"apSxMG-dYQ5NN" = "There are ${count} options matching ‘Public’."; +"apSxMG-dYQ5NN" = "Il y a ${count} options correspondant à « Public »."; -"apSxMG-ehFLjY" = "There are ${count} options matching ‘Followers Only’."; +"apSxMG-ehFLjY" = "Il y a ${count} options correspondant à « Abonnés uniquement »."; "ayoYEb-dYQ5NN" = "${content}, Public"; diff --git a/Localization/StringsConvertor/Intents/input/it_IT/Intents.strings b/Localization/StringsConvertor/Intents/input/it_IT/Intents.strings new file mode 100644 index 000000000..6877490ba --- /dev/null +++ b/Localization/StringsConvertor/Intents/input/it_IT/Intents.strings @@ -0,0 +1,51 @@ +"16wxgf" = "Post on Mastodon"; + +"751xkl" = "Text Content"; + +"CsR7G2" = "Post on Mastodon"; + +"HZSGTr" = "What content to post?"; + +"HdGikU" = "Posting failed"; + +"KDNTJ4" = "Failure Reason"; + +"RHxKOw" = "Send Post with text content"; + +"RxSqsb" = "Post"; + +"WCIR3D" = "Post ${content} on Mastodon"; + +"ZKJSNu" = "Post"; + +"ZS1XaK" = "${content}"; + +"ZbSjzC" = "Visibility"; + +"Zo4jgJ" = "Post Visibility"; + +"apSxMG-dYQ5NN" = "There are ${count} options matching ‘Public’."; + +"apSxMG-ehFLjY" = "There are ${count} options matching ‘Followers Only’."; + +"ayoYEb-dYQ5NN" = "${content}, Public"; + +"ayoYEb-ehFLjY" = "${content}, Followers Only"; + +"dUyuGg" = "Post on Mastodon"; + +"dYQ5NN" = "Public"; + +"ehFLjY" = "Followers Only"; + +"gfePDu" = "Posting failed. ${failureReason}"; + +"k7dbKQ" = "Post was sent successfully."; + +"oGiqmY-dYQ5NN" = "Just to confirm, you wanted ‘Public’?"; + +"oGiqmY-ehFLjY" = "Just to confirm, you wanted ‘Followers Only’?"; + +"rM6dvp" = "URL"; + +"ryJLwG" = "Post was sent successfully. "; diff --git a/Localization/StringsConvertor/Intents/input/it_IT/Intents.stringsdict b/Localization/StringsConvertor/Intents/input/it_IT/Intents.stringsdict new file mode 100644 index 000000000..18422c772 --- /dev/null +++ b/Localization/StringsConvertor/Intents/input/it_IT/Intents.stringsdict @@ -0,0 +1,38 @@ +<?xml version="1.0" encoding="utf-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> + <dict> + <key>There are ${count} options matching ‘${content}’. - 2</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>There are %#@count_option@ matching ‘${content}’.</string> + <key>count_option</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>%ld</string> + <key>one</key> + <string>1 option</string> + <key>other</key> + <string>%ld options</string> + </dict> + </dict> + <key>There are ${count} options matching ‘${visibility}’.</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>There are %#@count_option@ matching ‘${visibility}’.</string> + <key>count_option</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>%ld</string> + <key>one</key> + <string>1 option</string> + <key>other</key> + <string>%ld options</string> + </dict> + </dict> + </dict> +</plist> diff --git a/Localization/StringsConvertor/Intents/input/ja_JP/Intents.strings b/Localization/StringsConvertor/Intents/input/ja_JP/Intents.strings index 6877490ba..411b35c2e 100644 --- a/Localization/StringsConvertor/Intents/input/ja_JP/Intents.strings +++ b/Localization/StringsConvertor/Intents/input/ja_JP/Intents.strings @@ -1,51 +1,51 @@ -"16wxgf" = "Post on Mastodon"; +"16wxgf" = "Mastodonに投稿"; -"751xkl" = "Text Content"; +"751xkl" = "テキストコンテンツ"; -"CsR7G2" = "Post on Mastodon"; +"CsR7G2" = "Mastodonに投稿"; "HZSGTr" = "What content to post?"; -"HdGikU" = "Posting failed"; +"HdGikU" = "投稿に失敗しました"; -"KDNTJ4" = "Failure Reason"; +"KDNTJ4" = "失敗の理由"; "RHxKOw" = "Send Post with text content"; -"RxSqsb" = "Post"; +"RxSqsb" = "投稿"; -"WCIR3D" = "Post ${content} on Mastodon"; +"WCIR3D" = "Mastodonに ${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" = "Mastodonに投稿"; -"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"; -"ryJLwG" = "Post was sent successfully. "; +"ryJLwG" = "投稿に成功しました。 "; diff --git a/Localization/StringsConvertor/Intents/input/kab_KAB/Intents.strings b/Localization/StringsConvertor/Intents/input/kab_KAB/Intents.strings new file mode 100644 index 000000000..532c822f6 --- /dev/null +++ b/Localization/StringsConvertor/Intents/input/kab_KAB/Intents.strings @@ -0,0 +1,51 @@ +"16wxgf" = "Asuffeɣ deg Matodon"; + +"751xkl" = "Agbur n uḍris"; + +"CsR7G2" = "Asuffeɣ deg Matodon"; + +"HZSGTr" = "Anwa agbur ara d-yettwasuffɣen?"; + +"HdGikU" = "Yecceḍ usuffeɣ"; + +"KDNTJ4" = "Ssebba n tuccḍa"; + +"RHxKOw" = "Azen tasuffeɣt s ugbur n uḍris"; + +"RxSqsb" = "Tasuffeɣt"; + +"WCIR3D" = "Suffeɣ ${content} deg Mastodon"; + +"ZKJSNu" = "Tasuffeɣt"; + +"ZS1XaK" = "${content}"; + +"ZbSjzC" = "Abani"; + +"Zo4jgJ" = "Abani n tsuffeɣt"; + +"apSxMG-dYQ5NN" = "Yella ${count} n textiṛiyin yemṣadan d 'Uzayaz'."; + +"apSxMG-ehFLjY" = "Yella ${count} n textiṛiyin yemṣadan d 'Yineḍfaren kan'."; + +"ayoYEb-dYQ5NN" = "${content}, azayaz"; + +"ayoYEb-ehFLjY" = "${content}, ineḍfaren kan"; + +"dUyuGg" = "Asuffeɣ deg Maṣṭudun"; + +"dYQ5NN" = "Azayez"; + +"ehFLjY" = "Imeḍfaṛen kan"; + +"gfePDu" = "Asuffeɣ yecceḍ. ${failureReason}"; + +"k7dbKQ" = "Tasuffeɣt tettwazen akken iwata."; + +"oGiqmY-dYQ5NN" = "I usentem kan, tebɣiḍ 'Azayaz'?"; + +"oGiqmY-ehFLjY" = "I usentem kan, tebɣiḍ 'Ineḍfaren kan'?"; + +"rM6dvp" = "URL"; + +"ryJLwG" = "Tasuffeɣt tettwazen akken iwata. "; diff --git a/Localization/StringsConvertor/Intents/input/kab_KAB/Intents.stringsdict b/Localization/StringsConvertor/Intents/input/kab_KAB/Intents.stringsdict new file mode 100644 index 000000000..a8aeeaaf1 --- /dev/null +++ b/Localization/StringsConvertor/Intents/input/kab_KAB/Intents.stringsdict @@ -0,0 +1,38 @@ +<?xml version="1.0" encoding="utf-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> + <dict> + <key>There are ${count} options matching ‘${content}’. - 2</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>Llan %#@count_option@ i yemṣaḍan d '${content}'.</string> + <key>count_option</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>%ld</string> + <key>one</key> + <string>1 textiṛt</string> + <key>other</key> + <string>%ld textiṛiyin</string> + </dict> + </dict> + <key>There are ${count} options matching ‘${visibility}’.</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>Llan %#@count_option@ i yemṣaḍa, d '${visibility}'.</string> + <key>count_option</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>%ld</string> + <key>one</key> + <string>1 uɣewwaṛ</string> + <key>other</key> + <string>%ld iɣewwaṛen</string> + </dict> + </dict> + </dict> +</plist> diff --git a/Localization/StringsConvertor/Intents/input/sv_FI/Intents.strings b/Localization/StringsConvertor/Intents/input/sv_FI/Intents.strings index d4531ed64..1be213d45 100644 --- a/Localization/StringsConvertor/Intents/input/sv_FI/Intents.strings +++ b/Localization/StringsConvertor/Intents/input/sv_FI/Intents.strings @@ -1,51 +1,51 @@ -"16wxgf" = "Post on Mastodon"; +"16wxgf" = "Julkaise Mastodonissa"; -"751xkl" = "Text Content"; +"751xkl" = "Tekstisisältö"; -"CsR7G2" = "Post on Mastodon"; +"CsR7G2" = "Julkaise Mastodonissa"; -"HZSGTr" = "What content to post?"; +"HZSGTr" = "Mitä sisältöä julkaista?"; -"HdGikU" = "Posting failed"; +"HdGikU" = "Julkaiseminen epäonnistui"; -"KDNTJ4" = "Failure Reason"; +"KDNTJ4" = "Epäonnistumisen syy"; -"RHxKOw" = "Send Post with text content"; +"RHxKOw" = "Lähetä julkaisu teksisisällöllä"; -"RxSqsb" = "Post"; +"RxSqsb" = "Julkaisu"; -"WCIR3D" = "Posta ${content} på Mastodon"; +"WCIR3D" = "Julkaise ${content} Mastodonissa"; -"ZKJSNu" = "Post"; +"ZKJSNu" = "Julkaisu"; "ZS1XaK" = "${content}"; -"ZbSjzC" = "Visibility"; +"ZbSjzC" = "Näkyvyys"; -"Zo4jgJ" = "Post Visibility"; +"Zo4jgJ" = "Julkaisun näkyvyys"; -"apSxMG-dYQ5NN" = "There are ${count} options matching ‘Public’."; +"apSxMG-dYQ5NN" = "On ${count} vaihtoehtoa, jotka vastaavat ‘Julkinen’."; -"apSxMG-ehFLjY" = "There are ${count} options matching ‘Followers Only’."; +"apSxMG-ehFLjY" = "On ${count} vaihtoehtoa, jotka vastaavat ‘Vain seuraajat’."; -"ayoYEb-dYQ5NN" = "${content}, Public"; +"ayoYEb-dYQ5NN" = "${content}, julkinen"; -"ayoYEb-ehFLjY" = "${content}, Followers Only"; +"ayoYEb-ehFLjY" = "${content}, vain seuraajat"; -"dUyuGg" = "Post on Mastodon"; +"dUyuGg" = "Julkaise Mastodonissa"; -"dYQ5NN" = "Public"; +"dYQ5NN" = "Julkinen"; -"ehFLjY" = "Followers Only"; +"ehFLjY" = "Vain seuraajat"; -"gfePDu" = "Posting failed. ${failureReason}"; +"gfePDu" = "Julkaiseminen epäonnistui. ${failureReason}"; -"k7dbKQ" = "Post was sent successfully."; +"k7dbKQ" = "Julkaisu lähetettiin onnistuneesti."; -"oGiqmY-dYQ5NN" = "Just to confirm, you wanted ‘Public’?"; +"oGiqmY-dYQ5NN" = "Vahvitukseksi, halusit ‘Julkinen’?"; -"oGiqmY-ehFLjY" = "Just to confirm, you wanted ‘Followers Only’?"; +"oGiqmY-ehFLjY" = "Vahvitstukseksi, halusit ‘Vain seuraajat’?"; "rM6dvp" = "URL"; -"ryJLwG" = "Post was sent successfully. "; +"ryJLwG" = "Julkaisu lähetettiin onnistuneesti. "; diff --git a/Localization/StringsConvertor/Intents/input/sv_FI/Intents.stringsdict b/Localization/StringsConvertor/Intents/input/sv_FI/Intents.stringsdict index 18422c772..7825b778e 100644 --- a/Localization/StringsConvertor/Intents/input/sv_FI/Intents.stringsdict +++ b/Localization/StringsConvertor/Intents/input/sv_FI/Intents.stringsdict @@ -5,7 +5,7 @@ <key>There are ${count} options matching ‘${content}’. - 2</key> <dict> <key>NSStringLocalizedFormatKey</key> - <string>There are %#@count_option@ matching ‘${content}’.</string> + <string>On %#@count_option@, joka/jotka vastaavat sisältöön ‘${content}’.</string> <key>count_option</key> <dict> <key>NSStringFormatSpecTypeKey</key> @@ -13,15 +13,15 @@ <key>NSStringFormatValueTypeKey</key> <string>%ld</string> <key>one</key> - <string>1 option</string> + <string>1 vaihtoehto</string> <key>other</key> - <string>%ld options</string> + <string>%ld vaihtoehtoa</string> </dict> </dict> <key>There are ${count} options matching ‘${visibility}’.</key> <dict> <key>NSStringLocalizedFormatKey</key> - <string>There are %#@count_option@ matching ‘${visibility}’.</string> + <string>On vaihtoehtoa %#@count_option@, joka/jotka vastaavat näkyvyyteen ‘${visibility}’.</string> <key>count_option</key> <dict> <key>NSStringFormatSpecTypeKey</key> @@ -29,9 +29,9 @@ <key>NSStringFormatValueTypeKey</key> <string>%ld</string> <key>one</key> - <string>1 option</string> + <string>1 vaihtoehto</string> <key>other</key> - <string>%ld options</string> + <string>%ld vaihtoehtoa</string> </dict> </dict> </dict> diff --git a/Localization/StringsConvertor/Intents/input/sv_SE/Intents.strings b/Localization/StringsConvertor/Intents/input/sv_SE/Intents.strings index d4531ed64..e81116eee 100644 --- a/Localization/StringsConvertor/Intents/input/sv_SE/Intents.strings +++ b/Localization/StringsConvertor/Intents/input/sv_SE/Intents.strings @@ -34,9 +34,9 @@ "dUyuGg" = "Post on Mastodon"; -"dYQ5NN" = "Public"; +"dYQ5NN" = "Publikt"; -"ehFLjY" = "Followers Only"; +"ehFLjY" = "Endast följare"; "gfePDu" = "Posting failed. ${failureReason}"; diff --git a/Localization/StringsConvertor/Sources/StringsConvertor/main.swift b/Localization/StringsConvertor/Sources/StringsConvertor/main.swift index 6507986be..14266a45e 100644 --- a/Localization/StringsConvertor/Sources/StringsConvertor/main.swift +++ b/Localization/StringsConvertor/Sources/StringsConvertor/main.swift @@ -1,11 +1,6 @@ import os.log import Foundation -let currentFileURL = URL(fileURLWithPath: "\(#file)", isDirectory: false) -let packageRootURL = currentFileURL.deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() -let inputDirectoryURL = packageRootURL.appendingPathComponent("input", isDirectory: true) -let outputDirectoryURL = packageRootURL.appendingPathComponent("output", isDirectory: true) - // conver i18n JSON templates to strings files private func convert(from inputDirectoryURL: URL, to outputDirectory: URL) { do { @@ -17,7 +12,6 @@ private func convert(from inputDirectoryURL: URL, to outputDirectory: URL) { for inputLanguageDirectoryURL in inputLanguageDirectoryURLs { let language = inputLanguageDirectoryURL.lastPathComponent guard let mappedLanguage = map(language: language) else { continue } - let outputDirectoryURL = outputDirectory.appendingPathComponent(mappedLanguage + ".lproj", isDirectory: true) os_log("%{public}s[%{public}ld], %{public}s: process %s -> %s", ((#file as NSString).lastPathComponent), #line, #function, language, mappedLanguage) let fileURLs = try FileManager.default.contentsOfDirectory( @@ -29,9 +23,19 @@ private func convert(from inputDirectoryURL: URL, to outputDirectory: URL) { os_log("%{public}s[%{public}ld], %{public}s: process %s", ((#file as NSString).lastPathComponent), #line, #function, jsonURL.debugDescription) let filename = jsonURL.deletingPathExtension().lastPathComponent guard let (mappedFilename, keyStyle) = map(filename: filename) else { continue } - let outputFileURL = outputDirectoryURL.appendingPathComponent(mappedFilename).appendingPathExtension("strings") + guard let bundle = bundle(filename: filename) else { continue } + + let outputDirectoryURL = outputDirectory + .appendingPathComponent(bundle, isDirectory: true) + .appendingPathComponent(mappedLanguage + ".lproj", isDirectory: true) + + let outputFileURL = outputDirectoryURL + .appendingPathComponent(mappedFilename) + .appendingPathExtension("strings") + let strings = try process(url: jsonURL, keyStyle: keyStyle) try? FileManager.default.createDirectory(at: outputDirectoryURL, withIntermediateDirectories: true, attributes: nil) + try strings.write(to: outputFileURL, atomically: true, encoding: .utf8) } } @@ -44,6 +48,7 @@ private func convert(from inputDirectoryURL: URL, to outputDirectory: URL) { private func map(language: String) -> String? { switch language { case "ar_SA": return "ar" // Arabic (Saudi Arabia) + case "eu_ES": return "eu-ES" // Basque case "ca_ES": return "ca" // Catalan case "zh_CN": return "zh-Hans" // Chinese Simplified case "nl_NL": return "nl" // Dutch @@ -51,11 +56,12 @@ 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 "kmr_TR": return "ku" // Kurmanji (Kurdish) case "ru_RU": return "ru" // Russian case "gd_GB": return "gd-GB" // Scottish Gaelic case "es_ES": return "es" // Spanish case "es_AR": return "es-419" // Spanish, Argentina + case "sv_FI": return "sv_FI" // Swedish, Finland case "th_TH": return "th" // Thai default: return nil } @@ -69,6 +75,14 @@ private func map(filename: String) -> (filename: String, keyStyle: Parser.KeySty } } +private func bundle(filename: String) -> String? { + switch filename { + case "app": return "module" + case "ios-infoPlist": return "main" + default: return nil + } +} + private func process(url: URL, keyStyle: Parser.KeyStyle) throws -> String { do { let data = try Data(contentsOf: url) @@ -115,9 +129,16 @@ private func move(from inputDirectoryURL: URL, to outputDirectoryURL: URL, pathE } } -// i18n from "input" to "output" + +let currentFileURL = URL(fileURLWithPath: "\(#file)", isDirectory: false) +let packageRootURL = currentFileURL.deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + +let inputDirectoryURL = packageRootURL.appendingPathComponent("input", isDirectory: true) +let outputDirectoryURL = packageRootURL.appendingPathComponent("output", isDirectory: true) convert(from: inputDirectoryURL, to: outputDirectoryURL) -move(from: inputDirectoryURL, to: outputDirectoryURL, pathExtension: "stringsdict") + +let moduleDirectoryURL = outputDirectoryURL.appendingPathComponent("module", isDirectory: true) +move(from: inputDirectoryURL, to: moduleDirectoryURL, pathExtension: "stringsdict") // i18n from "Intents/input" to "Intents/output" let intentsDirectoryURL = packageRootURL.appendingPathComponent("Intents", isDirectory: true) diff --git a/Localization/StringsConvertor/input/ar_SA/app.json b/Localization/StringsConvertor/input/ar_SA/app.json index 71e0f538f..51b334720 100644 --- a/Localization/StringsConvertor/input/ar_SA/app.json +++ b/Localization/StringsConvertor/input/ar_SA/app.json @@ -2,8 +2,8 @@ "common": { "alerts": { "common": { - "please_try_again": "يُرجى المحاولة مرة أُخرى.", - "please_try_again_later": "يُرجى المحاولة مرة أُخرى لاحقاً." + "please_try_again": "يُرجى المُحاولة مرة أُخرى.", + "please_try_again_later": "يُرجى المُحاولة مرة أُخرى لاحقًا." }, "sign_up_failure": { "title": "إخفاق في التسجيل" @@ -28,17 +28,17 @@ } }, "edit_profile_failure": { - "title": "خطأ في تَحرير الملف الشخصي", - "message": "لا يمكن تعديل الملف الشخصي. يُرجى المحاولة مرة أُخرى." + "title": "خطأ في تَحرير الملف التعريفي", + "message": "يتعذَّر تعديل الملف التعريفي. يُرجى المُحاولة مرة أُخرى." }, "sign_out": { "title": "تسجيل الخروج", - "message": "هل أنت متأكد من رغبتك في تسجيل الخروج؟", + "message": "هل أنت متأكد من رغبتك في تسجيل الخُروج؟", "confirm": "تسجيل الخروج" }, "block_domain": { - "title": "هل أنتَ مُتأكِّدٌ حقًا مِن رغبتك في حظر %s بالكامل؟ في معظم الحالات، يكون مِنَ الكافي والمُفَضَّل استهداف عدد محدود للحظر أو الكتم. لن ترى محتوى من هذا النطاق وسوف يتم إزالة جميع متابعيك المتواجدين فيه.", - "block_entire_domain": "حظر النِطاق" + "title": "هل أنتَ مُتأكِّدٌ حقًا مِن رغبتك في حظر %s بالكامل؟ في معظم الحالات، يكون مِنَ الكافي والمُفَضَّل استهداف عدد محدود للحظر أو الكتم. لن ترى محتوى من هذا النطاق وسوف يُزال جميع متابعيك المتواجدين فيه.", + "block_entire_domain": "حظر النِّطاق" }, "save_photo_failure": { "title": "إخفاق في حفظ الصورة", @@ -46,11 +46,11 @@ }, "delete_post": { "title": "هل أنت متأكد من رغبتك في حذف هذا المنشور؟", - "delete": "احذف" + "message": "هَل أنتَ مُتأكِدٌ مِن رَغبتِكَ فِي حَذفِ هَذَا المَنشُور؟" }, "clean_cache": { "title": "مَحو ذاكرة التخزين المؤقت", - "message": "تمَّ مَحو ذاكرة التخزين المؤقت %s بنجاح." + "message": "تمَّ مَحو %s مِن ذاكرة التخزين المؤقت بنجاح." } }, "controls": { @@ -58,9 +58,9 @@ "back": "العودة", "next": "التالي", "previous": "السابق", - "open": "افتح", + "open": "فتح", "add": "إضافة", - "remove": "احذف", + "remove": "حذف", "edit": "تحرير", "save": "حفظ", "ok": "حسنًا", @@ -69,7 +69,7 @@ "continue": "واصل", "compose": "تأليف", "cancel": "إلغاء", - "discard": "تجاهل", + "discard": "تجاهُل", "try_again": "المُحاولة مرة أُخرى", "take_photo": "التقاط صورة", "save_photo": "حفظ الصورة", @@ -81,20 +81,21 @@ "share": "المُشارك", "share_user": "مُشاركة %s", "share_post": "مشارك المنشور", - "open_in_safari": "الفتح في Safari", - "find_people": "ابحث عن أشخاص لمتابعتهم", - "manually_search": "البحث يدوياً بدلاً من ذلك", + "open_in_safari": "الفَتحُ في Safari", + "open_in_browser": "الفَتحُ في المُتَصَفِّح", + "find_people": "ابحث عن أشخاص لِمُتابعتهم", + "manually_search": "البحث يدويًا بدلًا من ذلك", "skip": "تخطي", - "reply": "الرَد", - "report_user": "ابلغ عن %s", + "reply": "الرَّد", + "report_user": "الإبلاغ عن %s", "block_domain": "حظر %s", - "unblock_domain": "إلغاء حظر %s", + "unblock_domain": "رفع الحظر عن %s", "settings": "الإعدادات", - "delete": "احذف" + "delete": "حذف" }, "tabs": { - "home": "الخيط الرئيسي", - "search": "بحث", + "home": "الرَّئِيسَة", + "search": "البَحث", "notification": "الإشعارات", "profile": "الملف التعريفي" }, @@ -102,17 +103,17 @@ "common": { "switch_to_tab": "التبديل إلى %s", "compose_new_post": "تأليف منشور جديد", - "show_favorites": "إظهار المفضلة", - "open_settings": "أفتح الإعدادات" + "show_favorites": "إظهار المُفضَّلة", + "open_settings": "فَتحُ الإعدادات" }, "timeline": { "previous_status": "المنشور السابق", "next_status": "المنشور التالي", - "open_status": "افتح المنشور", - "open_author_profile": "افتح الملف التعريفي للمؤلف", - "open_reblogger_profile": "افتح الملف التعريفي لمشارِك المنشور", - "reply_status": "رد على المنشور", - "toggle_reblog": "تبديل إعادة تدوين منشور", + "open_status": "فتح المنشور", + "open_author_profile": "فتح الملف التعريفي للمؤلف", + "open_reblogger_profile": "فتح الملف التعريفي لمُعيد تدوين المنشور", + "reply_status": "الرَّد على مَنشور", + "toggle_reblog": "تبديل إعادة تدوين مَنشور", "toggle_favorite": "تبديل المفضلة لِمنشور", "toggle_content_warning": "تبديل تحذير المُحتَوى", "preview_image": "معاينة الصورة" @@ -124,131 +125,148 @@ }, "status": { "user_reblogged": "أعادَ %s تدوينها", - "user_replied_to": "رد على %s", - "show_post": "اظهر المنشور", - "show_user_profile": "اظهر الملف التعريفي للمستخدم", - "content_warning": "تحذير عن المحتوى", - "media_content_warning": "انقر على أي مكان للكشف", + "user_replied_to": "رَدًا على %s", + "show_post": "إظهار منشور", + "show_user_profile": "إظهار الملف التعريفي للمُستخدِم", + "content_warning": "تحذير المُحتوى", + "media_content_warning": "انقر للكشف", "poll": { "vote": "صَوِّت", "closed": "انتهى" }, "actions": { - "reply": "رد", + "reply": "الرَّد", "reblog": "إعادة النشر", - "unreblog": "تراجع عن إعادة النشر", - "favorite": "إضافة إلى المفضلة", - "unfavorite": "إزالة من المفضلة", - "menu": "القائمة" + "unreblog": "التراجُع عن إعادة النشر", + "favorite": "التفضيل", + "unfavorite": "إزالة التفضيل", + "menu": "القائمة", + "hide": "إخفاء" }, "tag": { "url": "عنوان URL", - "mention": "أشر إلى", - "link": "الرابط", - "hashtag": "الوسم", - "email": "البريد الإلكتروني", - "emoji": "إيموجي" + "mention": "إشارة", + "link": "رابط", + "hashtag": "وسم", + "email": "بريد إلكتروني", + "emoji": "رمز تعبيري" + }, + "visibility": { + "unlisted": "يُمكِنُ لِلجَميعِ رُؤيَةُ هَذَا المَنشورِ وَلكِنَّهُ لَا يُعرَضُ فِي الخَطِّ الزَمنيّ العام.", + "private": "فَقَطْ مُتابِعينَهُم مَن يُمكِنُهُم رُؤيَةُ هَذَا المَنشُور.", + "private_from_me": "فَقَطْ مُتابِعيني أنَا مَن يُمكِنُهُم رُؤيَةُ هَذَا المَنشُور.", + "direct": "المُستخدمِونَ المُشارِ إليهم فَقَطْ مَن يُمكِنُهُم رُؤيَةُ هَذَا المَنشُور." } }, "friendship": { - "follow": "اتبع", + "follow": "مُتابَعَة", "following": "مُتابَع", "request": "إرسال طَلَب", "pending": "قيد المُراجعة", "block": "حظر", "block_user": "حظر %s", "block_domain": "حظر %s", - "unblock": "إلغاء الحَظر", - "unblock_user": "إلغاء حظر %s", + "unblock": "رفع الحَظر", + "unblock_user": "رفع الحَظر عن %s", "blocked": "محظور", - "mute": "أكتم", - "mute_user": "أكتم %s", - "unmute": "إلغاء الكتم", - "unmute_user": "إلغاء كتم %s", + "mute": "كَتم", + "mute_user": "كَتم %s", + "unmute": "رفع الكتم", + "unmute_user": "رفع الكتم عن %s", "muted": "مكتوم", "edit_info": "تعديل المعلومات" }, "timeline": { "filtered": "مُصفَّى", "timestamp": { - "now": "الأن" + "now": "الآن" }, "loader": { - "load_missing_posts": "تحميل المنشورات المَفقودة", - "loading_missing_posts": "تحميل المزيد من المنشورات...", - "show_more_replies": "إظهار المزيد من الردود" + "load_missing_posts": "تحميل المَنشورات المَفقودَة", + "loading_missing_posts": "يَجري تحميل المَنشورات المَفقودَة...", + "show_more_replies": "إظهار مَزيد مِنَ الرُّدود" }, "header": { - "no_status_found": "لا توجد هناك منشورات", - "blocking_warning": "لا يُمكنك الاطلاع على الملف الشخصي لهذا المُستخدِم\nحتَّى تَرفعَ الحَظر عنه.\nملفًّكَ الشخصي يَظهَرُ بِمثل هذِهِ الحالة بالنسبةِ لَهُ أيضًا.", - "user_blocking_warning": "لا يُمكنك الاطلاع على ملف %s الشخصي\nحتَّى تَرفعَ الحَظر عنه.\nملفًّكَ الشخصي يَظهَرُ بِمثل هذِهِ الحالة بالنسبةِ لَهُ أيضًا.", - "blocked_warning": "لا يُمكِنُكَ عَرض الملف الشخصي لهذا المُستخدِم\nحتَّى يَرفَعَ الحَظر عَنك.", - "user_blocked_warning": "لا يُمكِنُكَ عَرض ملف %s الشخصي\nحتَّى يَرفَعَ الحَظر عَنك.", + "no_status_found": "لَم يُعْثَر على مَنشورات", + "blocking_warning": "لا يُمكِنُكَ الاِطلاع على الملف التَعريفي لهذا المُستخدِم\nحتَّى تَرفعَ الحَظر عنه.\nملفُّكَ التَعريفي يَظهَرُ بِمثل هذِهِ الحالة بالنسبةِ لَهُ أيضًا.", + "user_blocking_warning": "لا يُمكنك الاطلاع على ملف %s التَعريفي\nحتَّى تَرفعَ الحَظر عنه.\nملفُّكَ التَعريفي يَظهَرُ بِمثل هذِهِ الحالة بالنسبةِ لَهُ أيضًا.", + "blocked_warning": "لا يُمكِنُكَ عَرض الملف التَعريفي لهذا المُستخدِم\nحتَّى يَرفَعَ الحَظرَ عَنك.", + "user_blocked_warning": "لا يُمكِنُكَ عَرض ملف %s التَعريفي\nحتَّى يَرفَعَ الحَظر عَنك.", "suspended_warning": "تمَّ إيقاف هذا المُستخدِم.", - "user_suspended_warning": "لقد أوقِفَ حِساب %s." + "user_suspended_warning": "لقد أُوقِفَ حِساب %s." } } } }, "scene": { "welcome": { - "slogan": "شبكات التواصل الاجتماعي\nمرة أُخرى بين يديك." + "slogan": "شبكات التواصل الاجتماعي\nمرة أُخرى بين يديك.", + "get_started": "ابدأ الآن", + "log_in": "تسجيلُ الدخول" }, "server_picker": { - "title": "اِختر خادِم،\nأي خادِم.", + "title": "اِختر خادِم،\nأيًّا مِنهُم.", + "subtitle": "اختر مجتمعًا بناءً على اهتماماتك، منطقتك أو يمكنك حتى اختيارُ مجتمعٍ ذي غرضٍ عام.", + "subtitle_extend": "اختر مجتمعًا بناءً على اهتماماتك، منطقتك أو يمكنك حتى اختيارُ مجتمعٍ ذي غرضٍ عام. تُشغَّل جميعُ المجتمعِ مِن قِبَلِ مُنظمَةٍ أو فردٍ مُستقلٍ تمامًا.", "button": { "category": { "all": "الكل", "all_accessiblity_description": "الفئة: الكل", "academia": "أكاديمي", - "activism": "للنشطاء", + "activism": "النشطاء", "food": "الطعام", - "furry": "فروي", + "furry": "مكسو بالفرو", "games": "ألعاب", "general": "عام", "journalism": "صحافة", "lgbt": "مجتمع الشواذ", - "regional": "اقليمي", - "art": "فن", + "regional": "إقليمي", + "art": "فنون", "music": "موسيقى", - "tech": "تكنولوجيا" + "tech": "تقنية" }, - "see_less": "اعرض أقل", - "see_more": "اعرض المزيد" + "see_less": "عرض عناصر أقل", + "see_more": "عرض عناصر أكثر" }, "label": { - "language": "اللغة", - "users": "مستخدمون·ات", + "language": "اللُّغة", + "users": "مُستَخدِم", "category": "الفئة" }, "input": { - "placeholder": "ابحث عن خادم أو انضم إلى سيرفر خاص بك..." + "placeholder": "اِبحَث عن خادِم أو انضم إلى آخر خاص بك..." }, "empty_state": { - "finding_servers": "البحث عن خوادم متوفرة...", + "finding_servers": "يجري إيجاد خوادم متوفِّرَة...", "bad_network": "حدث خطأٌ ما أثناء تحميل البيانات. تحقَّق من اتصالك بالإنترنت.", "no_results": "لا توجد نتائج" } }, "register": { - "title": "أخبرنا عنك.", + "title": "أخبرنا عن نفسك.", "input": { "avatar": { - "delete": "احذف" + "delete": "حذف" }, "username": { - "placeholder": "اسم المستخدم", - "duplicate_prompt": "اسم المستخدم هذا غير متوفر." + "placeholder": "اِسم مُستَخدِم", + "duplicate_prompt": "اِسم المُستَخدِم هذا مأخوذٌ بالفعل." }, "display_name": { - "placeholder": "الاسم المعروض" + "placeholder": "اِسم العَرض" }, "email": { - "placeholder": "البريد الإلكتروني" + "placeholder": "بريد إلكتروني" }, "password": { - "placeholder": "الكلمة السرية", - "hint": "يجب أن تكون كلمتك السرية متكونة من ثمانية أحرف على الأقل" + "placeholder": "رمز سري", + "require": "رمز المرور الخاص بك يجب أن يحتوي على الأقل:", + "character_limit": "ثمانيةُ خانات", + "accessibility": { + "checked": "مُتَحَققٌ مِنه", + "unchecked": "غيرُ مُتَحَققٍ مِنه" + }, + "hint": "يجب أن يكون رمزك السري مكوَّن من ثمان خانات على الأقل" }, "invite": { "registration_user_invite_request": "لماذا ترغب في الانضمام؟" @@ -256,10 +274,10 @@ }, "error": { "item": { - "username": "اسم المستخدم", + "username": "اِسم المُستَخدِم", "email": "البريد الإلكتروني", - "password": "الكلمة السرية", - "agreement": "الاتفاقية", + "password": "الرمز السري", + "agreement": "الاِتِّفاقيَّة", "locale": "اللغة المحلية", "reason": "السبب" }, @@ -269,40 +287,40 @@ "taken": "إنَّ %s مُستخدَمٌ بالفعل", "reserved": "إنَّ %s عبارة عن كلمة مفتاحيَّة محجوزة", "accepted": "يجب أن يُقبل %s", - "blank": "%s مطلوب", + "blank": "%s مَطلوب", "invalid": "%s غير صالح", "too_long": "%s طويل جداً", - "too_short": "%s قصير جدا", + "too_short": "%s قصير جدًا", "inclusion": "إنَّ %s قيمة غير مدعومة" }, "special": { "username_invalid": "يُمكِن أن يحتوي اسم المستخدم على أحرف أبجدية، أرقام وشرطات سفلية فقط", - "username_too_long": "اسم المستخدم طويل جداً (يجب ألّا يكون أطول من 30 رمز)", + "username_too_long": "اِسم المُستَخدِم طويل جداً (يَجِبُ ألّا يكون أطول من ثلاثين خانة)", "email_invalid": "هذا عنوان بريد إلكتروني غير صالح", - "password_too_short": "كلمة المرور قصيرة جداً (يجب أن تكون 8 أحرف على الأقل)" + "password_too_short": "رمز السر قصير جدًا (يجب أن يتكون من ثمان خانات على الأقل)" } } }, "server_rules": { "title": "بعض القواعد الأساسية.", - "subtitle": "تم سنّ هذه القواعد من قبل مشرفي %s.", - "prompt": "إن اخترت المواصلة، فإنك تخضع لشروط الخدمة وسياسة الخصوصية لـ %s.", - "terms_of_service": "شروط الخدمة", - "privacy_policy": "سياسة الخصوصية", + "subtitle": "سُنَّت هذه القواعد من قِبل مشرفي %s.", + "prompt": "في حال إختيارك للمواصلة، أنت تخضع لشروط الخدمة وسياسة الخصوصية لِـ%s.", + "terms_of_service": "شُرُوط الخِدمَة", + "privacy_policy": "سِياسَة الخُصُوصيَّة", "button": { - "confirm": "انا أوافق" + "confirm": "أنا مُوافِق" } }, "confirm_email": { - "title": "شيء واحد أخير.", - "subtitle": "لقد أرسلنا للتو رسالة بريد إلكتروني إلى %s،\nاضغط على الرابط لتأكيد حسابك.", + "title": "شيءٌ أخير.", + "subtitle": "لقد أرسلنا للتو بريد إلكتروني إلى %s،\nانقر على الرابط لتأكيد حسابك.", "button": { - "open_email_app": "افتح تطبيق البريد الإلكتروني", - "dont_receive_email": "لم أستلم أبدًا بريدا إلكترونيا" + "open_email_app": "فتح تطبيق البريد الإلكتروني", + "resend": "إعادَةُ الإرسال" }, "dont_receive_email": { "title": "تحقق من بريدك الإلكتروني", - "description": "تحقق ممَّ إذا كان عنوان بريدك الإلكتروني صحيحًا وكذلك تأكد مِن مجلد البريد غير الهام إذا لم تكن قد فعلت ذلك.", + "description": "تحقق ممَّ إذا كان عنوان بريدك الإلكتروني صحيحًا، وكذلك تأكد مِن مجلد البريد غير الهام إذا لم تكن قد فعلت ذلك.", "resend_email": "إعادة إرسال البريد الإلكتروني" }, "open_email_app": { @@ -313,12 +331,12 @@ } }, "home_timeline": { - "title": "الخيط الرئيسي", + "title": "الرَّئِيسَة", "navigation_bar_state": { - "offline": "غير متصل", + "offline": "غَير مُتَّصِل", "new_posts": "إظهار منشورات جديدة", - "published": "تم نشره!", - "Publishing": "جارٍ نشر المشاركة…" + "published": "تمَّ النَّشر!", + "Publishing": "يَجري نَشر المُشارَكَة..." } }, "suggestion_account": { @@ -328,31 +346,31 @@ "compose": { "title": { "new_post": "منشور جديد", - "new_reply": "رد جديد" + "new_reply": "رَدٌّ جديد" }, "media_selection": { - "camera": "التقط صورة", + "camera": "إلتقاط صورة", "photo_library": "مكتبة الصور", "browse": "تصفح" }, "content_input_placeholder": "أخبِرنا بِما يَجُولُ فِي ذِهنَك", - "compose_action": "انشر", - "replying_to_user": "رد على %s", + "compose_action": "نَشر", + "replying_to_user": "رَدًا على %s", "attachment": { "photo": "صورة", - "video": "فيديو", - "attachment_broken": "هذا ال%s مُعطَّل ويتعذَّر رفعه إلى ماستودون.", - "description_photo": "صِف الصورة للمكفوفين...", - "description_video": "صِف المقطع المرئي للمكفوفين..." + "video": "مقطع مرئي", + "attachment_broken": "هذا ال%s مُعطَّل\nويتعذَّرُ رفعُه إلى ماستودون.", + "description_photo": "صِف الصورة للمَكفوفين...", + "description_video": "صِف المقطع المرئي للمَكفوفين..." }, "poll": { - "duration_time": "المدة: %s", - "thirty_minutes": "30 دقيقة", - "one_hour": "ساعة واحدة", - "six_hours": "6 ساعات", - "one_day": "يوم واحد", - "three_days": "3 أيام", - "seven_days": "7 أيام", + "duration_time": "المُدَّة: %s", + "thirty_minutes": "ثلاثون دقيقة", + "one_hour": "ساعةٌ واحدة", + "six_hours": "سِتُّ ساعات", + "one_day": "يومٌ واحِد", + "three_days": "ثلاثةُ أيام", + "seven_days": "سبعةُ أيام", "option_number": "الخيار %ld" }, "content_warning": { @@ -361,33 +379,33 @@ "visibility": { "public": "للعامة", "unlisted": "غير مُدرَج", - "private": "لمتابعيك فقط", - "direct": "ففط للأشخاص المشار إليهم" + "private": "للمُتابِعينَ فقط", + "direct": "للأشخاص المُشار إليهم فقط" }, "auto_complete": { - "space_to_add": "انقر مساحة لإضافتِها" + "space_to_add": "انقر على مساحة لإضافتِها" }, "accessibility": { "append_attachment": "إضافة مُرفَق", "append_poll": "اضافة استطلاع رأي", "remove_poll": "إزالة الاستطلاع", - "custom_emoji_picker": "منتقي مخصص للإيموجي", - "enable_content_warning": "تنشيط تحذير المحتوى", - "disable_content_warning": "تعطيل تحذير الحتوى", + "custom_emoji_picker": "منتقي الرموز التعبيرية المُخصَّص", + "enable_content_warning": "تفعيل تحذير المُحتَوى", + "disable_content_warning": "تعطيل تحذير المُحتَوى", "post_visibility_menu": "قائمة ظهور المنشور" }, "keyboard": { "discard_post": "تجاهُل المنشور", "publish_post": "نَشر المَنشُور", "toggle_poll": "تبديل الاستطلاع", - "toggle_content_warning": "تبديل تحذير المُحتوى", + "toggle_content_warning": "تبديل تحذير المُحتَوى", "append_attachment_entry": "إضافة مُرفَق - %s", "select_visibility_entry": "اختر مدى الظهور - %s" } }, "profile": { "dashboard": { - "posts": "منشورات", + "posts": "مَنشورات", "following": "مُتابَع", "followers": "متابِع" }, @@ -395,22 +413,32 @@ "add_row": "إضافة صف", "placeholder": { "label": "التسمية", - "content": "المحتوى" + "content": "المُحتَوى" } }, "segmented_control": { - "posts": "منشورات", - "replies": "ردود", - "media": "وسائط" + "posts": "مَنشورات", + "replies": "رُدُود", + "posts_and_replies": "المَنشوراتُ وَالرُدود", + "media": "وَسائِط", + "about": "حَول" }, "relationship_action_alert": { - "confirm_unmute_user": { - "title": "إلغاء كتم الحساب", - "message": "أكِّد لرفع كتمْ %s" + "confirm_mute_user": { + "title": "كَتمُ الحِساب", + "message": "تأكيدُ كَتم %s" }, - "confirm_unblock_usre": { - "title": "إلغاء حظر الحساب", - "message": "أكِّد لرفع حظر %s" + "confirm_unmute_user": { + "title": "رفع الكتم عن الحساب", + "message": "أكِّد لرفع الكتمْ عن %s" + }, + "confirm_block_user": { + "title": "حَظرُ الحِساب", + "message": "تأكيدُ حَظر %s" + }, + "confirm_unblock_user": { + "title": "رَفعُ الحَظرِ عَنِ الحِساب", + "message": "تأكيدُ رَفع الحَظرِ عَن %s" } } }, @@ -421,52 +449,54 @@ "footer": "لا يُمكِن عَرض المُتابَعات مِنَ الخوادم الأُخرى." }, "search": { - "title": "بحث", + "title": "البحث", "search_bar": { - "placeholder": "البحث عن وسوم أو مستخدمين·ات", + "placeholder": "البحث عن وسوم أو مستخدمين", "cancel": "إلغاء" }, "recommend": { - "button_text": "طالع الكل", + "button_text": "إظهار الكُل", "hash_tag": { - "title": "ذات شعبية على ماستدون", - "description": "الوسوم التي تحظى بقدر كبير من الاهتمام", + "title": "ذُو شعبيَّة على ماستودون", + "description": "الوُسُومُ الَّتي تَحظى بقدرٍ كبيرٍ مِنَ الاِهتمام", "people_talking": "%s أشخاص يتحدَّثوا" }, "accounts": { - "title": "حسابات قد تعجبك", - "description": "قد ترغب في متابعة هذه الحسابات", - "follow": "تابع" + "title": "حِساباتٍ قَد تُعجِبُك", + "description": "قَد تَرغَب في مُتابَعَةِ هَذِهِ الحِسابات", + "follow": "مُتابَعَة" } }, "searching": { "segment": { - "all": "الكل", + "all": "الكُل", "people": "الأشخاص", - "hashtags": "الوسوم", - "posts": "المنشورات" + "hashtags": "الوُسُوم", + "posts": "المَنشورات" }, "empty_state": { - "no_results": "ليس هناك أية نتيجة" + "no_results": "لا تُوجَدُ نتائِج" }, - "recent_search": "عمليات البحث الأخيرة", + "recent_search": "عَمَليَّاُت البَحثِ الأخيرَة", "clear": "مَحو" } }, "favorite": { - "title": "مفضلتك" + "title": "مُفضَّلَتُك" }, "notification": { "title": { - "Everything": "الكل", + "Everything": "كُلُّ شيء", "Mentions": "الإشارات" }, - "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 اِنتهى استطلاعُكَ للرأي", + "notification_description": { + "followed_you": "بَدَأ بِمُتابَعَتِك", + "favorited_your_post": "فَضَّلَ مَنشُورَك", + "reblogged_your_post": "أعادَ تَدوينَ مَنشُورَك", + "mentioned_you": "أشارَ إليك", + "request_to_follow_you": "طَلَبَ مُتابَعتَك", + "poll_has_ended": "انتهى استطلاعُ الرأي" + }, "keyobard": { "show_everything": "إظهار كل شيء", "show_mentions": "إظهار الإشارات" @@ -480,60 +510,70 @@ "title": "الإعدادات", "section": { "appearance": { - "title": "المظهر", + "title": "المَظهر", "automatic": "تلقائي", "light": "مضيءٌ دائمًا", "dark": "مظلمٌ دائِمًا" }, + "look_and_feel": { + "title": "المَظهَرُ وَالشُّعُور", + "use_system": "استخدم النِظام", + "really_dark": "مُظلمٌ حَقًّا", + "sorta_dark": "مُظلمٌ نوعًا ما", + "light": "مُضيء" + }, "notifications": { "title": "الإشعارات", - "favorites": "الإعجاب بِمنشوراتي", - "follows": "يتابعني", - "boosts": "إعادة تدوين منشوراتي", - "mentions": "الإشارة لي", + "favorites": "بِالإعْجاب بِمَنشوري", + "follows": "بِمُتابَعَتي", + "boosts": "بِإعادَةِ تدوينِ مَنشوري", + "mentions": "بِالإشارَةِ إليّ", "trigger": { - "anyone": "أي شخص", - "follower": "مشترِك", + "anyone": "أيُّ شخصٍ", + "follower": "مُتابِعٌ", "follow": "أي شخص أُتابِعُه", - "noone": "لا أحد", - "title": "إشعاري عِندَ" + "noone": "لَا أحد", + "title": "أشعِرني عِندما يَقومُ" } }, "preference": { - "title": "التفضيلات", - "true_black_dark_mode": "النمط الأسود الداكِن الحقيقي", - "disable_avatar_animation": "تعطيل الصور الرمزية المتحرِّكة", - "disable_emoji_animation": "تعطيل الرموز التعبيرية المتحرِّكَة", - "using_default_browser": "اِستخدام المتصفح الافتراضي لفتح الروابط" + "title": "التَّفضيلات", + "true_black_dark_mode": "النَّمَطُ الأسوَدُ الداكِنُ الحَقيقي", + "disable_avatar_animation": "تَعطيلُ الصوَرِ الرمزيَّةِ المُتحرِّكَة", + "disable_emoji_animation": "تَعطيلُ الرُموزِ التَّعبيريَّةِ المُتحرِّكَة", + "using_default_browser": "اِستِخدامُ المُتصفِّحِ الاِفتراضي لِفتحِ الرَّوابِط" }, "boring_zone": { - "title": "المنطقة المملة", - "account_settings": "إعدادات الحساب", - "terms": "شروط الخدمة", - "privacy": "سياسة الخصوصية" + "title": "المنطِقَةُ المُملَّة", + "account_settings": "إعداداتُ الحِساب", + "terms": "شُرُوطُ الخِدمَة", + "privacy": "سِياسَةُ الخُصوصيَّة" }, "spicy_zone": { - "title": "المنطقة الحارة", - "clear": "مسح ذاكرة التخزين المؤقت للوسائط", - "signout": "تسجيل الخروج" + "title": "المنطِقَةُ اللَّاذِعَة", + "clear": "مَحوُ ذاكِرَةُ التَّخزينِ المُؤقت لِلوسائِط", + "signout": "تَسجيلُ الخُروج" } }, "footer": { - "mastodon_description": "ماستدون برنامج مفتوح المصدر. يمكنك المساهمة، أو الإبلاغ عن تقارير الأخطاء على GitHub في %s (%s)" + "mastodon_description": "ماستودون بَرنامجٌ مَفتُوحُ المَصدَر. يُمكِنُكَ المُساهَمَةُ، أوِ الإبلاغُ عَنِ المُشكِلات عَن طريق مِنصَّة جيت هاب (GitHub) في %s (%s)" }, "keyboard": { "close_settings_window": "إغلاق نافذة الإعدادات" } }, "report": { - "title": "ابلغ عن %s", - "step1": "الخطوة 1 من 2", - "step2": "الخطوة 2 من 2", - "content1": "هل ترغب في إضافة أي مشاركات أُخرى إلى الشكوى؟", - "content2": "هل هناك أي شيء يجب أن يعرفه المُراقبين حول هذه الشكوى؟", - "send": "إرسال الشكوى", + "title_report": "إبلاغ", + "title": "الإبلاغ عن %s", + "step1": "الخطوة الأولى مِن أصل اثنتين", + "step2": "الخطوة الثانية والأخيرة", + "content1": "هل ترغب في إضافة أي منشورات أُخرى إلى البلاغ؟", + "content2": "هل هناك أي شيء يجب أن يعرفه المُراقبين حول هذا البلاغ؟", + "report_sent_title": "شُكرًا لَكَ على الإبلاغ، سَوفَ نَنظُرُ فِي هَذَا الأمر.", + "send": "إرسال البلاغ", "skip_to_send": "إرسال بدون تعليق", - "text_placeholder": "اكتب أو الصق تعليقات إضافيَّة" + "text_placeholder": "اكتب أو الصق تعليقات إضافيَّة", + "reported": "مُبْلَغٌ عَنه" }, "preview": { "keyboard": { @@ -543,14 +583,14 @@ } }, "account_list": { - "tab_bar_hint": "المِلف المُحدَّد حاليًا: %s. انقر نقرًا مزدوجًا ثم اضغط مع الاستمرار لإظهار مُبدِّل الحِساب", - "dismiss_account_switcher": "تجاهُل مبدِّل الحساب", - "add_account": "إضافة حساب" + "tab_bar_hint": "المِلف المُحدَّد حاليًا: %s. انقر نقرًا مزدوجًا مع الاستمرار لإظهار مُبدِّل الحِساب", + "dismiss_account_switcher": "تجاهُل مبدِّل الحِساب", + "add_account": "إضافَةُ حِساب" }, "wizard": { "new_in_mastodon": "جديد في ماستودون", "multiple_account_switch_intro_description": "بدِّل بين حسابات متعددة عبر الاستمرار بالضغط على زر الملف الشخصي.", - "accessibility_hint": "انقر نقرًا مزدوجًا لتجاهل النافذة المنبثقة" + "accessibility_hint": "انقر نقرًا مزدوجًا لتجاهُل النافذة المنبثقة" } } } \ No newline at end of file diff --git a/Localization/StringsConvertor/input/ca_ES/app.json b/Localization/StringsConvertor/input/ca_ES/app.json index 2ecd587c6..c3aac1e5e 100644 --- a/Localization/StringsConvertor/input/ca_ES/app.json +++ b/Localization/StringsConvertor/input/ca_ES/app.json @@ -46,7 +46,7 @@ }, "delete_post": { "title": "Estàs segur que vols suprimir aquesta publicació?", - "delete": "Esborra" + "message": "Estàs segur que vols suprimir aquesta publicació?" }, "clean_cache": { "title": "Neteja la memòria cau", @@ -82,6 +82,7 @@ "share_user": "Compartir %s", "share_post": "Compartir Publicació", "open_in_safari": "Obrir a Safari", + "open_in_browser": "Obre al navegador", "find_people": "Busca persones per seguir", "manually_search": "Cerca manualment a canvi", "skip": "Omet", @@ -139,7 +140,8 @@ "unreblog": "Desfer l'impuls", "favorite": "Favorit", "unfavorite": "Desfer Favorit", - "menu": "Menú" + "menu": "Menú", + "hide": "Amaga" }, "tag": { "url": "URL", @@ -148,6 +150,12 @@ "hashtag": "Etiqueta", "email": "Correu electrònic", "emoji": "Emoji" + }, + "visibility": { + "unlisted": "Tothom pot veure aquesta publicació però no es mostra en la línia de temps pública.", + "private": "Només els seus seguidors poden veure aquesta publicació.", + "private_from_me": "Només els meus seguidors poden veure aquesta publicació.", + "direct": "Només l'usuari mencionat pot veure aquesta publicació." } }, "friendship": { @@ -192,10 +200,14 @@ }, "scene": { "welcome": { - "slogan": "Xarxa social\nde nou a les teves mans." + "slogan": "Xarxa social\nde nou a les teves mans.", + "get_started": "Comença", + "log_in": "Inicia sessió" }, "server_picker": { "title": "Tria un servidor,\nqualsevol servidor.", + "subtitle": "Tria una comunitat segons els teus interessos, regió o una de propòsit general.", + "subtitle_extend": "Tria una comunitat segons els teus interessos, regió o una de propòsit general. Cada comunitat és operada per una organització totalment independent o individualment.", "button": { "category": { "all": "Totes", @@ -248,6 +260,12 @@ }, "password": { "placeholder": "contrasenya", + "require": "La teva contrasenya com a mínim necessita:", + "character_limit": "8 caràcters", + "accessibility": { + "checked": "verificat", + "unchecked": "no verificat" + }, "hint": "La teva contrasenya ha de tenir com a mínim buit caràcters" }, "invite": { @@ -298,7 +316,7 @@ "subtitle": "Acabem d'enviar un correu electrònic a %s,\ntoca l'enllaç per a confirmar el teu compte.", "button": { "open_email_app": "Obre l'aplicació de correu", - "dont_receive_email": "No he rebut cap correu electrònic" + "resend": "Reenvia" }, "dont_receive_email": { "title": "Comprova el teu correu", @@ -401,16 +419,26 @@ "segmented_control": { "posts": "Publicacions", "replies": "Respostes", - "media": "Mèdia" + "posts_and_replies": "Publicacions i Respostes", + "media": "Mèdia", + "about": "Quant a" }, "relationship_action_alert": { + "confirm_mute_user": { + "title": "Silencia el Compte", + "message": "Confirma per a silenciar %s" + }, "confirm_unmute_user": { "title": "Desfer silenciar compte", "message": "Confirma deixar de silenciar a %s" }, - "confirm_unblock_usre": { - "title": "Desbloquejar Compte", - "message": "Confirma desbloquejar a %s" + "confirm_block_user": { + "title": "Bloqueja el Compte", + "message": "Confirma per a bloquejar %s" + }, + "confirm_unblock_user": { + "title": "Desbloqueja el Compte", + "message": "Confirma per a desbloquejar %s" } } }, @@ -461,12 +489,14 @@ "Everything": "Tot", "Mentions": "Mencions" }, - "user_followed_you": "%s et segueix", - "user_favorited your post": "%s ha afavorit el teu estat", - "user_reblogged_your_post": "%s ha impulsat el teu estat", - "user_mentioned_you": "%s t'ha esmentat", - "user_requested_to_follow_you": "%s ha sol·licitat seguir-te", - "user_your_poll_has_ended": "%s L'enquesta ha finalitzat", + "notification_description": { + "followed_you": "et segueix", + "favorited_your_post": "ha afavorit la teva publicació", + "reblogged_your_post": "ha impulsat la teva publicació", + "mentioned_you": "t'ha mencionat", + "request_to_follow_you": "ha sol·licitat seguir-te", + "poll_has_ended": "la enquesta ha finalitzat" + }, "keyobard": { "show_everything": "Mostrar-ho tot", "show_mentions": "Mostrar Mencions" @@ -485,6 +515,13 @@ "light": "Sempre Clara", "dark": "Sempre Fosca" }, + "look_and_feel": { + "title": "Aspecte i Comportament", + "use_system": "Usa el del Sistema", + "really_dark": "Realment Negre", + "sorta_dark": "Una Mena de Fosc", + "light": "Clar" + }, "notifications": { "title": "Notificacions", "favorites": "Ha afavorit el meu estat", @@ -526,14 +563,17 @@ } }, "report": { + "title_report": "Informe", "title": "Informa sobre %s", "step1": "Pas 1 de 2", "step2": "Pas 2 de 2", "content1": "Hi ha alguna altre publicació que vulguis afegir a l'informe?", "content2": "Hi ha alguna cosa que els moderadors hagin de saber sobre aquest informe?", + "report_sent_title": "Gràcies per informar, ho investigarem.", "send": "Envia Informe", "skip_to_send": "Envia sense comentaris", - "text_placeholder": "Escriu o enganxa comentaris addicionals" + "text_placeholder": "Escriu o enganxa comentaris addicionals", + "reported": "REPORTAT" }, "preview": { "keyboard": { diff --git a/Localization/StringsConvertor/input/cy_GB/app.json b/Localization/StringsConvertor/input/cy_GB/app.json index 5c01ae7e0..ad99e178d 100644 --- a/Localization/StringsConvertor/input/cy_GB/app.json +++ b/Localization/StringsConvertor/input/cy_GB/app.json @@ -45,8 +45,8 @@ "message": "Please enable the photo library access permission to save the photo." }, "delete_post": { - "title": "Are you sure you want to delete this post?", - "delete": "Delete" + "title": "Delete Post", + "message": "Are you sure you want to delete this post?" }, "clean_cache": { "title": "Clean Cache", @@ -82,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", @@ -139,7 +140,8 @@ "unreblog": "Undo reblog", "favorite": "Favorite", "unfavorite": "Unfavorite", - "menu": "Menu" + "menu": "Menu", + "hide": "Hide" }, "tag": { "url": "URL", @@ -148,6 +150,12 @@ "hashtag": "Hashtag", "email": "Email", "emoji": "Emoji" + }, + "visibility": { + "unlisted": "Everyone can see this post but not display in the public timeline.", + "private": "Only their followers can see this post.", + "private_from_me": "Only my followers can see this post.", + "direct": "Only mentioned user can see this post." } }, "friendship": { @@ -192,10 +200,14 @@ }, "scene": { "welcome": { - "slogan": "Social networking\nback in your hands." + "slogan": "Social networking\nback in your hands.", + "get_started": "Get Started", + "log_in": "Log In" }, "server_picker": { - "title": "Pick a server,\nany server.", + "title": "Mastodon is made of users in different communities.", + "subtitle": "Pick a community based on your interests, region, or a general purpose one.", + "subtitle_extend": "Pick a community based on your interests, region, or a general purpose one. Each community is operated by an entirely independent organization or individual.", "button": { "category": { "all": "All", @@ -222,7 +234,7 @@ "category": "CATEGORY" }, "input": { - "placeholder": "Find a server or join your own..." + "placeholder": "Search communities" }, "empty_state": { "finding_servers": "Finding available servers...", @@ -231,7 +243,7 @@ } }, "register": { - "title": "Tell us about you.", + "title": "Let’s get you set up on %s", "input": { "avatar": { "delete": "Delete" @@ -248,6 +260,12 @@ }, "password": { "placeholder": "password", + "require": "Your password needs at least:", + "character_limit": "8 characters", + "accessibility": { + "checked": "checked", + "unchecked": "unchecked" + }, "hint": "Your password needs at least eight characters" }, "invite": { @@ -285,7 +303,7 @@ }, "server_rules": { "title": "Some ground rules.", - "subtitle": "These rules are set by the admins of %s.", + "subtitle": "These are set and enforced by the %s moderators.", "prompt": "By continuing, you’re subject to the terms of service and privacy policy for %s.", "terms_of_service": "terms of service", "privacy_policy": "privacy policy", @@ -295,10 +313,10 @@ }, "confirm_email": { "title": "One last thing.", - "subtitle": "We just sent an email to %s,\ntap the link to confirm your account.", + "subtitle": "Tap the link we emailed to you to verify your account.", "button": { "open_email_app": "Open Email App", - "dont_receive_email": "I never got an email" + "resend": "Resend" }, "dont_receive_email": { "title": "Check your email", @@ -401,14 +419,24 @@ "segmented_control": { "posts": "Posts", "replies": "Replies", - "media": "Media" + "posts_and_replies": "Posts and Replies", + "media": "Media", + "about": "About" }, "relationship_action_alert": { + "confirm_mute_user": { + "title": "Mute Account", + "message": "Confirm to mute %s" + }, "confirm_unmute_user": { "title": "Unmute Account", "message": "Confirm to unmute %s" }, - "confirm_unblock_usre": { + "confirm_block_user": { + "title": "Block Account", + "message": "Confirm to block %s" + }, + "confirm_unblock_user": { "title": "Unblock Account", "message": "Confirm to unblock %s" } @@ -461,12 +489,14 @@ "Everything": "Everything", "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", + "notification_description": { + "followed_you": "followed you", + "favorited_your_post": "favorited your post", + "reblogged_your_post": "reblogged your post", + "mentioned_you": "mentioned you", + "request_to_follow_you": "request to follow you", + "poll_has_ended": "poll has ended" + }, "keyobard": { "show_everything": "Show Everything", "show_mentions": "Show Mentions" @@ -485,6 +515,13 @@ "light": "Always Light", "dark": "Always Dark" }, + "look_and_feel": { + "title": "Look and Feel", + "use_system": "Use System", + "really_dark": "Really Dark", + "sorta_dark": "Sorta Dark", + "light": "Light" + }, "notifications": { "title": "Notifications", "favorites": "Favorites my post", @@ -526,14 +563,17 @@ } }, "report": { + "title_report": "Report", "title": "Report %s", "step1": "Step 1 of 2", "step2": "Step 2 of 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?", + "report_sent_title": "Thanks for reporting, we’ll look into this.", "send": "Send Report", "skip_to_send": "Send without comment", - "text_placeholder": "Type or paste additional comments" + "text_placeholder": "Type or paste additional comments", + "reported": "REPORTED" }, "preview": { "keyboard": { diff --git a/Localization/StringsConvertor/input/da_DK/app.json b/Localization/StringsConvertor/input/da_DK/app.json index 5c01ae7e0..ad99e178d 100644 --- a/Localization/StringsConvertor/input/da_DK/app.json +++ b/Localization/StringsConvertor/input/da_DK/app.json @@ -45,8 +45,8 @@ "message": "Please enable the photo library access permission to save the photo." }, "delete_post": { - "title": "Are you sure you want to delete this post?", - "delete": "Delete" + "title": "Delete Post", + "message": "Are you sure you want to delete this post?" }, "clean_cache": { "title": "Clean Cache", @@ -82,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", @@ -139,7 +140,8 @@ "unreblog": "Undo reblog", "favorite": "Favorite", "unfavorite": "Unfavorite", - "menu": "Menu" + "menu": "Menu", + "hide": "Hide" }, "tag": { "url": "URL", @@ -148,6 +150,12 @@ "hashtag": "Hashtag", "email": "Email", "emoji": "Emoji" + }, + "visibility": { + "unlisted": "Everyone can see this post but not display in the public timeline.", + "private": "Only their followers can see this post.", + "private_from_me": "Only my followers can see this post.", + "direct": "Only mentioned user can see this post." } }, "friendship": { @@ -192,10 +200,14 @@ }, "scene": { "welcome": { - "slogan": "Social networking\nback in your hands." + "slogan": "Social networking\nback in your hands.", + "get_started": "Get Started", + "log_in": "Log In" }, "server_picker": { - "title": "Pick a server,\nany server.", + "title": "Mastodon is made of users in different communities.", + "subtitle": "Pick a community based on your interests, region, or a general purpose one.", + "subtitle_extend": "Pick a community based on your interests, region, or a general purpose one. Each community is operated by an entirely independent organization or individual.", "button": { "category": { "all": "All", @@ -222,7 +234,7 @@ "category": "CATEGORY" }, "input": { - "placeholder": "Find a server or join your own..." + "placeholder": "Search communities" }, "empty_state": { "finding_servers": "Finding available servers...", @@ -231,7 +243,7 @@ } }, "register": { - "title": "Tell us about you.", + "title": "Let’s get you set up on %s", "input": { "avatar": { "delete": "Delete" @@ -248,6 +260,12 @@ }, "password": { "placeholder": "password", + "require": "Your password needs at least:", + "character_limit": "8 characters", + "accessibility": { + "checked": "checked", + "unchecked": "unchecked" + }, "hint": "Your password needs at least eight characters" }, "invite": { @@ -285,7 +303,7 @@ }, "server_rules": { "title": "Some ground rules.", - "subtitle": "These rules are set by the admins of %s.", + "subtitle": "These are set and enforced by the %s moderators.", "prompt": "By continuing, you’re subject to the terms of service and privacy policy for %s.", "terms_of_service": "terms of service", "privacy_policy": "privacy policy", @@ -295,10 +313,10 @@ }, "confirm_email": { "title": "One last thing.", - "subtitle": "We just sent an email to %s,\ntap the link to confirm your account.", + "subtitle": "Tap the link we emailed to you to verify your account.", "button": { "open_email_app": "Open Email App", - "dont_receive_email": "I never got an email" + "resend": "Resend" }, "dont_receive_email": { "title": "Check your email", @@ -401,14 +419,24 @@ "segmented_control": { "posts": "Posts", "replies": "Replies", - "media": "Media" + "posts_and_replies": "Posts and Replies", + "media": "Media", + "about": "About" }, "relationship_action_alert": { + "confirm_mute_user": { + "title": "Mute Account", + "message": "Confirm to mute %s" + }, "confirm_unmute_user": { "title": "Unmute Account", "message": "Confirm to unmute %s" }, - "confirm_unblock_usre": { + "confirm_block_user": { + "title": "Block Account", + "message": "Confirm to block %s" + }, + "confirm_unblock_user": { "title": "Unblock Account", "message": "Confirm to unblock %s" } @@ -461,12 +489,14 @@ "Everything": "Everything", "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", + "notification_description": { + "followed_you": "followed you", + "favorited_your_post": "favorited your post", + "reblogged_your_post": "reblogged your post", + "mentioned_you": "mentioned you", + "request_to_follow_you": "request to follow you", + "poll_has_ended": "poll has ended" + }, "keyobard": { "show_everything": "Show Everything", "show_mentions": "Show Mentions" @@ -485,6 +515,13 @@ "light": "Always Light", "dark": "Always Dark" }, + "look_and_feel": { + "title": "Look and Feel", + "use_system": "Use System", + "really_dark": "Really Dark", + "sorta_dark": "Sorta Dark", + "light": "Light" + }, "notifications": { "title": "Notifications", "favorites": "Favorites my post", @@ -526,14 +563,17 @@ } }, "report": { + "title_report": "Report", "title": "Report %s", "step1": "Step 1 of 2", "step2": "Step 2 of 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?", + "report_sent_title": "Thanks for reporting, we’ll look into this.", "send": "Send Report", "skip_to_send": "Send without comment", - "text_placeholder": "Type or paste additional comments" + "text_placeholder": "Type or paste additional comments", + "reported": "REPORTED" }, "preview": { "keyboard": { diff --git a/Localization/StringsConvertor/input/de_DE/app.json b/Localization/StringsConvertor/input/de_DE/app.json index dc8cdf8c0..f62f9f95d 100644 --- a/Localization/StringsConvertor/input/de_DE/app.json +++ b/Localization/StringsConvertor/input/de_DE/app.json @@ -46,7 +46,7 @@ }, "delete_post": { "title": "Bist du dir sicher, dass du diesen Beitrag löschen möchtest?", - "delete": "Löschen" + "message": "Bist du dir sicher, dass du diesen Beitrag löschen willst?" }, "clean_cache": { "title": "Zwischenspeicher leeren", @@ -67,7 +67,7 @@ "done": "Fertig", "confirm": "Bestätigen", "continue": "Fortfahren", - "compose": "Compose", + "compose": "Neue Nachricht", "cancel": "Abbrechen", "discard": "Verwerfen", "try_again": "Nochmals versuchen", @@ -82,6 +82,7 @@ "share_user": "%s teilen", "share_post": "Beitrag teilen", "open_in_safari": "In Safari öffnen", + "open_in_browser": "Im Browser anzeigen", "find_people": "Finde Personen zum Folgen", "manually_search": "Stattdessen manuell suchen", "skip": "Überspringen", @@ -139,7 +140,8 @@ "unreblog": "Nicht mehr teilen", "favorite": "Favorit", "unfavorite": "Aus Favoriten entfernen", - "menu": "Menü" + "menu": "Menü", + "hide": "Verstecken" }, "tag": { "url": "URL", @@ -148,6 +150,12 @@ "hashtag": "Hashtag", "email": "E-Mail", "emoji": "Emoji" + }, + "visibility": { + "unlisted": "Jeder kann diesen Post sehen, aber nicht in der öffentlichen Timeline zeigen.", + "private": "Nur Follower des Authors können diesen Beitrag sehen.", + "private_from_me": "Nur meine Follower können diesen Beitrag sehen.", + "direct": "Nur erwähnte Benutzer können diesen Beitrag sehen." } }, "friendship": { @@ -192,10 +200,14 @@ }, "scene": { "welcome": { - "slogan": "Soziale Netzwerke wieder in deinen Händen." + "slogan": "Soziale Netzwerke wieder in deinen Händen.", + "get_started": "Erste Schritte", + "log_in": "Anmelden" }, "server_picker": { "title": "Wähle einen Server,\nbeliebigen Server.", + "subtitle": "Wähle eine Gemeinschaft, die auf deinen Interessen, Region oder einem allgemeinen Zweck basiert.", + "subtitle_extend": "Wähle eine Gemeinschaft basierend auf deinen Interessen, deiner Region oder einem allgemeinen Zweck. Jede Gemeinschaft wird von einer völlig unabhängigen Organisation oder Einzelperson betrieben.", "button": { "category": { "all": "Alle", @@ -248,6 +260,12 @@ }, "password": { "placeholder": "Passwort", + "require": "Anforderungen an dein Passwort:", + "character_limit": "8 Zeichen", + "accessibility": { + "checked": "Häkchen gesetzt", + "unchecked": "Häkchen entfernt" + }, "hint": "Ihr Passwort muss mindestens 8 Zeichen lang sein" }, "invite": { @@ -298,7 +316,7 @@ "subtitle": "Wir haben gerade eine E-Mail an %s gesendet,\ntippe darin auf den Link, um Dein Konto zu bestätigen.", "button": { "open_email_app": "E-Mail-App öffnen", - "dont_receive_email": "Ich habe keine E-Mail erhalten." + "resend": "Erneut senden" }, "dont_receive_email": { "title": "Bitte überprüfe deine E-Mails", @@ -401,24 +419,34 @@ "segmented_control": { "posts": "Beiträge", "replies": "Antworten", - "media": "Medien" + "posts_and_replies": "Beiträge und Antworten", + "media": "Medien", + "about": "Über" }, "relationship_action_alert": { + "confirm_mute_user": { + "title": "Konto stummschalten", + "message": "Bestätige %s stumm zu schalten" + }, "confirm_unmute_user": { "title": "Ton einschalten", "message": "Bestätige um %s nicht mehr stummzuschalten" }, - "confirm_unblock_usre": { + "confirm_block_user": { + "title": "Konto blockieren", + "message": "Bestätige %s zu blockieren" + }, + "confirm_unblock_user": { "title": "Konto entsperren", - "message": "Bestätigen zum Entsperren von %s" + "message": "Bestätige %s zu entsperren" } } }, "follower": { - "footer": "Followers from other servers are not displayed." + "footer": "Follower von anderen Servern werden nicht angezeigt." }, "following": { - "footer": "Follows from other servers are not displayed." + "footer": "Wem das Konto folgt wird von anderen Servern werden nicht angezeigt." }, "search": { "title": "Suche", @@ -461,12 +489,14 @@ "Everything": "Alles", "Mentions": "Erwähnungen" }, - "user_followed_you": "%s folgte dir", - "user_favorited your post": "%s favorisierte deinen Beitrag", - "user_reblogged_your_post": "%s teilte deinen Beitrag", - "user_mentioned_you": "%s erwähnte dich", - "user_requested_to_follow_you": "%s beantragte dir zu folgen", - "user_your_poll_has_ended": "%s deine Umfrage ist beendet", + "notification_description": { + "followed_you": "folgt dir", + "favorited_your_post": "hat deinen Beitrag favorisiert", + "reblogged_your_post": "hat deinen Beitrag geteilt", + "mentioned_you": "hat dich erwähnt", + "request_to_follow_you": "Folgeanfrage", + "poll_has_ended": "Umfrage wurde beendet" + }, "keyobard": { "show_everything": "Alles anzeigen", "show_mentions": "Erwähnungen anzeigen" @@ -485,6 +515,13 @@ "light": "Immer hell", "dark": "Immer dunkel" }, + "look_and_feel": { + "title": "Erscheinungsbild", + "use_system": "Systemeinstellung benutzen", + "really_dark": "Wirklich dunkel", + "sorta_dark": "Ziemlich dunkel", + "light": "Hell" + }, "notifications": { "title": "Benachrichtigungen", "favorites": "Meinen Beitrag favorisiert", @@ -507,7 +544,7 @@ "using_default_browser": "Standardbrowser zum Öffnen von Links verwenden" }, "boring_zone": { - "title": "Der Langweiliger Bereich", + "title": "Der langweilige Bereich", "account_settings": "Kontoeinstellungen", "terms": "Allgemeine Geschäftsbedingungen", "privacy": "Datenschutzerklärung" @@ -526,14 +563,17 @@ } }, "report": { + "title_report": "Melden", "title": "%s melden", "step1": "Schritt 1 von 2", "step2": "Schritt 2 von 2", "content1": "Gibt es noch weitere Beiträge, die du der Meldung hinzufügen möchtest?", "content2": "Gibt es etwas, was die Moderatoren über diese Meldung wissen sollten?", + "report_sent_title": "Danke für deine Meldung, wir werden uns damit beschäftigen.", "send": "Meldung abschicken", "skip_to_send": "Ohne Kommentar abschicken", - "text_placeholder": "Zusätzliche Kommentare eingeben oder einfügen" + "text_placeholder": "Zusätzliche Kommentare eingeben oder einfügen", + "reported": "GEMELDET" }, "preview": { "keyboard": { @@ -544,7 +584,7 @@ }, "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", + "dismiss_account_switcher": "Dialog zum Wechseln des Kontos schließen", "add_account": "Konto hinzufügen" }, "wizard": { diff --git a/Localization/StringsConvertor/input/en_US/app.json b/Localization/StringsConvertor/input/en_US/app.json index 5c01ae7e0..ad99e178d 100644 --- a/Localization/StringsConvertor/input/en_US/app.json +++ b/Localization/StringsConvertor/input/en_US/app.json @@ -45,8 +45,8 @@ "message": "Please enable the photo library access permission to save the photo." }, "delete_post": { - "title": "Are you sure you want to delete this post?", - "delete": "Delete" + "title": "Delete Post", + "message": "Are you sure you want to delete this post?" }, "clean_cache": { "title": "Clean Cache", @@ -82,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", @@ -139,7 +140,8 @@ "unreblog": "Undo reblog", "favorite": "Favorite", "unfavorite": "Unfavorite", - "menu": "Menu" + "menu": "Menu", + "hide": "Hide" }, "tag": { "url": "URL", @@ -148,6 +150,12 @@ "hashtag": "Hashtag", "email": "Email", "emoji": "Emoji" + }, + "visibility": { + "unlisted": "Everyone can see this post but not display in the public timeline.", + "private": "Only their followers can see this post.", + "private_from_me": "Only my followers can see this post.", + "direct": "Only mentioned user can see this post." } }, "friendship": { @@ -192,10 +200,14 @@ }, "scene": { "welcome": { - "slogan": "Social networking\nback in your hands." + "slogan": "Social networking\nback in your hands.", + "get_started": "Get Started", + "log_in": "Log In" }, "server_picker": { - "title": "Pick a server,\nany server.", + "title": "Mastodon is made of users in different communities.", + "subtitle": "Pick a community based on your interests, region, or a general purpose one.", + "subtitle_extend": "Pick a community based on your interests, region, or a general purpose one. Each community is operated by an entirely independent organization or individual.", "button": { "category": { "all": "All", @@ -222,7 +234,7 @@ "category": "CATEGORY" }, "input": { - "placeholder": "Find a server or join your own..." + "placeholder": "Search communities" }, "empty_state": { "finding_servers": "Finding available servers...", @@ -231,7 +243,7 @@ } }, "register": { - "title": "Tell us about you.", + "title": "Let’s get you set up on %s", "input": { "avatar": { "delete": "Delete" @@ -248,6 +260,12 @@ }, "password": { "placeholder": "password", + "require": "Your password needs at least:", + "character_limit": "8 characters", + "accessibility": { + "checked": "checked", + "unchecked": "unchecked" + }, "hint": "Your password needs at least eight characters" }, "invite": { @@ -285,7 +303,7 @@ }, "server_rules": { "title": "Some ground rules.", - "subtitle": "These rules are set by the admins of %s.", + "subtitle": "These are set and enforced by the %s moderators.", "prompt": "By continuing, you’re subject to the terms of service and privacy policy for %s.", "terms_of_service": "terms of service", "privacy_policy": "privacy policy", @@ -295,10 +313,10 @@ }, "confirm_email": { "title": "One last thing.", - "subtitle": "We just sent an email to %s,\ntap the link to confirm your account.", + "subtitle": "Tap the link we emailed to you to verify your account.", "button": { "open_email_app": "Open Email App", - "dont_receive_email": "I never got an email" + "resend": "Resend" }, "dont_receive_email": { "title": "Check your email", @@ -401,14 +419,24 @@ "segmented_control": { "posts": "Posts", "replies": "Replies", - "media": "Media" + "posts_and_replies": "Posts and Replies", + "media": "Media", + "about": "About" }, "relationship_action_alert": { + "confirm_mute_user": { + "title": "Mute Account", + "message": "Confirm to mute %s" + }, "confirm_unmute_user": { "title": "Unmute Account", "message": "Confirm to unmute %s" }, - "confirm_unblock_usre": { + "confirm_block_user": { + "title": "Block Account", + "message": "Confirm to block %s" + }, + "confirm_unblock_user": { "title": "Unblock Account", "message": "Confirm to unblock %s" } @@ -461,12 +489,14 @@ "Everything": "Everything", "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", + "notification_description": { + "followed_you": "followed you", + "favorited_your_post": "favorited your post", + "reblogged_your_post": "reblogged your post", + "mentioned_you": "mentioned you", + "request_to_follow_you": "request to follow you", + "poll_has_ended": "poll has ended" + }, "keyobard": { "show_everything": "Show Everything", "show_mentions": "Show Mentions" @@ -485,6 +515,13 @@ "light": "Always Light", "dark": "Always Dark" }, + "look_and_feel": { + "title": "Look and Feel", + "use_system": "Use System", + "really_dark": "Really Dark", + "sorta_dark": "Sorta Dark", + "light": "Light" + }, "notifications": { "title": "Notifications", "favorites": "Favorites my post", @@ -526,14 +563,17 @@ } }, "report": { + "title_report": "Report", "title": "Report %s", "step1": "Step 1 of 2", "step2": "Step 2 of 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?", + "report_sent_title": "Thanks for reporting, we’ll look into this.", "send": "Send Report", "skip_to_send": "Send without comment", - "text_placeholder": "Type or paste additional comments" + "text_placeholder": "Type or paste additional comments", + "reported": "REPORTED" }, "preview": { "keyboard": { diff --git a/Localization/StringsConvertor/input/es_AR/app.json b/Localization/StringsConvertor/input/es_AR/app.json index ed909ecf1..106ffb646 100644 --- a/Localization/StringsConvertor/input/es_AR/app.json +++ b/Localization/StringsConvertor/input/es_AR/app.json @@ -46,7 +46,7 @@ }, "delete_post": { "title": "¿Estás seguro que querés eliminar este mensaje?", - "delete": "Eliminar" + "message": "¿Estás seguro que querés eliminar este mensaje?" }, "clean_cache": { "title": "Limpiar caché", @@ -82,6 +82,7 @@ "share_user": "Compartir %s", "share_post": "Compartir mensaje", "open_in_safari": "Abrir en Safari", + "open_in_browser": "Abrir en el navegador", "find_people": "Encontrá cuentas para seguir", "manually_search": "Buscar manualmente", "skip": "Omitir", @@ -139,7 +140,8 @@ "unreblog": "Deshacer adhesión", "favorite": "Marcar como favorito", "unfavorite": "Dejar de marcar como favorito", - "menu": "Menú" + "menu": "Menú", + "hide": "Ocultar" }, "tag": { "url": "Dirección web", @@ -148,6 +150,12 @@ "hashtag": "Etiqueta", "email": "Correo electrónico", "emoji": "Emoji" + }, + "visibility": { + "unlisted": "Todo el mundo puede ver este mensaje pero no mostrarse en la línea temporal pública.", + "private": "Sólo sus seguidores pueden ver este mensaje.", + "private_from_me": "Sólo mis seguidores pueden ver este mensaje.", + "direct": "Sólo el usuario mencionado puede ver este mensaje." } }, "friendship": { @@ -192,10 +200,14 @@ }, "scene": { "welcome": { - "slogan": "La red social,\nnuevamente en tu poder." + "slogan": "La red social,\nnuevamente en tu poder.", + "get_started": "Comenzá", + "log_in": "Iniciar sesión" }, "server_picker": { "title": "Elegí un servidor,\nel que quieras.", + "subtitle": "Elegí una comunidad basada en tus intereses, región o una de propósitos generales.", + "subtitle_extend": "Elegí una comunidad basada en tus intereses, región o una de propósitos generales. Cada comunidad es operada por una organización o individuo totalmente independiente.", "button": { "category": { "all": "Todas", @@ -248,6 +260,12 @@ }, "password": { "placeholder": "contraseña", + "require": "Tu contraseña necesita al menos:", + "character_limit": "8 caracteres", + "accessibility": { + "checked": "marcado", + "unchecked": "sin marcar" + }, "hint": "Tu contraseña necesita al menos ocho caracteres" }, "invite": { @@ -298,7 +316,7 @@ "subtitle": "Acabamos de enviar un correo electrónico a %s,\npulsá en el enlace para confirmar tu cuenta.", "button": { "open_email_app": "Abrir aplicación de correo electrónico", - "dont_receive_email": "Nunca recibí un correo electrónico" + "resend": "Reenviar" }, "dont_receive_email": { "title": "Revisá tu correo electrónico", @@ -401,14 +419,24 @@ "segmented_control": { "posts": "Mensajes", "replies": "Respuestas", - "media": "Medios" + "posts_and_replies": "Mensajes y respuestas", + "media": "Medios", + "about": "Información" }, "relationship_action_alert": { + "confirm_mute_user": { + "title": "Silenciar cuenta", + "message": "Confirmá para silenciar a %s" + }, "confirm_unmute_user": { "title": "Dejar de silenciar cuenta", "message": "Confirmá para dejar de silenciar a %s" }, - "confirm_unblock_usre": { + "confirm_block_user": { + "title": "Bloquear cuenta", + "message": "Confirmá para desbloquear a %s" + }, + "confirm_unblock_user": { "title": "Desbloquear cuenta", "message": "Confirmá para desbloquear a %s" } @@ -461,12 +489,14 @@ "Everything": "Todo", "Mentions": "Menciones" }, - "user_followed_you": "%s te sigue", - "user_favorited your post": "%s marcó tu msj. como favorito", - "user_reblogged_your_post": "%s adhirió a tu mensaje", - "user_mentioned_you": "%s te mencionó", - "user_requested_to_follow_you": "%s solicitó seguirte", - "user_your_poll_has_ended": "%s, tu encuesta finalizó", + "notification_description": { + "followed_you": "te sigue", + "favorited_your_post": "marcó como favorito tu mensaje", + "reblogged_your_post": "adhirió a tu mensaje", + "mentioned_you": "te mencionó", + "request_to_follow_you": "solicitó seguirte", + "poll_has_ended": "la encuesta terminó" + }, "keyobard": { "show_everything": "Mostrar todo", "show_mentions": "Mostrar menciones" @@ -485,6 +515,13 @@ "light": "Siempre clara", "dark": "Siempre oscura" }, + "look_and_feel": { + "title": "Apariencia", + "use_system": "Usar sistema", + "really_dark": "Oscuro de verdad", + "sorta_dark": "Algo oscuro", + "light": "Claro" + }, "notifications": { "title": "Notificaciones", "favorites": "Marcó como favorito mi mensaje", @@ -526,14 +563,17 @@ } }, "report": { + "title_report": "Denunciar", "title": "Denunciar a %s", "step1": "Paso 1 de 2", "step2": "Paso 2 de 2", "content1": "¿Hay otros mensajes que te gustaría agregar a la denuncia?", "content2": "¿Hay algo que los moderadores deban saber sobre esta denuncia?", + "report_sent_title": "Gracias por tu denuncia, vamos a revisarla.", "send": "Enviar denuncia", "skip_to_send": "Enviar sin comentarios", - "text_placeholder": "Escribí o pegá comentarios adicionales" + "text_placeholder": "Escribí o pegá comentarios adicionales", + "reported": "DENUNCIADA" }, "preview": { "keyboard": { diff --git a/Localization/StringsConvertor/input/es_ES/Localizable.stringsdict b/Localization/StringsConvertor/input/es_ES/Localizable.stringsdict index d31d8825b..186218af6 100644 --- a/Localization/StringsConvertor/input/es_ES/Localizable.stringsdict +++ b/Localization/StringsConvertor/input/es_ES/Localizable.stringsdict @@ -13,9 +13,9 @@ <key>NSStringFormatValueTypeKey</key> <string>ld</string> <key>one</key> - <string>1 unread notification</string> + <string>1 notificación no leída</string> <key>other</key> - <string>%ld unread notification</string> + <string>%ld notificaciones no leídas</string> </dict> </dict> <key>a11y.plural.count.input_limit_exceeds</key> diff --git a/Localization/StringsConvertor/input/es_ES/app.json b/Localization/StringsConvertor/input/es_ES/app.json index 72967c40f..cfebae265 100644 --- a/Localization/StringsConvertor/input/es_ES/app.json +++ b/Localization/StringsConvertor/input/es_ES/app.json @@ -46,7 +46,7 @@ }, "delete_post": { "title": "¿Estás seguro de que deseas eliminar esta publicación?", - "delete": "Eliminar" + "message": "¿Estás seguro de que quieres borrar esta publicación?" }, "clean_cache": { "title": "Limpiar Caché", @@ -82,6 +82,7 @@ "share_user": "Compartir %s", "share_post": "Compartir publicación", "open_in_safari": "Abrir en Safari", + "open_in_browser": "Abrir en el navegador", "find_people": "Encuentra gente a la que seguir", "manually_search": "Mejor hacer una búsqueda manual", "skip": "Omitir", @@ -139,7 +140,8 @@ "unreblog": "Deshacer reblogueo", "favorite": "Favorito", "unfavorite": "No favorito", - "menu": "Menú" + "menu": "Menú", + "hide": "Ocultar" }, "tag": { "url": "URL", @@ -148,6 +150,12 @@ "hashtag": "Etiqueta", "email": "E-mail", "emoji": "Emoji" + }, + "visibility": { + "unlisted": "Todo el mundo puede ver este post pero no mostrar en la línea de tiempo pública.", + "private": "Sólo sus seguidores pueden ver este mensaje.", + "private_from_me": "Sólo mis seguidores pueden ver este mensaje.", + "direct": "Sólo el usuario mencionado puede ver este mensaje." } }, "friendship": { @@ -192,10 +200,14 @@ }, "scene": { "welcome": { - "slogan": "Las redes sociales\nde nuevo en tus manos." + "slogan": "Las redes sociales\nde nuevo en tus manos.", + "get_started": "Empezar", + "log_in": "Iniciar sesión" }, "server_picker": { "title": "Elige un servidor,\ncualquier servidor.", + "subtitle": "Elige una comunidad relacionada con tus intereses, con tu región o una más genérica.", + "subtitle_extend": "Elige una comunidad relacionada con tus intereses, con tu región o una más genérica. Cada comunidad está operada por una organización o individuo completamente independiente.", "button": { "category": { "all": "Todas", @@ -248,6 +260,12 @@ }, "password": { "placeholder": "contraseña", + "require": "Tu contraseña debe contener como mínimo:", + "character_limit": "8 caracteres", + "accessibility": { + "checked": "marcado", + "unchecked": "sin marcar" + }, "hint": "Tu contraseña necesita tener al menos ocho caracteres" }, "invite": { @@ -298,7 +316,7 @@ "subtitle": "Te acabamos de enviar un correo a %s,\npulsa en el enlace para confirmar tu cuenta.", "button": { "open_email_app": "Abrir Aplicación de Correo Electrónico", - "dont_receive_email": "No he recibido el correo electrónico" + "resend": "Reenviar" }, "dont_receive_email": { "title": "Revisa tu correo electrónico", @@ -401,15 +419,25 @@ "segmented_control": { "posts": "Publicaciones", "replies": "Respuestas", - "media": "Multimedia" + "posts_and_replies": "Publicaciones y respuestas", + "media": "Multimedia", + "about": "Acerca de" }, "relationship_action_alert": { + "confirm_mute_user": { + "title": "Silenciar cuenta", + "message": "Confirmar para silenciar %s" + }, "confirm_unmute_user": { "title": "Dejar de Silenciar Cuenta", "message": "Confirmar para dejar de silenciar a %s" }, - "confirm_unblock_usre": { - "title": "Desbloquear Cuenta", + "confirm_block_user": { + "title": "Bloquear cuenta", + "message": "Confirmar para bloquear a %s" + }, + "confirm_unblock_user": { + "title": "Desbloquear cuenta", "message": "Confirmar para desbloquear a %s" } } @@ -461,12 +489,14 @@ "Everything": "Todo", "Mentions": "Menciones" }, - "user_followed_you": "%s te ha empezado a seguir", - "user_favorited your post": "%s marcó tu post como favorito", - "user_reblogged_your_post": "%s reblogueó tu publicación", - "user_mentioned_you": "%s te ha mencionado", - "user_requested_to_follow_you": "%s ha solicitado seguirte", - "user_your_poll_has_ended": "%s Tu encuesta ha terminado", + "notification_description": { + "followed_you": "te siguió", + "favorited_your_post": "ha marcado como favorita tu publicación", + "reblogged_your_post": "reblogueó tu publicación", + "mentioned_you": "te mencionó", + "request_to_follow_you": "solicitó seguirte", + "poll_has_ended": "encuesta ha terminado" + }, "keyobard": { "show_everything": "Mostrar Todo", "show_mentions": "Mostrar Menciones" @@ -485,6 +515,13 @@ "light": "Siempre Clara", "dark": "Siempre Oscura" }, + "look_and_feel": { + "title": "Apariencia", + "use_system": "Uso del sistema", + "really_dark": "Realmente Oscuro", + "sorta_dark": "Más o Menos Oscuro", + "light": "Claro" + }, "notifications": { "title": "Notificaciones", "favorites": "Marque como favorita mi publicación", @@ -526,14 +563,17 @@ } }, "report": { + "title_report": "Reportar", "title": "Reportar %s", "step1": "Paso 1 de 2", "step2": "Paso 2 de 2", "content1": "¿Hay alguna otra publicación que te gustaría añadir al reporte?", "content2": "¿Hay algo que los moderadores deberían saber acerca de este reporte?", + "report_sent_title": "Gracias por reportar, estudiaremos esto.", "send": "Enviar Reporte", "skip_to_send": "Enviar sin comentarios", - "text_placeholder": "Escribe o pega comentarios adicionales" + "text_placeholder": "Escribe o pega comentarios adicionales", + "reported": "REPORTADO" }, "preview": { "keyboard": { @@ -543,14 +583,14 @@ } }, "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" + "tab_bar_hint": "Perfil seleccionado actualmente: %s. Haz un doble toque y mantén pulsado para mostrar el selector de cuentas", + "dismiss_account_switcher": "Descartar el selector de cuentas", + "add_account": "Añadir cuenta" }, "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" + "new_in_mastodon": "Nuevo en Mastodon", + "multiple_account_switch_intro_description": "Cambie entre varias cuentas manteniendo presionado el botón de perfil.", + "accessibility_hint": "Haz doble toque para descartar este asistente" } } } \ No newline at end of file diff --git a/Localization/StringsConvertor/input/eu_ES/Localizable.stringsdict b/Localization/StringsConvertor/input/eu_ES/Localizable.stringsdict new file mode 100644 index 000000000..817e8372b --- /dev/null +++ b/Localization/StringsConvertor/input/eu_ES/Localizable.stringsdict @@ -0,0 +1,390 @@ +<?xml version="1.0" encoding="utf-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> + <dict> + <key>a11y.plural.count.unread.notification</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@notification_count_unread_notification@</string> + <key>notification_count_unread_notification</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>Irakurri gabeko jakinarazpen bat</string> + <key>other</key> + <string>Irakurri gabeko %ld jakinarazpen</string> + </dict> + </dict> + <key>a11y.plural.count.input_limit_exceeds</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>Sarrerak %#@character_count@ karaktereko muga gainditzen du</string> + <key>character_count</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>karaktere bat</string> + <key>other</key> + <string>%ld karaktere</string> + </dict> + </dict> + <key>a11y.plural.count.input_limit_remains</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>Sarreraren karaktere muga %#@character_count@ da oraindik</string> + <key>character_count</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>karaktere bat</string> + <key>other</key> + <string>%ld karaktere</string> + </dict> + </dict> + <key>plural.count.metric_formatted.post</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%@ %#@post_count@</string> + <key>post_count</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>bidalketa</string> + <key>other</key> + <string>bidalketa</string> + </dict> + </dict> + <key>plural.count.post</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@post_count@</string> + <key>post_count</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>Bidalketa bat</string> + <key>other</key> + <string>%ld bidalketa</string> + </dict> + </dict> + <key>plural.count.favorite</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@favorite_count@</string> + <key>favorite_count</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>Gogoko bat</string> + <key>other</key> + <string>%ld gogoko</string> + </dict> + </dict> + <key>plural.count.reblog</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@reblog_count@</string> + <key>reblog_count</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>Bultzada bat</string> + <key>other</key> + <string>%ld bultzada</string> + </dict> + </dict> + <key>plural.count.vote</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@vote_count@</string> + <key>vote_count</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>Boto bat</string> + <key>other</key> + <string>%ld boto</string> + </dict> + </dict> + <key>plural.count.voter</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@voter_count@</string> + <key>voter_count</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>Boto-emaile bat</string> + <key>other</key> + <string>%ld boto-emaile</string> + </dict> + </dict> + <key>plural.people_talking</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_people_talking@</string> + <key>count_people_talking</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>Pertsona bat hizketan</string> + <key>other</key> + <string>%ld pertsona hizketan</string> + </dict> + </dict> + <key>plural.count.following</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_following@</string> + <key>count_following</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>Bat jarraitzen</string> + <key>other</key> + <string>%ld jarraitzen</string> + </dict> + </dict> + <key>plural.count.follower</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_follower@</string> + <key>count_follower</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>Jarraitzaile bat</string> + <key>other</key> + <string>%ld jarraitzaile</string> + </dict> + </dict> + <key>date.year.left</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_year_left@</string> + <key>count_year_left</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>Urte bat geratzen da</string> + <key>other</key> + <string>%ld urte geratzen dira</string> + </dict> + </dict> + <key>date.month.left</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_month_left@</string> + <key>count_month_left</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>Hilabete bat geratzen da</string> + <key>other</key> + <string>%ld hilabete geratzen dira</string> + </dict> + </dict> + <key>date.day.left</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_day_left@</string> + <key>count_day_left</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>Egun bat geratzen da</string> + <key>other</key> + <string>%ld egun geratzen dira</string> + </dict> + </dict> + <key>date.hour.left</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_hour_left@</string> + <key>count_hour_left</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>Ordu 1 geratzen da</string> + <key>other</key> + <string>%ld ordu geratzen dira</string> + </dict> + </dict> + <key>date.minute.left</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_minute_left@</string> + <key>count_minute_left</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>Minutu 1 geratzen da</string> + <key>other</key> + <string>%ld minutu geratzen dira</string> + </dict> + </dict> + <key>date.second.left</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_second_left@</string> + <key>count_second_left</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>Segundo 1 geratzen da</string> + <key>other</key> + <string>%ld segundo geratzen dira</string> + </dict> + </dict> + <key>date.year.ago.abbr</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_year_ago_abbr@</string> + <key>count_year_ago_abbr</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>Duela urtebete</string> + <key>other</key> + <string>Duela %ld urte</string> + </dict> + </dict> + <key>date.month.ago.abbr</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_month_ago_abbr@</string> + <key>count_month_ago_abbr</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>Duela hilabete</string> + <key>other</key> + <string>Duela %ld hilabete</string> + </dict> + </dict> + <key>date.day.ago.abbr</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_day_ago_abbr@</string> + <key>count_day_ago_abbr</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>Duela egun bat</string> + <key>other</key> + <string>Duela %ld egun</string> + </dict> + </dict> + <key>date.hour.ago.abbr</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_hour_ago_abbr@</string> + <key>count_hour_ago_abbr</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>Duela ordubete</string> + <key>other</key> + <string>Duela %ld ordu</string> + </dict> + </dict> + <key>date.minute.ago.abbr</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_minute_ago_abbr@</string> + <key>count_minute_ago_abbr</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>Duela minutu bat</string> + <key>other</key> + <string>Duela %ld minutu</string> + </dict> + </dict> + <key>date.second.ago.abbr</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_second_ago_abbr@</string> + <key>count_second_ago_abbr</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>Duela segundo bat</string> + <key>other</key> + <string>Duela %ld segundo</string> + </dict> + </dict> + </dict> +</plist> diff --git a/Localization/StringsConvertor/input/eu_ES/app.json b/Localization/StringsConvertor/input/eu_ES/app.json new file mode 100644 index 000000000..39d06227b --- /dev/null +++ b/Localization/StringsConvertor/input/eu_ES/app.json @@ -0,0 +1,596 @@ +{ + "common": { + "alerts": { + "common": { + "please_try_again": "Mesedez, saiatu berriro.", + "please_try_again_later": "Mesedez beranduago saiatu." + }, + "sign_up_failure": { + "title": "Hutsegitea izen-ematean" + }, + "server_error": { + "title": "Zerbitzari-errorea" + }, + "vote_failure": { + "title": "Hutsegitea botoa ematean", + "poll_ended": "Inkesta amaitu da" + }, + "discard_post_content": { + "title": "Baztertu zirriborroa", + "message": "Berretsi idatzitako bidalketaren edukia baztertzea." + }, + "publish_post_failure": { + "title": "Hutsegitea argitaratzean", + "message": "Huts egin du bidalketa argitaratzean.\nEgiaztatu Interneteko konexioa.", + "attachments_message": { + "video_attach_with_photo": "Ezin da irudiak dituen bidalketa batean bideo bat erantsi.", + "more_than_one_video": "Ezin da bideo bat baino gehiago erantsi." + } + }, + "edit_profile_failure": { + "title": "Errorea profila editatzean", + "message": "Ezin da profila editatu. Mesedez saiatu berriro." + }, + "sign_out": { + "title": "Amaitu saioa", + "message": "Ziur saioa amaitu nahi duzula?", + "confirm": "Amaitu saioa" + }, + "block_domain": { + "title": "Ziur, erabat ziur, %s domeinu osoa blokeatu nahi duzula? Gehienetan erabiltzaile gutxi batzuk blokeatu edo mututzearekin nahikoa da. Ez duzu domeinu horretako edukirik ikusiko eta domeinu horretako zure jarraitzaileak kenduko dira.", + "block_entire_domain": "Blokeatu domeinua" + }, + "save_photo_failure": { + "title": "Hutsegitea argazkia gordetzean", + "message": "Gaitu argazki galeriarako sarbidearen baimena argazkia gordetzeko." + }, + "delete_post": { + "title": "Ziur zaude bidalketa hau ezabatu nahi duzula?", + "message": "Ziur bidalketa hau ezabatu nahi duzula?" + }, + "clean_cache": { + "title": "Garbitu cache-a", + "message": "Behar bezala garbitu da %s cache-a." + } + }, + "controls": { + "actions": { + "back": "Atzera", + "next": "Hurrengoa", + "previous": "Aurrekoa", + "open": "Ireki", + "add": "Gehitu", + "remove": "Kendu", + "edit": "Editatu", + "save": "Gorde", + "ok": "Ados", + "done": "Egina", + "confirm": "Berretsi", + "continue": "Jarraitu", + "compose": "Idatzi", + "cancel": "Utzi", + "discard": "Baztertu", + "try_again": "Saiatu berriro", + "take_photo": "Atera argazkia", + "save_photo": "Gorde argazkia", + "copy_photo": "Kopiatu argazkia", + "sign_in": "Hasi saioa", + "sign_up": "Eman Izena", + "see_more": "Ikusi gehiago", + "preview": "Aurrebista", + "share": "Partekatu", + "share_user": "Partekatu %s", + "share_post": "Partekatu bidalketa", + "open_in_safari": "Ireki Safarin", + "open_in_browser": "Ireki nabigatzailean", + "find_people": "Bilatu jarraitzeko jendea", + "manually_search": "Eskuz bilatu", + "skip": "Saltatu", + "reply": "Erantzun", + "report_user": "Salatu %s", + "block_domain": "Blokeatu %s", + "unblock_domain": "Desblokeatu %s", + "settings": "Ezarpenak", + "delete": "Ezabatu" + }, + "tabs": { + "home": "Hasiera", + "search": "Bilatu", + "notification": "Jakinarazpena", + "profile": "Profila" + }, + "keyboard": { + "common": { + "switch_to_tab": "Aldatu %s(e)ra", + "compose_new_post": "Idatzi bidalketa berria", + "show_favorites": "Erakutsi gogokoak", + "open_settings": "Ireki ezarpenak" + }, + "timeline": { + "previous_status": "Aurreko bidalketa", + "next_status": "Hurrengo bidalketa", + "open_status": "Ireki bidalketa", + "open_author_profile": "Ireki egilearen profila", + "open_reblogger_profile": "Ireki bultzada eman duenaren profila", + "reply_status": "Erantzun bidalketari", + "toggle_reblog": "Txandakatu bidalketaren bultzada", + "toggle_favorite": "Txandakatu bidalketa gogoko egitea", + "toggle_content_warning": "Txandakatu edukiaren abisua", + "preview_image": "Aurreikusi irudia" + }, + "segmented_control": { + "previous_section": "Aurreko sekzioa", + "next_section": "Hurrengo sekzioa" + } + }, + "status": { + "user_reblogged": "%s erabiltzaileak bultzada eman dio", + "user_replied_to": "%s(r)i erantzuten", + "show_post": "Erakutsi bidalketa", + "show_user_profile": "Erakutsi erabiltzailearen profila", + "content_warning": "Edukiaren abisua", + "media_content_warning": "Ukitu edonon bistaratzeko", + "poll": { + "vote": "Bozkatu", + "closed": "Itxita" + }, + "actions": { + "reply": "Erantzun", + "reblog": "Bultzada", + "unreblog": "Desegin bultzada", + "favorite": "Gogokoa", + "unfavorite": "Kendu gogokoa", + "menu": "Menua", + "hide": "Ezkutatu" + }, + "tag": { + "url": "URLa", + "mention": "Aipatu", + "link": "Esteka", + "hashtag": "Traola", + "email": "Eposta", + "emoji": "Emojia" + }, + "visibility": { + "unlisted": "Edozeinek ikusi dezake bidalketa hau baina ez da denbora-lerro publikoan bistaratuko.", + "private": "Beren jarraitzaileek soilik ikus dezakete bidalketa hau.", + "private_from_me": "Nire jarraitzaileek soilik ikus dezakete bidalketa hau.", + "direct": "Aipatutako erabiltzaileek soilik ikus dezakete bidalketa hau." + } + }, + "friendship": { + "follow": "Jarraitu", + "following": "Jarraitzen", + "request": "Eskaera", + "pending": "Zain", + "block": "Blokeatu", + "block_user": "Blokeatu %s", + "block_domain": "Blokeatu %s", + "unblock": "Desblokeatu", + "unblock_user": "Desblokeatu %s", + "blocked": "Blokeatuta", + "mute": "Mututu", + "mute_user": "Mututu %s", + "unmute": "Desmututu", + "unmute_user": "Desmututu %s", + "muted": "Mutututa", + "edit_info": "Editatu informazioa" + }, + "timeline": { + "filtered": "Iragazita", + "timestamp": { + "now": "Orain" + }, + "loader": { + "load_missing_posts": "Kargatu falta diren bidalketak", + "loading_missing_posts": "Falta diren bidalketak kargatzen...", + "show_more_replies": "Erakutsi erantzun gehiago" + }, + "header": { + "no_status_found": "Ez da bidalketa aurkitu", + "blocking_warning": "Ezin duzu erabiltzaile honen profila ikusi\ndesblokeatzen duzun arte.\nZure profilak itxura hau du berarentzat.", + "user_blocking_warning": "Ezin duzu %s erabiltzailearen\nprofila ikusi desblokeatzen duzun arte.\nZure profilak itxura hau du berarentzat.", + "blocked_warning": "Ezin duzu erabiltzaile honen profila ikusi\ndesblokeatzen zaituen arte.", + "user_blocked_warning": "Ezin duzu %s erabiltzailearen\nprofila ikusi desblokeatzen zaituen arte.", + "suspended_warning": "Erabiltzaile hau kanporatua izan da.", + "user_suspended_warning": "%s kontua kanporatua izan da." + } + } + } + }, + "scene": { + "welcome": { + "slogan": "Sare sozialak\nberriz zure eskuetan.", + "get_started": "Nola hasi", + "log_in": "Hasi saioa" + }, + "server_picker": { + "title": "Aukeratu zerbitzari bat,\nedozein zerbitzari.", + "subtitle": "Aukeratu komunitate bat zure interes edo lurraldearen arabera, edo erabilera orokorreko bat.", + "subtitle_extend": "Aukeratu komunitate bat zure interes edo lurraldearen arabera, edo erabilera orokorreko bat. Komunitate bakoitza erakunde edo norbanako independente batek kudeatzen du.", + "button": { + "category": { + "all": "Guztiak", + "all_accessiblity_description": "Kategoria: Guztiak", + "academia": "akademia", + "activism": "aktibismoa", + "food": "janaria", + "furry": "furry", + "games": "jokoak", + "general": "orokorra", + "journalism": "kazetaritza", + "lgbt": "LGBTQ+", + "regional": "herrialdekoa", + "art": "artea", + "music": "musika", + "tech": "teknologia" + }, + "see_less": "Ikusi gutxiago", + "see_more": "Ikusi gehiago" + }, + "label": { + "language": "HIZKUNTZA", + "users": "ERABILTZAILEAK", + "category": "KATEGORIA" + }, + "input": { + "placeholder": "Bilatu zerbitzari bat edo sortu zurea..." + }, + "empty_state": { + "finding_servers": "Erabilgarri dauden zerbitzariak bilatzen...", + "bad_network": "Arazoren bat egon da datuak kargatzean. Egiaztatu zure Interneteko konexioa.", + "no_results": "Emaitzarik ez" + } + }, + "register": { + "title": "Hitz egin iezaguzu zuri buruz.", + "input": { + "avatar": { + "delete": "Ezabatu" + }, + "username": { + "placeholder": "erabiltzaile-izena", + "duplicate_prompt": "Erabiltzaile-izen hau hartuta dago." + }, + "display_name": { + "placeholder": "pantaila-izena" + }, + "email": { + "placeholder": "eposta" + }, + "password": { + "placeholder": "pasahitza", + "require": "Zure pasahitzak izan behar ditu gutxienez:", + "character_limit": "8 karaktere", + "accessibility": { + "checked": "hautatuta", + "unchecked": "hautatu gabe" + }, + "hint": "Pasahitzak zortzi karaktere izan behar ditu gutxienez" + }, + "invite": { + "registration_user_invite_request": "Zergatik elkartu nahi duzu?" + } + }, + "error": { + "item": { + "username": "Erabiltzaile-izena", + "email": "Eposta", + "password": "Pasahitza", + "agreement": "Adostasuna", + "locale": "Eskualdeko ezarpenak", + "reason": "Arrazoia" + }, + "reason": { + "blocked": "%s(e)k onartu gabeko eposta hornitzaile bat erabiltzen du", + "unreachable": "dirudienez %s ez da existitzen", + "taken": "%s dagoeneko erabiltzen da", + "reserved": "%s gako-hitz erreserbatu bat da", + "accepted": "%s onartu behar da", + "blank": "%s beharrezkoa da", + "invalid": "%s baliogabea da", + "too_long": "%s luzeegia da", + "too_short": "%s laburregia da", + "inclusion": "%s ez da onartutako balio bat" + }, + "special": { + "username_invalid": "Erabiltzaile-izenak karaktere alfanumerikoak eta azpimarrak soilik eduki ditzake", + "username_too_long": "Erabiltzaile-izena luzeegia da (ezin ditu 30 karaktere baino gehiago izan)", + "email_invalid": "Hau ez da baliozko eposta helbidea", + "password_too_short": "Pasahitza laburregia da (gutxienez 8 karaktere izan behar ditu)" + } + } + }, + "server_rules": { + "title": "Oinarrizko arau batzuk.", + "subtitle": "Arau hauek %s instantziako administratzaileek ezarri dituzte.", + "prompt": "Jarraituz gero, %s instantziaren zerbitzu-baldintzak eta pribatutasun-gidalerroak onartzen dituzu.", + "terms_of_service": "zerbitzu-baldintzak", + "privacy_policy": "pribatutasun-gidalerroak", + "button": { + "confirm": "Ados nago" + } + }, + "confirm_email": { + "title": "Eta azkenik...", + "subtitle": "Eposta bat bidali dizugu %s helbidera,\nsakatu kontua berresteko esteka.", + "button": { + "open_email_app": "Ireki eposta aplikazioa", + "resend": "Berbidali" + }, + "dont_receive_email": { + "title": "Begiratu zure eposta", + "description": "Egiaztatu zure eposta helbidea zuzena den eta begiratu zaborraren karpeta.", + "resend_email": "Birbidali eposta" + }, + "open_email_app": { + "title": "Egiaztatu zure sarrerako ontzia.", + "description": "Eposta bat bidali dizugu. Egiaztatu zure zaborraren karpeta.", + "mail": "Posta", + "open_email_client": "Ireki eposta bezeroa" + } + }, + "home_timeline": { + "title": "Hasiera", + "navigation_bar_state": { + "offline": "Konexio gabe", + "new_posts": "Ikusi bidal. berriak", + "published": "Argitaratua!", + "Publishing": "Bidalketa argitaratzen..." + } + }, + "suggestion_account": { + "title": "Bilatu jarraitzeko jendea", + "follow_explain": "Norbait jarraitzen duzunean, bere bidalketak zure hasierako denbora-lerroan agertuko zaizkizu." + }, + "compose": { + "title": { + "new_post": "Bidalketa berria", + "new_reply": "Erantzun berria" + }, + "media_selection": { + "camera": "Atera argazkia", + "photo_library": "Argazki-liburutegia", + "browse": "Arakatu" + }, + "content_input_placeholder": "Idatzi edo itsatsi buruan duzuna", + "compose_action": "Argitaratu", + "replying_to_user": "%s(r)i erantzuten", + "attachment": { + "photo": "argazkia", + "video": "bideoa", + "attachment_broken": "%s hondatuta dago eta ezin da\nMastodonera igo.", + "description_photo": "Deskribatu argazkia ikusmen arazoak dituztenentzat...", + "description_video": "Deskribatu bideoa ikusmen arazoak dituztenentzat..." + }, + "poll": { + "duration_time": "Iraupena: %s", + "thirty_minutes": "30 minutu", + "one_hour": "Ordu 1", + "six_hours": "6 ordu", + "one_day": "Egun 1", + "three_days": "3 egun", + "seven_days": "7 egun", + "option_number": "%ld aukera" + }, + "content_warning": { + "placeholder": "Idatzi abisu zehatz bat hemen..." + }, + "visibility": { + "public": "Publikoa", + "unlisted": "Zerrendatu gabea", + "private": "Jarraitzaileak soilik", + "direct": "Aipatzen dudan jendea soilik" + }, + "auto_complete": { + "space_to_add": "Sakatu zuriunea gehitzeko" + }, + "accessibility": { + "append_attachment": "Gehitu eranskina", + "append_poll": "Gehitu inkesta", + "remove_poll": "Kendu inkesta", + "custom_emoji_picker": "Emoji pertsonalizatuen hautatzailea", + "enable_content_warning": "Gaitu edukiaren abisua", + "disable_content_warning": "Desgaitu edukiaren abisua", + "post_visibility_menu": "Bidalketaren ikusgaitasunaren menua" + }, + "keyboard": { + "discard_post": "Baztertu bidalketa", + "publish_post": "Argitaratu bidalketa", + "toggle_poll": "Txandakatu inkesta", + "toggle_content_warning": "Txandakatu edukiaren abisua", + "append_attachment_entry": "Gehitu eranskina - %s", + "select_visibility_entry": "Hautatu ikusgaitasuna - %s" + } + }, + "profile": { + "dashboard": { + "posts": "bidalketa", + "following": "jarraitzen", + "followers": "jarraitzaile" + }, + "fields": { + "add_row": "Gehitu errenkada", + "placeholder": { + "label": "Etiketa", + "content": "Edukia" + } + }, + "segmented_control": { + "posts": "Bidalketak", + "replies": "Erantzunak", + "posts_and_replies": "Bidalketak eta erantzunak", + "media": "Multimedia", + "about": "Honi buruz" + }, + "relationship_action_alert": { + "confirm_mute_user": { + "title": "Mututu kontua", + "message": "Berretsi %s mututzea" + }, + "confirm_unmute_user": { + "title": "Desmututu kontua", + "message": "Berretsi %s desmututzea" + }, + "confirm_block_user": { + "title": "Blokeatu kontua", + "message": "Berretsi %s blokeatzea" + }, + "confirm_unblock_user": { + "title": "Desblokeatu kontua", + "message": "Berretsi %s desblokeatzea" + } + } + }, + "follower": { + "footer": "Beste zerbitzarietako jarraitzaileak ez dira bistaratzen." + }, + "following": { + "footer": "Beste zerbitzarietan jarraitutakoak ez dira bistaratzen." + }, + "search": { + "title": "Bilatu", + "search_bar": { + "placeholder": "Bilatu traolak eta erabiltzaileak", + "cancel": "Utzi" + }, + "recommend": { + "button_text": "Ikusi guztiak", + "hash_tag": { + "title": "Mastodoneko joerak", + "description": "Deigarri gertatzen ari diren traolak", + "people_talking": "%s pertsona hizketan" + }, + "accounts": { + "title": "Gustuko izan ditzakezun kontuak", + "description": "Kontu hauek jarraitu nahiko dituzu behar bada", + "follow": "Jarraitu" + } + }, + "searching": { + "segment": { + "all": "Guztiak", + "people": "Jendea", + "hashtags": "Traolak", + "posts": "Bidalketak" + }, + "empty_state": { + "no_results": "Emaitzarik ez" + }, + "recent_search": "Azken bilaketak", + "clear": "Garbitu" + } + }, + "favorite": { + "title": "Zure gogokoak" + }, + "notification": { + "title": { + "Everything": "Dena", + "Mentions": "Aipamenak" + }, + "notification_description": { + "followed_you": "zu jarraitzen hasi da", + "favorited_your_post": "erabiltzaileak zure bidalketa gogoko du", + "reblogged_your_post": "erabiltzaileak bultzada eman dio zure bidalketari", + "mentioned_you": "erabiltzaileak aipatu zaitu", + "request_to_follow_you": "erabiltzaileak zu jarraitzea eskatu du", + "poll_has_ended": "inkesta amaitu da" + }, + "keyobard": { + "show_everything": "Erakutsi guztia", + "show_mentions": "Erakutsi aipamenak" + } + }, + "thread": { + "back_title": "Bidalketa", + "title": "%s(e)n bidalketa" + }, + "settings": { + "title": "Ezarpenak", + "section": { + "appearance": { + "title": "Itxura", + "automatic": "Automatikoa", + "light": "Beti argia", + "dark": "Beti iluna" + }, + "look_and_feel": { + "title": "Itxura", + "use_system": "Erabili sistemakoa", + "really_dark": "Oso iluna", + "sorta_dark": "Ilun antzekoa", + "light": "Argia" + }, + "notifications": { + "title": "Jakinarazpenak", + "favorites": "Nire bidalketa gogoko egitean", + "follows": "Jarraitzen nau", + "boosts": "Nire bidalketa bultzatu du", + "mentions": "Aipatu nau", + "trigger": { + "anyone": "edozein", + "follower": "jarraitzaile bat", + "follow": "jarraitzen dudan edonor", + "noone": "inor ez", + "title": "Noiz jakinarazi:" + } + }, + "preference": { + "title": "Hobespenak", + "true_black_dark_mode": "Benetako modu beltz iluna", + "disable_avatar_animation": "Desgaitu abatar animatuak", + "disable_emoji_animation": "Desgaitu emoji animatuak", + "using_default_browser": "Erabili nabigatzaile lehenetsia estekak irekitzeko" + }, + "boring_zone": { + "title": "Eremu aspergarria", + "account_settings": "Kontuaren ezarpenak", + "terms": "Zerbitzu-baldintzak", + "privacy": "Pribatutasun-gidalerroak" + }, + "spicy_zone": { + "title": "Eremu beroa", + "clear": "Garbitu multimediaren cachea", + "signout": "Amaitu saioa" + } + }, + "footer": { + "mastodon_description": "Mastodon software librea da. Arazoen berri eman dezakezu GitHub bidez: %s (%s)" + }, + "keyboard": { + "close_settings_window": "Itxi ezarpenen leihoa" + } + }, + "report": { + "title_report": "Salatu", + "title": "Salatu %s", + "step1": "1. urratsa 2tik", + "step2": "2. urratsa 2tik", + "content1": "Salaketan beste bidalketarik gehitu nahi duzu?", + "content2": "Moderatzaileek besterik jakin behar dute salaketa honi buruz?", + "report_sent_title": "Mila esker salaketagatik, berrikusiko dugu.", + "send": "Bidali salaketa", + "skip_to_send": "Bidali iruzkinik gabe", + "text_placeholder": "Idatzi edo itsatsi iruzkin gehigarriak", + "reported": "SALATUA" + }, + "preview": { + "keyboard": { + "close_preview": "Itxi aurrebista", + "show_next": "Erakutsi hurrengoa", + "show_previous": "Erakutsi aurrekoa" + } + }, + "account_list": { + "tab_bar_hint": "Unean hautatutako profila: %s. Ukitu birritan, ondoren eduki sakatuta kontu-aldatzailea erakusteko", + "dismiss_account_switcher": "Baztertu kontu-aldatzailea", + "add_account": "Gehitu kontua" + }, + "wizard": { + "new_in_mastodon": "Berria Mastodonen", + "multiple_account_switch_intro_description": "Aldatu hainbat konturen artean profilaren botoia sakatuta edukiz.", + "accessibility_hint": "Ukitu birritan morroi hau baztertzeko" + } + } +} \ No newline at end of file diff --git a/Localization/StringsConvertor/input/eu_ES/ios-infoPlist.json b/Localization/StringsConvertor/input/eu_ES/ios-infoPlist.json new file mode 100644 index 000000000..bc0457eab --- /dev/null +++ b/Localization/StringsConvertor/input/eu_ES/ios-infoPlist.json @@ -0,0 +1,6 @@ +{ + "NSCameraUsageDescription": "Bidalketetarako argazkiak ateratzeko erabiltzen da", + "NSPhotoLibraryAddUsageDescription": "Argazkiak Argazki-liburutegian gordetzeko erabiltzen da", + "NewPostShortcutItemTitle": "Bidalketa berria", + "SearchShortcutItemTitle": "Bilatu" +} diff --git a/Localization/StringsConvertor/input/fr_FR/Localizable.stringsdict b/Localization/StringsConvertor/input/fr_FR/Localizable.stringsdict index 4a912e4b3..37f07e67a 100644 --- a/Localization/StringsConvertor/input/fr_FR/Localizable.stringsdict +++ b/Localization/StringsConvertor/input/fr_FR/Localizable.stringsdict @@ -37,7 +37,7 @@ <key>a11y.plural.count.input_limit_remains</key> <dict> <key>NSStringLocalizedFormatKey</key> - <string>Input limit remains %#@character_count@</string> + <string>La limite d'entrée reste %#@character_count@</string> <key>character_count</key> <dict> <key>NSStringFormatSpecTypeKey</key> diff --git a/Localization/StringsConvertor/input/fr_FR/app.json b/Localization/StringsConvertor/input/fr_FR/app.json index dd834928a..9941ff99e 100644 --- a/Localization/StringsConvertor/input/fr_FR/app.json +++ b/Localization/StringsConvertor/input/fr_FR/app.json @@ -46,7 +46,7 @@ }, "delete_post": { "title": "Voulez-vous vraiment supprimer ce message ?", - "delete": "Supprimer" + "message": "Voulez-vous vraiment supprimer ce message ?" }, "clean_cache": { "title": "Vider le cache", @@ -67,7 +67,7 @@ "done": "Terminé", "confirm": "Confirmer", "continue": "Continuer", - "compose": "Compose", + "compose": "Rédiger", "cancel": "Annuler", "discard": "Abandonner", "try_again": "Réessayer", @@ -82,6 +82,7 @@ "share_user": "Partager %s", "share_post": "Partager la publication", "open_in_safari": "Ouvrir dans Safari", + "open_in_browser": "Ouvrir dans le navigateur", "find_people": "Trouver des personnes à suivre", "manually_search": "Rechercher manuellement à la place", "skip": "Passer", @@ -139,7 +140,8 @@ "unreblog": "Annuler le reblog", "favorite": "Favori", "unfavorite": "Retirer des favoris", - "menu": "Menu" + "menu": "Menu", + "hide": "Cacher" }, "tag": { "url": "URL", @@ -148,6 +150,12 @@ "hashtag": "Hashtag", "email": "Courriel", "emoji": "Émoji" + }, + "visibility": { + "unlisted": "Tout le monde peut voir ce message mais ne sera pas affiché sur le fil public.", + "private": "Seul·e·s leurs abonné·e·s peuvent voir ce message.", + "private_from_me": "Seul·e·s mes abonné·e·s peuvent voir ce message.", + "direct": "Seul·e l’utilisateur·rice mentionnée peut voir ce message." } }, "friendship": { @@ -192,10 +200,14 @@ }, "scene": { "welcome": { - "slogan": "Le réseau social qui vous rend le contrôle." + "slogan": "Le réseau social qui vous rend le contrôle.", + "get_started": "Prise en main", + "log_in": "Se connecter" }, "server_picker": { "title": "Choisissez un serveur,\nn'importe quel serveur.", + "subtitle": "Choisissez une communauté en fonction de vos intérêts, de votre région ou de votre objectif général.", + "subtitle_extend": "Choisissez une communauté basée sur vos intérêts, votre région ou un but général. Chaque communauté est gérée par une organisation ou un individu entièrement indépendant.", "button": { "category": { "all": "Tout", @@ -248,6 +260,12 @@ }, "password": { "placeholder": "mot de passe", + "require": "Votre mot de passe doit être composé d’au moins :", + "character_limit": "8 caractères", + "accessibility": { + "checked": "vérifié", + "unchecked": "non vérifié" + }, "hint": "Votre mot de passe doit contenir au moins 8 caractères" }, "invite": { @@ -298,7 +316,7 @@ "subtitle": "Nous venons d’envoyer un courriel à %s,\ntapotez le lien pour confirmer votre compte.", "button": { "open_email_app": "Ouvrir l’application de courriel", - "dont_receive_email": "Je n’ai jamais reçu de courriel" + "resend": "Renvoyer" }, "dont_receive_email": { "title": "Vérifier vos courriels", @@ -401,14 +419,24 @@ "segmented_control": { "posts": "Publications", "replies": "Réponses", - "media": "Média" + "posts_and_replies": "Messages et réponses", + "media": "Média", + "about": "À propos" }, "relationship_action_alert": { + "confirm_mute_user": { + "title": "Masquer le compte", + "message": "Êtes-vous sûr de vouloir mettre en sourdine %s" + }, "confirm_unmute_user": { "title": "Ne plus mettre en sourdine ce compte", "message": "Êtes-vous sûr de vouloir désactiver la sourdine de %s" }, - "confirm_unblock_usre": { + "confirm_block_user": { + "title": "Bloquer le compte", + "message": "Confirmer le blocage de %s" + }, + "confirm_unblock_user": { "title": "Débloquer le compte", "message": "Confirmer le déblocage de %s" } @@ -418,7 +446,7 @@ "footer": "Les abonné·e·s issus des autres serveurs ne sont pas affiché·e·s." }, "following": { - "footer": "Follows from other servers are not displayed." + "footer": "Les abonnés issus des autres serveurs ne sont pas affichés." }, "search": { "title": "Rechercher", @@ -461,12 +489,14 @@ "Everything": "Tout", "Mentions": "Mentions" }, - "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é", + "notification_description": { + "followed_you": "s’est abonné à vous", + "favorited_your_post": "a ajouté votre message à ses favoris", + "reblogged_your_post": "a partagé votre message", + "mentioned_you": "vous a mentionné", + "request_to_follow_you": "vous a envoyé une demande d’abonnement", + "poll_has_ended": "le sondage est terminé" + }, "keyobard": { "show_everything": "Tout Afficher", "show_mentions": "Afficher les mentions" @@ -485,6 +515,13 @@ "light": "Toujours claire", "dark": "Toujours sombre" }, + "look_and_feel": { + "title": "Apparence", + "use_system": "Utiliser le thème du système", + "really_dark": "Très sombre", + "sorta_dark": "Légèrement sombre", + "light": "Clair" + }, "notifications": { "title": "Notifications", "favorites": "Ajoute l’une de mes publications à ses favoris", @@ -526,14 +563,17 @@ } }, "report": { + "title_report": "Signalement", "title": "Signaler %s", "step1": "Étape 1 de 2", "step2": "Étape 2 de 2", "content1": "Y a-t-il d’autres messages que vous aimeriez ajouter au signalement?", "content2": "Y a-t-il quelque chose que les modérateurs devraient savoir sur ce rapport ?", + "report_sent_title": "Merci de nous l’avoir signalé, nous allons examiner cela.", "send": "Envoyer le rapport", "skip_to_send": "Envoyer sans commentaire", - "text_placeholder": "Tapez ou collez des informations supplémentaires" + "text_placeholder": "Tapez ou collez des informations supplémentaires", + "reported": "SIGNALÉ" }, "preview": { "keyboard": { @@ -543,8 +583,8 @@ } }, "account_list": { - "tab_bar_hint": "Current selected profile: %s. Double tap then hold to show account switcher", - "dismiss_account_switcher": "Dismiss Account Switcher", + "tab_bar_hint": "Profil sélectionné actuel: %s. Double appui puis maintenez enfoncé pour afficher le changement de compte", + "dismiss_account_switcher": "Rejeter le commutateur de compte", "add_account": "Ajouter un compte" }, "wizard": { diff --git a/Localization/StringsConvertor/input/gd_GB/app.json b/Localization/StringsConvertor/input/gd_GB/app.json index b5c66f8f6..520293d40 100644 --- a/Localization/StringsConvertor/input/gd_GB/app.json +++ b/Localization/StringsConvertor/input/gd_GB/app.json @@ -46,7 +46,7 @@ }, "delete_post": { "title": "A bheil thu cinnteach gu bheil thu airson am post seo a sguabadh às?", - "delete": "Sguab às" + "message": "A bheil thu cinnteach gu bheil thu airson am post seo a sguabadh às?" }, "clean_cache": { "title": "Falamhaich an tasgadan", @@ -82,6 +82,7 @@ "share_user": "Co-roinn %s", "share_post": "Co-roinn am post", "open_in_safari": "Fosgail ann an Safari", + "open_in_browser": "Fosgail sa bhrabhsair", "find_people": "Lorg daoine a leanas tu", "manually_search": "Lorg a làimh ’na àite", "skip": "Leum thairis air", @@ -139,7 +140,8 @@ "unreblog": "Na brosnaich tuilleadh", "favorite": "Cuir ris na h-annsachdan", "unfavorite": "Thoir air falbh o na h-annsachdan", - "menu": "Clàr-taice" + "menu": "Clàr-taice", + "hide": "Falaich" }, "tag": { "url": "URL", @@ -148,6 +150,12 @@ "hashtag": "Taga hais", "email": "Post-d", "emoji": "Emoji" + }, + "visibility": { + "unlisted": "Chì a h-uile duine am post seo ach cha nochd e air an loidhne-ama phoblach.", + "private": "Chan fhaic ach an luchd-leantainn aca am post seo.", + "private_from_me": "Chan fhaic ach an luchd-leantainn agam am post seo.", + "direct": "Chan fhaic ach an cleachdaiche air an dugadh iomradh am post seo." } }, "friendship": { @@ -192,10 +200,14 @@ }, "scene": { "welcome": { - "slogan": "A’ cur nan lìonraidhean sòisealta\n’nad làmhan fhèin." + "slogan": "A’ cur nan lìonraidhean sòisealta\n’nad làmhan fhèin.", + "get_started": "Dèan toiseach-tòiseachaidh", + "log_in": "Clàraich a-steach" }, "server_picker": { "title": "Tagh frithealaiche sam bith.", + "subtitle": "Tagh coimhearsnachd stèidhichte air d’ ùidhean no an roinn-dùthcha agad no tè choitcheann.", + "subtitle_extend": "Tagh coimhearsnachd stèidhichte air d’ ùidhean no an roinn-dùthcha agad no tè choitcheann. Tha gach coimhearsnachd ’ga stiùireadh le buidheann no neach gu neo-eisimeileach.", "button": { "category": { "all": "Na h-uile", @@ -248,6 +260,12 @@ }, "password": { "placeholder": "facal-faire", + "require": "Feumaidh am facal-faire agad co-dhiù:", + "character_limit": "8 caractaran", + "accessibility": { + "checked": "le cromag", + "unchecked": "gun chromag" + }, "hint": "Feumaidh ochd caractaran a bhith san fhacal-fhaire agad air a char as giorra" }, "invite": { @@ -298,7 +316,7 @@ "subtitle": "Tha sinn air post-d a chur gu %s,\nthoir gnogag air a’ chunntas a dhearbhadh a’ chunntais agad.", "button": { "open_email_app": "Fosgail aplacaid a’ phuist-d", - "dont_receive_email": "Cha d’ fhuair mi post-d a-riamh" + "resend": "Ath-chuir" }, "dont_receive_email": { "title": "Thoir sùil air a’ phost-d agad", @@ -401,14 +419,24 @@ "segmented_control": { "posts": "Postaichean", "replies": "Freagairtean", - "media": "Meadhanan" + "posts_and_replies": "Postaichean ’s freagairtean", + "media": "Meadhanan", + "about": "Mu dhèidhinn" }, "relationship_action_alert": { + "confirm_mute_user": { + "title": "Mùch an cunntas", + "message": "Dearbh mùchadh %s" + }, "confirm_unmute_user": { "title": "Dì-mhùch an cunntas", "message": "Dearbh dì-mhùchadh %s" }, - "confirm_unblock_usre": { + "confirm_block_user": { + "title": "Bac an cunntas", + "message": "Dearbh bacadh %s" + }, + "confirm_unblock_user": { "title": "Dì-bhac an cunntas", "message": "Dearbh dì-bhacadh %s" } @@ -461,12 +489,14 @@ "Everything": "A h-uile rud", "Mentions": "Iomraidhean" }, - "user_followed_you": "Tha %s a’ leantainn ort a-nis", - "user_favorited your post": "Is annsa le %s am post agad", - "user_reblogged_your_post": "Bhrosnaich %s am post agad", - "user_mentioned_you": "Thug %s iomradh ort", - "user_requested_to_follow_you": "Dh’iarr %s leantainn ort", - "user_your_poll_has_ended": "Crìoch cunntais-bheachd aig %s", + "notification_description": { + "followed_you": "– ’s iad ’gad leantainn a-nis", + "favorited_your_post": "– ’s iad air am post agad a chur ris na h-annsachdan aca", + "reblogged_your_post": "– ’s iad air am post agad a bhrosnachadh", + "mentioned_you": "– ’s iad air iomradh a thoirt ort", + "request_to_follow_you": "iarrtas leantainn ort", + "poll_has_ended": "thàinig cunntas-bheachd gu crìoch" + }, "keyobard": { "show_everything": "Seall a h-uile càil", "show_mentions": "Seall na h-iomraidhean" @@ -485,6 +515,13 @@ "light": "Soilleir an-còmhnaidh", "dark": "Dorcha an-còmhnaidh" }, + "look_and_feel": { + "title": "Coltas", + "use_system": "Cleachd coltas an t-siostaim", + "really_dark": "Glè dhorcha", + "sorta_dark": "Caran dorcha", + "light": "Soilleir" + }, "notifications": { "title": "Brathan", "favorites": "Nuair as annsa leotha am post agam", @@ -526,14 +563,17 @@ } }, "report": { + "title_report": "Dèan gearan", "title": "Dèan gearan mu %s", "step1": "Ceum 1 à 2", "step2": "Ceum 2 à 2", "content1": "A bheil post sam bith eile ann a bu mhiann leat cur ris a’ ghearan?", "content2": "A bheil rud sam bith ann a bu mhiann leat innse dha na maoir mun ghearan seo?", + "report_sent_title": "Mòran taing airson a’ ghearain, bheir sinn sùil air.", "send": "Cuir an gearan", "skip_to_send": "Cuir gun bheachd ris", - "text_placeholder": "Sgrìobh no cuir ann beachdan a bharrachd" + "text_placeholder": "Sgrìobh no cuir ann beachdan a bharrachd", + "reported": "CHAIDH GEARAN A DHÈANAMH" }, "preview": { "keyboard": { diff --git a/Localization/StringsConvertor/input/hi_IN/app.json b/Localization/StringsConvertor/input/hi_IN/app.json index 5c01ae7e0..ad99e178d 100644 --- a/Localization/StringsConvertor/input/hi_IN/app.json +++ b/Localization/StringsConvertor/input/hi_IN/app.json @@ -45,8 +45,8 @@ "message": "Please enable the photo library access permission to save the photo." }, "delete_post": { - "title": "Are you sure you want to delete this post?", - "delete": "Delete" + "title": "Delete Post", + "message": "Are you sure you want to delete this post?" }, "clean_cache": { "title": "Clean Cache", @@ -82,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", @@ -139,7 +140,8 @@ "unreblog": "Undo reblog", "favorite": "Favorite", "unfavorite": "Unfavorite", - "menu": "Menu" + "menu": "Menu", + "hide": "Hide" }, "tag": { "url": "URL", @@ -148,6 +150,12 @@ "hashtag": "Hashtag", "email": "Email", "emoji": "Emoji" + }, + "visibility": { + "unlisted": "Everyone can see this post but not display in the public timeline.", + "private": "Only their followers can see this post.", + "private_from_me": "Only my followers can see this post.", + "direct": "Only mentioned user can see this post." } }, "friendship": { @@ -192,10 +200,14 @@ }, "scene": { "welcome": { - "slogan": "Social networking\nback in your hands." + "slogan": "Social networking\nback in your hands.", + "get_started": "Get Started", + "log_in": "Log In" }, "server_picker": { - "title": "Pick a server,\nany server.", + "title": "Mastodon is made of users in different communities.", + "subtitle": "Pick a community based on your interests, region, or a general purpose one.", + "subtitle_extend": "Pick a community based on your interests, region, or a general purpose one. Each community is operated by an entirely independent organization or individual.", "button": { "category": { "all": "All", @@ -222,7 +234,7 @@ "category": "CATEGORY" }, "input": { - "placeholder": "Find a server or join your own..." + "placeholder": "Search communities" }, "empty_state": { "finding_servers": "Finding available servers...", @@ -231,7 +243,7 @@ } }, "register": { - "title": "Tell us about you.", + "title": "Let’s get you set up on %s", "input": { "avatar": { "delete": "Delete" @@ -248,6 +260,12 @@ }, "password": { "placeholder": "password", + "require": "Your password needs at least:", + "character_limit": "8 characters", + "accessibility": { + "checked": "checked", + "unchecked": "unchecked" + }, "hint": "Your password needs at least eight characters" }, "invite": { @@ -285,7 +303,7 @@ }, "server_rules": { "title": "Some ground rules.", - "subtitle": "These rules are set by the admins of %s.", + "subtitle": "These are set and enforced by the %s moderators.", "prompt": "By continuing, you’re subject to the terms of service and privacy policy for %s.", "terms_of_service": "terms of service", "privacy_policy": "privacy policy", @@ -295,10 +313,10 @@ }, "confirm_email": { "title": "One last thing.", - "subtitle": "We just sent an email to %s,\ntap the link to confirm your account.", + "subtitle": "Tap the link we emailed to you to verify your account.", "button": { "open_email_app": "Open Email App", - "dont_receive_email": "I never got an email" + "resend": "Resend" }, "dont_receive_email": { "title": "Check your email", @@ -401,14 +419,24 @@ "segmented_control": { "posts": "Posts", "replies": "Replies", - "media": "Media" + "posts_and_replies": "Posts and Replies", + "media": "Media", + "about": "About" }, "relationship_action_alert": { + "confirm_mute_user": { + "title": "Mute Account", + "message": "Confirm to mute %s" + }, "confirm_unmute_user": { "title": "Unmute Account", "message": "Confirm to unmute %s" }, - "confirm_unblock_usre": { + "confirm_block_user": { + "title": "Block Account", + "message": "Confirm to block %s" + }, + "confirm_unblock_user": { "title": "Unblock Account", "message": "Confirm to unblock %s" } @@ -461,12 +489,14 @@ "Everything": "Everything", "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", + "notification_description": { + "followed_you": "followed you", + "favorited_your_post": "favorited your post", + "reblogged_your_post": "reblogged your post", + "mentioned_you": "mentioned you", + "request_to_follow_you": "request to follow you", + "poll_has_ended": "poll has ended" + }, "keyobard": { "show_everything": "Show Everything", "show_mentions": "Show Mentions" @@ -485,6 +515,13 @@ "light": "Always Light", "dark": "Always Dark" }, + "look_and_feel": { + "title": "Look and Feel", + "use_system": "Use System", + "really_dark": "Really Dark", + "sorta_dark": "Sorta Dark", + "light": "Light" + }, "notifications": { "title": "Notifications", "favorites": "Favorites my post", @@ -526,14 +563,17 @@ } }, "report": { + "title_report": "Report", "title": "Report %s", "step1": "Step 1 of 2", "step2": "Step 2 of 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?", + "report_sent_title": "Thanks for reporting, we’ll look into this.", "send": "Send Report", "skip_to_send": "Send without comment", - "text_placeholder": "Type or paste additional comments" + "text_placeholder": "Type or paste additional comments", + "reported": "REPORTED" }, "preview": { "keyboard": { diff --git a/Localization/StringsConvertor/input/id_ID/app.json b/Localization/StringsConvertor/input/id_ID/app.json index 6f3171254..c6af04e08 100644 --- a/Localization/StringsConvertor/input/id_ID/app.json +++ b/Localization/StringsConvertor/input/id_ID/app.json @@ -46,7 +46,7 @@ }, "delete_post": { "title": "Apakah Anda yakin ingin menghapus postingan ini?", - "delete": "Hapus" + "message": "Are you sure you want to delete this post?" }, "clean_cache": { "title": "Bersihkan Cache", @@ -82,6 +82,7 @@ "share_user": "Bagikan %s", "share_post": "Bagikan Postingan", "open_in_safari": "Buka di Safari", + "open_in_browser": "Open in Browser", "find_people": "Cari orang untuk diikuti", "manually_search": "Manually search instead", "skip": "Lewati", @@ -139,7 +140,8 @@ "unreblog": "Undo reblog", "favorite": "Favorit", "unfavorite": "Unfavorite", - "menu": "Menu" + "menu": "Menu", + "hide": "Hide" }, "tag": { "url": "URL", @@ -148,6 +150,12 @@ "hashtag": "Tagar", "email": "Surel", "emoji": "Emoji" + }, + "visibility": { + "unlisted": "Everyone can see this post but not display in the public timeline.", + "private": "Only their followers can see this post.", + "private_from_me": "Only my followers can see this post.", + "direct": "Only mentioned user can see this post." } }, "friendship": { @@ -192,10 +200,14 @@ }, "scene": { "welcome": { - "slogan": "Social networking\nback in your hands." + "slogan": "Social networking\nback in your hands.", + "get_started": "Get Started", + "log_in": "Log In" }, "server_picker": { "title": "Pilih sebuah server,\nserver manapun.", + "subtitle": "Pick a community based on your interests, region, or a general purpose one.", + "subtitle_extend": "Pick a community based on your interests, region, or a general purpose one. Each community is operated by an entirely independent organization or individual.", "button": { "category": { "all": "Semua", @@ -222,7 +234,7 @@ "category": "KATEGORI" }, "input": { - "placeholder": "Find a server or join your own..." + "placeholder": "Search communities" }, "empty_state": { "finding_servers": "Mencari server yang tersedia...", @@ -248,6 +260,12 @@ }, "password": { "placeholder": "kata sandi", + "require": "Your password needs at least:", + "character_limit": "8 characters", + "accessibility": { + "checked": "checked", + "unchecked": "unchecked" + }, "hint": "Kata sandi Anda harus memiliki sekurang-kurangnya delapan karakter" }, "invite": { @@ -298,7 +316,7 @@ "subtitle": "Kami baru saja mengirim sebuah surel ke %s,\nketuk tautannya untuk mengkonfirmasi akun Anda.", "button": { "open_email_app": "Buka Aplikasi Surel", - "dont_receive_email": "Saya tidak mendapatkan surel" + "resend": "Resend" }, "dont_receive_email": { "title": "Periksa surel Anda", @@ -401,15 +419,25 @@ "segmented_control": { "posts": "Postingan", "replies": "Balasan", - "media": "Media" + "posts_and_replies": "Posts and Replies", + "media": "Media", + "about": "About" }, "relationship_action_alert": { + "confirm_mute_user": { + "title": "Mute Account", + "message": "Confirm to mute %s" + }, "confirm_unmute_user": { "title": "Berhenti Membisukan Akun", "message": "Confirm to unmute %s" }, - "confirm_unblock_usre": { - "title": "Berhenti Memblokir Akun", + "confirm_block_user": { + "title": "Block Account", + "message": "Confirm to block %s" + }, + "confirm_unblock_user": { + "title": "Unblock Account", "message": "Confirm to unblock %s" } } @@ -461,12 +489,14 @@ "Everything": "Segalanya", "Mentions": "Sebutan" }, - "user_followed_you": "%s mengikuti Anda", - "user_favorited your post": "%s favorited your post", - "user_reblogged_your_post": "%s reblogged your post", - "user_mentioned_you": "%s menyebut Anda", - "user_requested_to_follow_you": "%s ingin mengikuti Anda", - "user_your_poll_has_ended": "%s Japat Anda telah berakhir", + "notification_description": { + "followed_you": "followed you", + "favorited_your_post": "favorited your post", + "reblogged_your_post": "reblogged your post", + "mentioned_you": "mentioned you", + "request_to_follow_you": "request to follow you", + "poll_has_ended": "poll has ended" + }, "keyobard": { "show_everything": "Tampilkan Segalanya", "show_mentions": "Tampilkan Sebutan" @@ -485,6 +515,13 @@ "light": "Selalu Cerah", "dark": "Selalu Gelap" }, + "look_and_feel": { + "title": "Look and Feel", + "use_system": "Use System", + "really_dark": "Really Dark", + "sorta_dark": "Sorta Dark", + "light": "Light" + }, "notifications": { "title": "Notifikasi", "favorites": "Favorites my post", @@ -526,14 +563,17 @@ } }, "report": { + "title_report": "Report", "title": "Laporkan %s", "step1": "Langkah 1 dari 2", "step2": "Langkah 2 dari 2", "content1": "Apakah ada postingan lain yang ingin Anda tambahkan ke laporannya?", "content2": "Ada yang moderator harus tahu tentang laporan ini?", + "report_sent_title": "Thanks for reporting, we’ll look into this.", "send": "Kirim Laporan", "skip_to_send": "Kirim tanpa komentar", - "text_placeholder": "Ketik atau tempel komentar tambahan" + "text_placeholder": "Ketik atau tempel komentar tambahan", + "reported": "REPORTED" }, "preview": { "keyboard": { diff --git a/Mastodon/Resources/en.lproj/Localizable.stringsdict b/Localization/StringsConvertor/input/it_IT/Localizable.stringsdict similarity index 100% rename from Mastodon/Resources/en.lproj/Localizable.stringsdict rename to Localization/StringsConvertor/input/it_IT/Localizable.stringsdict diff --git a/Localization/StringsConvertor/input/it_IT/app.json b/Localization/StringsConvertor/input/it_IT/app.json new file mode 100644 index 000000000..ad99e178d --- /dev/null +++ b/Localization/StringsConvertor/input/it_IT/app.json @@ -0,0 +1,596 @@ +{ + "common": { + "alerts": { + "common": { + "please_try_again": "Please try again.", + "please_try_again_later": "Please try again later." + }, + "sign_up_failure": { + "title": "Sign Up Failure" + }, + "server_error": { + "title": "Server Error" + }, + "vote_failure": { + "title": "Vote Failure", + "poll_ended": "The poll has ended" + }, + "discard_post_content": { + "title": "Discard Draft", + "message": "Confirm to discard composed post content." + }, + "publish_post_failure": { + "title": "Publish Failure", + "message": "Failed to publish the post.\nPlease check your internet connection.", + "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." + } + }, + "edit_profile_failure": { + "title": "Edit Profile Error", + "message": "Cannot edit profile. Please try again." + }, + "sign_out": { + "title": "Sign Out", + "message": "Are you sure you want to sign out?", + "confirm": "Sign Out" + }, + "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": "Block Domain" + }, + "save_photo_failure": { + "title": "Save Photo Failure", + "message": "Please enable the photo library access permission to save the photo." + }, + "delete_post": { + "title": "Delete Post", + "message": "Are you sure you want to delete this post?" + }, + "clean_cache": { + "title": "Clean Cache", + "message": "Successfully cleaned %s cache." + } + }, + "controls": { + "actions": { + "back": "Back", + "next": "Next", + "previous": "Previous", + "open": "Open", + "add": "Add", + "remove": "Remove", + "edit": "Edit", + "save": "Save", + "ok": "OK", + "done": "Done", + "confirm": "Confirm", + "continue": "Continue", + "compose": "Compose", + "cancel": "Cancel", + "discard": "Discard", + "try_again": "Try Again", + "take_photo": "Take Photo", + "save_photo": "Save Photo", + "copy_photo": "Copy Photo", + "sign_in": "Sign In", + "sign_up": "Sign Up", + "see_more": "See More", + "preview": "Preview", + "share": "Share", + "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", + "reply": "Reply", + "report_user": "Report %s", + "block_domain": "Block %s", + "unblock_domain": "Unblock %s", + "settings": "Settings", + "delete": "Delete" + }, + "tabs": { + "home": "Home", + "search": "Search", + "notification": "Notification", + "profile": "Profile" + }, + "keyboard": { + "common": { + "switch_to_tab": "Switch to %s", + "compose_new_post": "Compose New Post", + "show_favorites": "Show Favorites", + "open_settings": "Open Settings" + }, + "timeline": { + "previous_status": "Previous Post", + "next_status": "Next Post", + "open_status": "Open Post", + "open_author_profile": "Open Author's Profile", + "open_reblogger_profile": "Open Reblogger's Profile", + "reply_status": "Reply to Post", + "toggle_reblog": "Toggle Reblog on Post", + "toggle_favorite": "Toggle Favorite on Post", + "toggle_content_warning": "Toggle Content Warning", + "preview_image": "Preview Image" + }, + "segmented_control": { + "previous_section": "Previous Section", + "next_section": "Next Section" + } + }, + "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", + "poll": { + "vote": "Vote", + "closed": "Closed" + }, + "actions": { + "reply": "Reply", + "reblog": "Reblog", + "unreblog": "Undo reblog", + "favorite": "Favorite", + "unfavorite": "Unfavorite", + "menu": "Menu", + "hide": "Hide" + }, + "tag": { + "url": "URL", + "mention": "Mention", + "link": "Link", + "hashtag": "Hashtag", + "email": "Email", + "emoji": "Emoji" + }, + "visibility": { + "unlisted": "Everyone can see this post but not display in the public timeline.", + "private": "Only their followers can see this post.", + "private_from_me": "Only my followers can see this post.", + "direct": "Only mentioned user can see this post." + } + }, + "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", + "mute": "Mute", + "mute_user": "Mute %s", + "unmute": "Unmute", + "unmute_user": "Unmute %s", + "muted": "Muted", + "edit_info": "Edit Info" + }, + "timeline": { + "filtered": "Filtered", + "timestamp": { + "now": "Now" + }, + "loader": { + "load_missing_posts": "Load missing posts", + "loading_missing_posts": "Loading missing posts...", + "show_more_replies": "Show more replies" + }, + "header": { + "no_status_found": "No Post 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." + } + } + } + }, + "scene": { + "welcome": { + "slogan": "Social networking\nback in your hands.", + "get_started": "Get Started", + "log_in": "Log In" + }, + "server_picker": { + "title": "Mastodon is made of users in different communities.", + "subtitle": "Pick a community based on your interests, region, or a general purpose one.", + "subtitle_extend": "Pick a community based on your interests, region, or a general purpose one. Each community is operated by an entirely independent organization or individual.", + "button": { + "category": { + "all": "All", + "all_accessiblity_description": "Category: All", + "academia": "academia", + "activism": "activism", + "food": "food", + "furry": "furry", + "games": "games", + "general": "general", + "journalism": "journalism", + "lgbt": "lgbt", + "regional": "regional", + "art": "art", + "music": "music", + "tech": "tech" + }, + "see_less": "See Less", + "see_more": "See More" + }, + "label": { + "language": "LANGUAGE", + "users": "USERS", + "category": "CATEGORY" + }, + "input": { + "placeholder": "Search communities" + }, + "empty_state": { + "finding_servers": "Finding available servers...", + "bad_network": "Something went wrong while loading the data. Check your internet connection.", + "no_results": "No results" + } + }, + "register": { + "title": "Let’s get you set up on %s", + "input": { + "avatar": { + "delete": "Delete" + }, + "username": { + "placeholder": "username", + "duplicate_prompt": "This username is taken." + }, + "display_name": { + "placeholder": "display name" + }, + "email": { + "placeholder": "email" + }, + "password": { + "placeholder": "password", + "require": "Your password needs at least:", + "character_limit": "8 characters", + "accessibility": { + "checked": "checked", + "unchecked": "unchecked" + }, + "hint": "Your password needs at least eight characters" + }, + "invite": { + "registration_user_invite_request": "Why do you want to join?" + } + }, + "error": { + "item": { + "username": "Username", + "email": "Email", + "password": "Password", + "agreement": "Agreement", + "locale": "Locale", + "reason": "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" + }, + "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)" + } + } + }, + "server_rules": { + "title": "Some ground rules.", + "subtitle": "These are set and enforced by the %s moderators.", + "prompt": "By continuing, you’re subject to the terms of service and privacy policy for %s.", + "terms_of_service": "terms of service", + "privacy_policy": "privacy policy", + "button": { + "confirm": "I Agree" + } + }, + "confirm_email": { + "title": "One last thing.", + "subtitle": "Tap the link we emailed to you to verify your account.", + "button": { + "open_email_app": "Open Email App", + "resend": "Resend" + }, + "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" + }, + "open_email_app": { + "title": "Check your inbox.", + "description": "We just sent you an email. Check your junk folder if you haven’t.", + "mail": "Mail", + "open_email_client": "Open Email Client" + } + }, + "home_timeline": { + "title": "Home", + "navigation_bar_state": { + "offline": "Offline", + "new_posts": "See new posts", + "published": "Published!", + "Publishing": "Publishing post..." + } + }, + "suggestion_account": { + "title": "Find People to Follow", + "follow_explain": "When you follow someone, you’ll see their posts in your home feed." + }, + "compose": { + "title": { + "new_post": "New Post", + "new_reply": "New Reply" + }, + "media_selection": { + "camera": "Take Photo", + "photo_library": "Photo Library", + "browse": "Browse" + }, + "content_input_placeholder": "Type or paste what’s on your mind", + "compose_action": "Publish", + "replying_to_user": "replying to %s", + "attachment": { + "photo": "photo", + "video": "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..." + }, + "poll": { + "duration_time": "Duration: %s", + "thirty_minutes": "30 minutes", + "one_hour": "1 Hour", + "six_hours": "6 Hours", + "one_day": "1 Day", + "three_days": "3 Days", + "seven_days": "7 Days", + "option_number": "Option %ld" + }, + "content_warning": { + "placeholder": "Write an accurate warning here..." + }, + "visibility": { + "public": "Public", + "unlisted": "Unlisted", + "private": "Followers only", + "direct": "Only people I mention" + }, + "auto_complete": { + "space_to_add": "Space to add" + }, + "accessibility": { + "append_attachment": "Add Attachment", + "append_poll": "Add Poll", + "remove_poll": "Remove Poll", + "custom_emoji_picker": "Custom Emoji Picker", + "enable_content_warning": "Enable Content Warning", + "disable_content_warning": "Disable Content Warning", + "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" + } + }, + "profile": { + "dashboard": { + "posts": "posts", + "following": "following", + "followers": "followers" + }, + "fields": { + "add_row": "Add Row", + "placeholder": { + "label": "Label", + "content": "Content" + } + }, + "segmented_control": { + "posts": "Posts", + "replies": "Replies", + "posts_and_replies": "Posts and Replies", + "media": "Media", + "about": "About" + }, + "relationship_action_alert": { + "confirm_mute_user": { + "title": "Mute Account", + "message": "Confirm to mute %s" + }, + "confirm_unmute_user": { + "title": "Unmute Account", + "message": "Confirm to unmute %s" + }, + "confirm_block_user": { + "title": "Block Account", + "message": "Confirm to block %s" + }, + "confirm_unblock_user": { + "title": "Unblock Account", + "message": "Confirm to unblock %s" + } + } + }, + "follower": { + "footer": "Followers from other servers are not displayed." + }, + "following": { + "footer": "Follows from other servers are not displayed." + }, + "search": { + "title": "Search", + "search_bar": { + "placeholder": "Search hashtags and users", + "cancel": "Cancel" + }, + "recommend": { + "button_text": "See All", + "hash_tag": { + "title": "Trending on Mastodon", + "description": "Hashtags that are getting quite a bit of attention", + "people_talking": "%s people are talking" + }, + "accounts": { + "title": "Accounts you might like", + "description": "You may like to follow these accounts", + "follow": "Follow" + } + }, + "searching": { + "segment": { + "all": "All", + "people": "People", + "hashtags": "Hashtags", + "posts": "Posts" + }, + "empty_state": { + "no_results": "No results" + }, + "recent_search": "Recent searches", + "clear": "Clear" + } + }, + "favorite": { + "title": "Your Favorites" + }, + "notification": { + "title": { + "Everything": "Everything", + "Mentions": "Mentions" + }, + "notification_description": { + "followed_you": "followed you", + "favorited_your_post": "favorited your post", + "reblogged_your_post": "reblogged your post", + "mentioned_you": "mentioned you", + "request_to_follow_you": "request to follow you", + "poll_has_ended": "poll has ended" + }, + "keyobard": { + "show_everything": "Show Everything", + "show_mentions": "Show Mentions" + } + }, + "thread": { + "back_title": "Post", + "title": "Post from %s" + }, + "settings": { + "title": "Settings", + "section": { + "appearance": { + "title": "Appearance", + "automatic": "Automatic", + "light": "Always Light", + "dark": "Always Dark" + }, + "look_and_feel": { + "title": "Look and Feel", + "use_system": "Use System", + "really_dark": "Really Dark", + "sorta_dark": "Sorta Dark", + "light": "Light" + }, + "notifications": { + "title": "Notifications", + "favorites": "Favorites my post", + "follows": "Follows me", + "boosts": "Reblogs my post", + "mentions": "Mentions me", + "trigger": { + "anyone": "anyone", + "follower": "a follower", + "follow": "anyone I follow", + "noone": "no one", + "title": "Notify me when" + } + }, + "preference": { + "title": "Preferences", + "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" + }, + "boring_zone": { + "title": "The Boring Zone", + "account_settings": "Account Settings", + "terms": "Terms of Service", + "privacy": "Privacy Policy" + }, + "spicy_zone": { + "title": "The Spicy Zone", + "clear": "Clear Media Cache", + "signout": "Sign Out" + } + }, + "footer": { + "mastodon_description": "Mastodon is open source software. You can report issues on GitHub at %s (%s)" + }, + "keyboard": { + "close_settings_window": "Close Settings Window" + } + }, + "report": { + "title_report": "Report", + "title": "Report %s", + "step1": "Step 1 of 2", + "step2": "Step 2 of 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?", + "report_sent_title": "Thanks for reporting, we’ll look into this.", + "send": "Send Report", + "skip_to_send": "Send without comment", + "text_placeholder": "Type or paste additional comments", + "reported": "REPORTED" + }, + "preview": { + "keyboard": { + "close_preview": "Close Preview", + "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/it_IT/ios-infoPlist.json b/Localization/StringsConvertor/input/it_IT/ios-infoPlist.json new file mode 100644 index 000000000..c6db73de0 --- /dev/null +++ b/Localization/StringsConvertor/input/it_IT/ios-infoPlist.json @@ -0,0 +1,6 @@ +{ + "NSCameraUsageDescription": "Used to take photo for post status", + "NSPhotoLibraryAddUsageDescription": "Used to save photo into the Photo Library", + "NewPostShortcutItemTitle": "New Post", + "SearchShortcutItemTitle": "Search" +} diff --git a/Localization/StringsConvertor/input/ja_JP/Localizable.stringsdict b/Localization/StringsConvertor/input/ja_JP/Localizable.stringsdict index c51a9a29d..f1c5e6e25 100644 --- a/Localization/StringsConvertor/input/ja_JP/Localizable.stringsdict +++ b/Localization/StringsConvertor/input/ja_JP/Localizable.stringsdict @@ -279,7 +279,7 @@ <key>NSStringFormatValueTypeKey</key> <string>ld</string> <key>other</key> - <string>%ld分前</string> + <string>%ldか月前</string> </dict> </dict> <key>date.day.ago.abbr</key> diff --git a/Localization/StringsConvertor/input/ja_JP/app.json b/Localization/StringsConvertor/input/ja_JP/app.json index 417ca3e3a..7ddfa51c1 100644 --- a/Localization/StringsConvertor/input/ja_JP/app.json +++ b/Localization/StringsConvertor/input/ja_JP/app.json @@ -23,7 +23,7 @@ "title": "失敗", "message": "投稿に失敗しました。\nインターネットに接続されているか確認してください。", "attachments_message": { - "video_attach_with_photo": "すでに画像が含まれている投稿に、動画を添付することができません。", + "video_attach_with_photo": "すでに画像が含まれている投稿に、動画を添付することはできません。", "more_than_one_video": "複数の動画を添付することはできません。" } }, @@ -46,7 +46,7 @@ }, "delete_post": { "title": "この投稿を消去しますか?", - "delete": "消去" + "message": "本当に削除しますか?" }, "clean_cache": { "title": "キャッシュを消去", @@ -67,7 +67,7 @@ "done": "完了", "confirm": "確認", "continue": "続ける", - "compose": "Compose", + "compose": "新規作成", "cancel": "キャンセル", "discard": "破棄", "try_again": "再実行", @@ -82,11 +82,12 @@ "share_user": "%sを共有", "share_post": "投稿を共有", "open_in_safari": "Safariで開く", + "open_in_browser": "ブラウザで開く", "find_people": "フォローする人を見つける", "manually_search": "手動で検索する", "skip": "スキップ", "reply": "リプライ", - "report_user": "%sを報告", + "report_user": "%sを通報", "block_domain": "%sをブロック", "unblock_domain": "%sのブロックを解除", "settings": "設定", @@ -139,7 +140,8 @@ "unreblog": "ブーストを戻す", "favorite": "お気に入り", "unfavorite": "お気に入り登録を取り消す", - "menu": "メニュー" + "menu": "メニュー", + "hide": "非表示" }, "tag": { "url": "URL", @@ -148,6 +150,12 @@ "hashtag": "ハッシュタグ", "email": "メール", "emoji": "絵文字" + }, + "visibility": { + "unlisted": "この投稿は誰でも見ることができますが、公開タイムラインには表示されません。", + "private": "この投稿はフォロワーに限り見ることができます。", + "private_from_me": "この投稿はフォロワーに限り見ることができます。", + "direct": "この投稿はメンションされたユーザーに限り見ることができます。" } }, "friendship": { @@ -192,10 +200,14 @@ }, "scene": { "welcome": { - "slogan": "ソーシャルネットワーキングを、あなたの手の中に." + "slogan": "ソーシャルネットワーキングを、あなたの手の中に.", + "get_started": "Get Started", + "log_in": "ログイン" }, "server_picker": { "title": "サーバーを選択", + "subtitle": "あなたの興味分野・地域に合ったコミュニティや、汎用のものを選択してください。", + "subtitle_extend": "あなたの興味分野・地域に合ったコミュニティや、汎用のものを選択してください。各コミュニティはそれぞれ完全に独立した組織や個人によって運営されています。", "button": { "category": { "all": "すべて", @@ -203,7 +215,7 @@ "academia": "アカデミア", "activism": "アクティビズム", "food": "食べ物", - "furry": "furry", + "furry": "ケモノ", "games": "ゲーム", "general": "全般", "journalism": "言論", @@ -248,6 +260,12 @@ }, "password": { "placeholder": "パスワード", + "require": "Your password needs at least:", + "character_limit": "8 characters", + "accessibility": { + "checked": "checked", + "unchecked": "unchecked" + }, "hint": "パスワードは最低でも8文字必要です。" }, "invite": { @@ -298,7 +316,7 @@ "subtitle": "先程 %s にメールを送信しました。リンクをタップしてアカウントを確認してください。", "button": { "open_email_app": "メールアプリを開く", - "dont_receive_email": "メールがこない" + "resend": "Resend" }, "dont_receive_email": { "title": "メールをチェックしてください", @@ -342,8 +360,8 @@ "photo": "写真", "video": "動画", "attachment_broken": "%sは壊れていてMastodonにアップロードできません。", - "description_photo": "視覚障がい者のために写真を説明", - "description_video": "視覚障がい者のための映像の説明" + "description_photo": "閲覧が難しいユーザーへの画像説明", + "description_video": "閲覧が難しいユーザーへの映像説明" }, "poll": { "duration_time": "期間: %s", @@ -401,24 +419,34 @@ "segmented_control": { "posts": "投稿", "replies": "リプライ", - "media": "メディア" + "posts_and_replies": "Posts and Replies", + "media": "メディア", + "about": "About" }, "relationship_action_alert": { + "confirm_mute_user": { + "title": "Mute Account", + "message": "Confirm to mute %s" + }, "confirm_unmute_user": { "title": "ミュートを解除", "message": "%sをミュートしますか?" }, - "confirm_unblock_usre": { - "title": "ブロックを解除", - "message": "%sのブロックを解除しますか?" + "confirm_block_user": { + "title": "Block Account", + "message": "Confirm to block %s" + }, + "confirm_unblock_user": { + "title": "Unblock Account", + "message": "Confirm to unblock %s" } } }, "follower": { - "footer": "Followers from other servers are not displayed." + "footer": "他のサーバーからのフォロワーは表示されません。" }, "following": { - "footer": "Follows from other servers are not displayed." + "footer": "他のサーバーにいるフォローは表示されません。" }, "search": { "title": "検索", @@ -461,12 +489,14 @@ "Everything": "すべて", "Mentions": "メンション" }, - "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 投票が終了しました", + "notification_description": { + "followed_you": "followed you", + "favorited_your_post": "favorited your post", + "reblogged_your_post": "reblogged your post", + "mentioned_you": "mentioned you", + "request_to_follow_you": "request to follow you", + "poll_has_ended": "poll has ended" + }, "keyobard": { "show_everything": "すべて見る", "show_mentions": "メンションを見る" @@ -485,6 +515,13 @@ "light": "ライト", "dark": "ダーク" }, + "look_and_feel": { + "title": "Look and Feel", + "use_system": "Use System", + "really_dark": "Really Dark", + "sorta_dark": "Sorta Dark", + "light": "Light" + }, "notifications": { "title": "通知", "favorites": "お気に入り登録", @@ -502,7 +539,7 @@ "preference": { "title": "環境設定", "true_black_dark_mode": "真っ黒なダークテーマを使用する", - "disable_avatar_animation": "アニメーションアバターの無効化する", + "disable_avatar_animation": "アバターのアニメーションを無効化する", "disable_emoji_animation": "絵文字のアニメーションを無効化する", "using_default_browser": "既定のブラウザでリンクを開く" }, @@ -526,14 +563,17 @@ } }, "report": { - "title": "%sを報告", + "title_report": "Report", + "title": "%sを通報", "step1": "ステップ 1/2", "step2": "ステップ 2/2", - "content1": "他に報告したい投稿はありますか?", - "content2": "この報告についてモデレーターに言いたいことはありますか?", - "send": "報告を送信", + "content1": "他に通報したい投稿はありますか?", + "content2": "この通報についてモデレーターに伝達しておきたい事項はありますか?", + "report_sent_title": "Thanks for reporting, we’ll look into this.", + "send": "通報を送信", "skip_to_send": "コメントなしで送信", - "text_placeholder": "追加コメントを入力" + "text_placeholder": "追加コメントを入力", + "reported": "REPORTED" }, "preview": { "keyboard": { @@ -543,14 +583,14 @@ } }, "account_list": { - "tab_bar_hint": "Current selected profile: %s. Double tap then hold to show account switcher", - "dismiss_account_switcher": "Dismiss Account Switcher", + "tab_bar_hint": "現在のアカウント: %s. ダブルタップしてアカウント切替画面を表示します", + "dismiss_account_switcher": "アカウント切替画面を閉じます", "add_account": "アカウントを追加" }, "wizard": { "new_in_mastodon": "Mastodon の新機能", "multiple_account_switch_intro_description": "プロフィールボタンを押して複数のアカウントを切り替えます。", - "accessibility_hint": "Double tap to dismiss this wizard" + "accessibility_hint": "チュートリアルを閉じるには、ダブルタップしてください" } } } \ No newline at end of file diff --git a/Localization/StringsConvertor/input/kab_KAB/Localizable.stringsdict b/Localization/StringsConvertor/input/kab_KAB/Localizable.stringsdict new file mode 100644 index 000000000..8a2bac9ec --- /dev/null +++ b/Localization/StringsConvertor/input/kab_KAB/Localizable.stringsdict @@ -0,0 +1,390 @@ +<?xml version="1.0" encoding="utf-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> + <dict> + <key>a11y.plural.count.unread.notification</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@notification_count_unread_notification@</string> + <key>notification_count_unread_notification</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 wulɣu ur nettwaɣra</string> + <key>other</key> + <string>%ld yilɣa ur nettwaɣra</string> + </dict> + </dict> + <key>a11y.plural.count.input_limit_exceeds</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>Talast n unekcum tɛedda %#@character_count@</string> + <key>character_count</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 usekkil</string> + <key>other</key> + <string>%ld yisekkilen</string> + </dict> + </dict> + <key>a11y.plural.count.input_limit_remains</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>Talast n unekcum yeqqim-d seg-s %#@character_count@</string> + <key>character_count</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 usekkil</string> + <key>other</key> + <string>%ld yisekkilen</string> + </dict> + </dict> + <key>plural.count.metric_formatted.post</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%@ %#@post_count@</string> + <key>post_count</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>tasuffeɣt</string> + <key>other</key> + <string>tisuffaɣ</string> + </dict> + </dict> + <key>plural.count.post</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@post_count@</string> + <key>post_count</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 tsuffeɣt</string> + <key>other</key> + <string>%ld n tsuffaɣ</string> + </dict> + </dict> + <key>plural.count.favorite</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@favorite_count@</string> + <key>favorite_count</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1unurif</string> + <key>other</key> + <string>%ld yinurifen</string> + </dict> + </dict> + <key>plural.count.reblog</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@reblog_count@</string> + <key>reblog_count</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1uɛiwed n usuffeɣ</string> + <key>other</key> + <string>%ld n uɛiwed n usuffeɣ</string> + </dict> + </dict> + <key>plural.count.vote</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@vote_count@</string> + <key>vote_count</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 tefrant</string> + <key>other</key> + <string>%ld tefranin</string> + </dict> + </dict> + <key>plural.count.voter</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@voter_count@</string> + <key>voter_count</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1umefran</string> + <key>other</key> + <string>%ld imefranen</string> + </dict> + </dict> + <key>plural.people_talking</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_people_talking@</string> + <key>count_people_talking</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 umdan i yettmeslayen</string> + <key>other</key> + <string>%ld yimdanen i yettmeslayen</string> + </dict> + </dict> + <key>plural.count.following</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_following@</string> + <key>count_following</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 uneḍfar</string> + <key>other</key> + <string>%ld yineḍfaren</string> + </dict> + </dict> + <key>plural.count.follower</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_follower@</string> + <key>count_follower</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 uneḍfar</string> + <key>other</key> + <string>%ld yineḍfaren</string> + </dict> + </dict> + <key>date.year.left</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_year_left@</string> + <key>count_year_left</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>Yeqqim-d 1 useggas</string> + <key>other</key> + <string>Qqimen-d %ld yiseggasen</string> + </dict> + </dict> + <key>date.month.left</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_month_left@</string> + <key>count_month_left</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 wayyur i d-yeqqimen</string> + <key>other</key> + <string>%ld wayyuren i d-yeqqimen</string> + </dict> + </dict> + <key>date.day.left</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_day_left@</string> + <key>count_day_left</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>Yeqqim-d 1 wass</string> + <key>other</key> + <string>Qqimen-d %ld wussan</string> + </dict> + </dict> + <key>date.hour.left</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_hour_left@</string> + <key>count_hour_left</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>Yeqqim-d 1 usrag</string> + <key>other</key> + <string>Qqimen-d %ld yisragen</string> + </dict> + </dict> + <key>date.minute.left</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_minute_left@</string> + <key>count_minute_left</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 tesdat i d-yeqqimen</string> + <key>other</key> + <string>%ld tesdatin i d-yeqqimen</string> + </dict> + </dict> + <key>date.second.left</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_second_left@</string> + <key>count_second_left</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 tasint i d-yeqqimen</string> + <key>other</key> + <string>%ld tsinin i d-yeqqimen</string> + </dict> + </dict> + <key>date.year.ago.abbr</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_year_ago_abbr@</string> + <key>count_year_ago_abbr</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 useggas aya</string> + <key>other</key> + <string>%ld yiseggasen aya</string> + </dict> + </dict> + <key>date.month.ago.abbr</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_month_ago_abbr@</string> + <key>count_month_ago_abbr</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 wayyur aya</string> + <key>other</key> + <string>%ld wayyuren aya</string> + </dict> + </dict> + <key>date.day.ago.abbr</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_day_ago_abbr@</string> + <key>count_day_ago_abbr</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 wass aya</string> + <key>other</key> + <string>%ld wussan aya</string> + </dict> + </dict> + <key>date.hour.ago.abbr</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_hour_ago_abbr@</string> + <key>count_hour_ago_abbr</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 usrag aya</string> + <key>other</key> + <string>%ld yisragen aya</string> + </dict> + </dict> + <key>date.minute.ago.abbr</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_minute_ago_abbr@</string> + <key>count_minute_ago_abbr</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 tesdat aya</string> + <key>other</key> + <string>%ld tesdatin aya</string> + </dict> + </dict> + <key>date.second.ago.abbr</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_second_ago_abbr@</string> + <key>count_second_ago_abbr</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 tasint aya</string> + <key>other</key> + <string>%ld tsinin aya</string> + </dict> + </dict> + </dict> +</plist> diff --git a/Localization/StringsConvertor/input/kab_KAB/app.json b/Localization/StringsConvertor/input/kab_KAB/app.json new file mode 100644 index 000000000..74c167956 --- /dev/null +++ b/Localization/StringsConvertor/input/kab_KAB/app.json @@ -0,0 +1,596 @@ +{ + "common": { + "alerts": { + "common": { + "please_try_again": "Ttxil εreḍ tikelt-nniḍen.", + "please_try_again_later": "Ttxil εreḍ tikelt-nniḍen ticki." + }, + "sign_up_failure": { + "title": "Tuccḍa deg unekcum" + }, + "server_error": { + "title": "Tuccḍa n uqeddac" + }, + "vote_failure": { + "title": "Tuccḍa deg ufran", + "poll_ended": "Tafrant tfuk" + }, + "discard_post_content": { + "title": "Kkes arewway", + "message": "Sentem i wakken ad yettusefsax ugbur n tsuffeɣt." + }, + "publish_post_failure": { + "title": "Yecceḍ usuffeɣ", + "message": "Yecceḍ usuffeɣ n tsuffeɣt.\nMa ulac aɣilif, senqed tuqqna-inek internet.", + "attachments_message": { + "video_attach_with_photo": "Ur tezmireḍ ara ad tsedduḍ tavidyut deg tsuffeɣt ideg llant yakan tugniwin.", + "more_than_one_video": "Ur tezmireḍ ara ad ugar n tvidyut." + } + }, + "edit_profile_failure": { + "title": "Ẓreg tuccḍa n umaɣnu", + "message": "Yegguma ad yettwaẓreg umaɣnu. Ɛreḍ tikkelt-nniḍen." + }, + "sign_out": { + "title": "Ffeɣ", + "message": "Tebɣiḍ ad teffɣeḍ?", + "confirm": "Ffeɣ" + }, + "block_domain": { + "title": "D tidet, d tidet tebɣiḍ ad tesweḥleḍ %s akken ma yella? Deg tuget, kra n yisewḥal d ugdal ad yili d ayen iwulmen, yettwafernen. Ur tettwaliḍ ara agbur seg taɣult-nni neɣ kra seg yineḍfaren-ik i tt-yesseqdacen.", + "block_entire_domain": "Sewḥel taɣult" + }, + "save_photo_failure": { + "title": "Tuccḍa deg usekles n tewlaft", + "message": "Ma ulac aɣilif, rmed tasiregt n unekcum ɣer temkarḍit n tewlafin i usekles n tewlaft." + }, + "delete_post": { + "title": "Tebɣiḍ s tidet ad tekkseḍ tasuffeɣt-agi?", + "message": "Tebɣiḍ s tidet ad tekkseḍ tasuffeɣt-agi?" + }, + "clean_cache": { + "title": "Sfeḍ tuffirt", + "message": "Yettwasfeḍ %s n tkatut tuffirt akken iwata." + } + }, + "controls": { + "actions": { + "back": "Tuɣalin", + "next": "Uḍfir", + "previous": "Uzwir", + "open": "Ldi", + "add": "Rnu", + "remove": "Kkes", + "edit": "Ẓreg", + "save": "Sekles", + "ok": "IH", + "done": "Immed", + "confirm": "Sentem", + "continue": "Kemmel", + "compose": "Sudes", + "cancel": "Sefsex", + "discard": "Sefsex", + "try_again": "Ɛreḍ tikkelt-nniḍen", + "take_photo": "Ṭṭef tawlaft", + "save_photo": "Sekles tawlaft", + "copy_photo": "Nɣel tawlaft", + "sign_in": "Qqen", + "sign_up": "Jerred amiḍan", + "see_more": "Wali ugar", + "preview": "Taskant", + "share": "Bḍu", + "share_user": "Bḍu %s", + "share_post": "Bḍu tasuffeɣt", + "open_in_safari": "Ldi deg Safari", + "open_in_browser": "Ldi deg yiminig", + "find_people": "Af imdanen ara tḍefreḍ", + "manually_search": "Anadi s ufus deg wadeg-is", + "skip": "Zgel", + "reply": "Err", + "report_user": "Cetki ɣef %s", + "block_domain": "Sewḥel %s", + "unblock_domain": "Serreḥ i %s", + "settings": "Iɣewwaṛen", + "delete": "Kkes" + }, + "tabs": { + "home": "Agejdan", + "search": "Nadi", + "notification": "Tilɣa", + "profile": "Amaɣnu" + }, + "keyboard": { + "common": { + "switch_to_tab": "Ddu ɣer %s", + "compose_new_post": "Aru tasuffeɣt tamaynut", + "show_favorites": "Sken-d ismenyifen", + "open_settings": "Ldi iɣewwaren" + }, + "timeline": { + "previous_status": "Amagrad uzwir", + "next_status": "Amagrad uḍfir", + "open_status": "Ldi tasuffeɣt", + "open_author_profile": "Ldi amaɣnu n umeskar", + "open_reblogger_profile": "Ldi amaɣnu n win i yulsen asuffeɣ", + "reply_status": "Err ɣef tsuffeɣt", + "toggle_reblog": "Abeddel n unallas n tsuffeɣt", + "toggle_favorite": "Abeddel n usmenyaf i tsuffeɣt", + "toggle_content_warning": "Beddel alɣu n ugbur", + "preview_image": "Asenqed n tugna" + }, + "segmented_control": { + "previous_section": "Tafrant tuzwirt", + "next_section": "Tigezmi tuḍfirt" + } + }, + "status": { + "user_reblogged": "Tettwasuffeɣ-d %s i tikkelt-nniḍen", + "user_replied_to": "Yerra ɣef %s", + "show_post": "Sken-d tasuffeɣt", + "show_user_profile": "Ssken-d amaɣnu n useqdac", + "content_warning": "Alɣu n ugbur", + "media_content_warning": "Sit anida tebɣiḍ i wakken ad twaliḍ", + "poll": { + "vote": "Dɣeṛ", + "closed": "Ifukk" + }, + "actions": { + "reply": "Err", + "reblog": "Aɛiwed n usuffeɣ", + "unreblog": "Sefsex allus n usuffeɣ", + "favorite": "Anurif", + "unfavorite": "Kkes seg yismenyifen", + "menu": "Umuɣ", + "hide": "Ffer" + }, + "tag": { + "url": "URL", + "mention": "Tabdart", + "link": "Aseɣwen", + "hashtag": "Ahacṭag", + "email": "Imayl", + "emoji": "Emuji" + }, + "visibility": { + "unlisted": "Yal wa yezmer ad iwali tsuffeɣt-a maca ur d-tettwaskaneḍ ara deg yizirig n wakud azayaz.", + "private": "D ineḍfaren-is kan i izemren ad walin tsuffeɣ-a.", + "private_from_me": "D ineḍfaren-is kan i izemren ad walin tsuffeɣ-a.", + "direct": "D ineḍfaren-is kan i izemren ad walin tsuffeɣ-a." + } + }, + "friendship": { + "follow": "Ḍfeṛ", + "following": "Yettwaḍfar", + "request": "Tuttra", + "pending": "Yegguni", + "block": "Sewḥel", + "block_user": "Sewḥel %s", + "block_domain": "Sewḥel %s", + "unblock": "Serreḥ", + "unblock_user": "Serreḥ i %s", + "blocked": "Yettusewḥel", + "mute": "Sgugem", + "mute_user": "Sgugem %s", + "unmute": "Kkes asgugem", + "unmute_user": "Kkes asgugem ɣef %s", + "muted": "Yettwasgugem", + "edit_info": "Ẓreg talɣut" + }, + "timeline": { + "filtered": "Yettwasizdeg", + "timestamp": { + "now": "Tura" + }, + "loader": { + "load_missing_posts": "Sali tisuffaɣ i iruḥen", + "loading_missing_posts": "Asali n tsuffaɣ i iruḥen...", + "show_more_replies": "Ssken-d ugar n tririyin" + }, + "header": { + "no_status_found": "Ulac tasuffeɣt yettwafen", + "blocking_warning": "Ur tezmireḍ ara ad twaliḍ amaɣnu n useqdac-a\nalamma tekkseḍ-as asewḥel.\nAkka i as-d-yettban umaɣnu-inek.", + "user_blocking_warning": "Ur tezmireḍ ara ad twaliḍ amaɣnu n %s\nalamma tekkseḍ-as asewḥel.\nAkka i as-d-yettban umaɣnu-inek.", + "blocked_warning": "Ur tezmireḍ ara ad twaliḍ amaɣnu n useqdac-a\nAkka i as-d-yettban umaɣnu-inek.", + "user_blocked_warning": "Ur tezmireḍ ara ad twaliḍ amaɣnu n %s\nAkka i as-d-yettban umaɣnu-inek.", + "suspended_warning": "Yettwaseḥbes useqdac-a.", + "user_suspended_warning": "Yettwaseḥbes umiḍan n %s." + } + } + } + }, + "scene": { + "welcome": { + "slogan": "Izeḍwa inmettiyen\nuɣalen-d ɣer ufus-ik.", + "get_started": "Aha bdu tura", + "log_in": "Qqen" + }, + "server_picker": { + "title": "Mastodon yettwaxdem i yiseqdacen deg waṭas n temɣiwnin.", + "subtitle": "Fren tamɣiwent almend n wayen tḥemmleḍ, n tmurt-ik neɣ n yiswi-inek amatu.", + "subtitle_extend": "Fren tamɣiwent almend n wayen tḥemmleḍ, n tmurt-ik neɣ n yiswi-inek amatu. Yal tamɣiwent tsedday-itt tkebbanit neɣ amdan ilelliyen.", + "button": { + "category": { + "all": "Akk", + "all_accessiblity_description": "Taggayt: Akk", + "academia": "akadimi", + "activism": "tinuɣmest", + "food": "učči", + "furry": "furry", + "games": "uraren", + "general": "amatu", + "journalism": "taɣamsa", + "lgbt": "lgbt", + "regional": "amnaḍan", + "art": "taẓuri", + "music": "aẓawan", + "tech": "atiknikan" + }, + "see_less": "Sken cwiṭ", + "see_more": "Wali ugar" + }, + "label": { + "language": "TUTLAYT", + "users": "ISEQDACEN", + "category": "TAGGAYT" + }, + "input": { + "placeholder": "Nadi timɣiwnin" + }, + "empty_state": { + "finding_servers": "Tifin n yiqeddacen yellan...", + "bad_network": "Tella-d tuccḍa lawan n usali n yisefka. Senqed tuqqna-ink internet.", + "no_results": "Ulac igemmaḍ" + } + }, + "register": { + "title": "Aha ad nebdu asbadu ɣef %s", + "input": { + "avatar": { + "delete": "Kkes" + }, + "username": { + "placeholder": "isem n useqdac", + "duplicate_prompt": "Isem-ayi n umseqdac yettwaṭṭef yakan." + }, + "display_name": { + "placeholder": "isem ara d-yettwaskanen" + }, + "email": { + "placeholder": "imayl" + }, + "password": { + "placeholder": "awal uffir", + "require": "Awal-ik uffir yesra ma drus:", + "character_limit": "8 n yisekkilen", + "accessibility": { + "checked": "yettwasenqed", + "unchecked": "ur yettwasenqed ara" + }, + "hint": "Awal-ik uffir yesra ma drus ṭam n yisekkilen" + }, + "invite": { + "registration_user_invite_request": "Acimi tebγiḍ ad ternuḍ iman-ik?" + } + }, + "error": { + "item": { + "username": "Isem n useqdac", + "email": "Imayl", + "password": "Awal uffir", + "agreement": "Amtawa", + "locale": "Tadigant", + "reason": "Taɣẓint" + }, + "reason": { + "blocked": "%s deg-s asaǧǧăw n yimayl ur nettusireg ara", + "unreachable": "%s ur yettban ara yella", + "taken": "%s yettwaseqdec yakan", + "reserved": "%s d awal uffir yettwaḥarren", + "accepted": "%s ilaq ad yettwaqbal", + "blank": "isra %s", + "invalid": "%s d arameɣtu", + "too_long": "%s ɣezzif aṭas", + "too_short": "%s wezzil aṭas", + "inclusion": "%s mačči d azal yettusefraken" + }, + "special": { + "username_invalid": "Isem n useqdac ilaq ad yesɛu kan isekkilen igmumḍinen d wid yettujerrden", + "username_too_long": "Isem n useqdac ɣezzif aṭas (ur ilaq ara ad iɛeddi nnig 30 yisekkilen)", + "email_invalid": "Tagi mačči d tansa n yimayl tameɣtut", + "password_too_short": "Awal uffir wezzil aṭas (ilaq ad yesɛu ma drus 8 yisekkilen)" + } + } + }, + "server_rules": { + "title": "Kra n yilugan igejdanen.", + "subtitle": "Ilugan-a ttusbadun sɣur inedbalen n %s.", + "prompt": "Mi ara tkemmleḍ, ilaq ad tqebleḍ tiwtilin n yimeẓla d tsertit tabaḍnit n %s.", + "terms_of_service": "tiwetlin n useqdec", + "privacy_policy": "tasertit tabaḍnit", + "button": { + "confirm": "Qebleɣ" + } + }, + "confirm_email": { + "title": "Taɣawsa taneggarut.", + "subtitle": "Sit ɣef useɣwen i ak-n-uznen i wakken ad tesneqdeḍ amiḍan-ik.", + "button": { + "open_email_app": "Ldi asnas n yimayl", + "resend": "Ales tuzna" + }, + "dont_receive_email": { + "title": "Senqed imayl-ik·im", + "description": "Senqed ma yella tansa-inek n imayl d tameɣut akked uspam ma yella ur t-tufiḍ ara.", + "resend_email": "Ales tuzna n yimayl" + }, + "open_email_app": { + "title": "Sefqed Tanaka-inek.", + "description": "Akken kan i ak-n-nuzen imayl. Sefqed aspam ma yella ur t-tufiḍ ara.", + "mail": "Imayl", + "open_email_client": "Ldi amsaɣ n yimayl" + } + }, + "home_timeline": { + "title": "Agejdan", + "navigation_bar_state": { + "offline": "Beṛṛa n tuqqna", + "new_posts": "Tissufaɣ timaynutin", + "published": "Yettwasuffeɣ!", + "Publishing": "Asuffeɣ tasuffeɣt..." + } + }, + "suggestion_account": { + "title": "Af imdanen ara tḍefreḍ", + "follow_explain": "Mi ara teṭṭafareḍ albaɛḍ, ad twaliḍ tisuffaɣ-is deg usuddem-inek agejdan." + }, + "compose": { + "title": { + "new_post": "Tasuffeɣt tamaynut", + "new_reply": "Tiririt tamaynut" + }, + "media_selection": { + "camera": "Ṭṭef tawlaft", + "photo_library": "Tanedlist n tewlaft", + "browse": "Snirem" + }, + "content_input_placeholder": "Aru neɣ senteḍ ayen yellan deg wallaɣ-ik", + "compose_action": "Sufeɣ", + "replying_to_user": "tiririt ɣef %s", + "attachment": { + "photo": "tawlaft", + "video": "tavidyutt", + "attachment_broken": "%s-a yerreẓ, ur yezmir ara\nAd d-yettwasali ɣef Mastodon.", + "description_photo": "Glem-d tawlaft i wid yesɛan ugur deg yiẓri...", + "description_video": "Glem-d tavidyut i wid yesɛan ugur deg yiẓri..." + }, + "poll": { + "duration_time": "Tangazt: %s", + "thirty_minutes": "30 n tesdatin", + "one_hour": "1 n wesrag", + "six_hours": "6 n yisragen", + "one_day": "1 n wass", + "three_days": "3 n wussan", + "seven_days": "7 n wussan", + "option_number": "Taxtiṛt %ld" + }, + "content_warning": { + "placeholder": "Aru alɣu-inek s telqeyt da..." + }, + "visibility": { + "public": "Azayez", + "unlisted": "War tabdert", + "private": "Imeḍfaṛen kan", + "direct": "Imdanen i d-bedreɣ kan" + }, + "auto_complete": { + "space_to_add": "Tallunt ara yettwarnun" + }, + "accessibility": { + "append_attachment": "Rnu taceqquft yeddan", + "append_poll": "Rnu asenqed", + "remove_poll": "Kkes asenqed", + "custom_emoji_picker": "Amefran n yimujiten udmawanen", + "enable_content_warning": "Rmed alɣu n ugbur", + "disable_content_warning": "Sens alɣu n ugbur", + "post_visibility_menu": "Umuɣ n ubani n tsuffeɣt" + }, + "keyboard": { + "discard_post": "Sefsex tasuffeɣt", + "publish_post": "Suffeɣ tasuffeɣt", + "toggle_poll": "Beddel asenqed", + "toggle_content_warning": "Beddel alɣu n ugbur", + "append_attachment_entry": "Rnu taceqquft yeddan - %s", + "select_visibility_entry": "Fren timeẓriwt - %s" + } + }, + "profile": { + "dashboard": { + "posts": "tisuffaɣ", + "following": "iṭafaṛ", + "followers": "imeḍfaren" + }, + "fields": { + "add_row": "Rnu izirig", + "placeholder": { + "label": "Tabzimt", + "content": "Agbur" + } + }, + "segmented_control": { + "posts": "Imagraden", + "replies": "Tiririyin", + "posts_and_replies": "Tisuffaɣ d tririyin", + "media": "Amidya", + "about": "Ɣef" + }, + "relationship_action_alert": { + "confirm_mute_user": { + "title": "Sgugem amiḍan", + "message": "Sentem asgugem i %s" + }, + "confirm_unmute_user": { + "title": "Kkes asgugem i umiḍan", + "message": "Sentem tukksa n usgugem i %s" + }, + "confirm_block_user": { + "title": "Sewḥel amiḍan", + "message": "Sentem asewḥel n %s" + }, + "confirm_unblock_user": { + "title": "Kkes asewḥel i umiḍan", + "message": "Sentem tukksa n usgugem i %s" + } + } + }, + "follower": { + "footer": "Ineḍfaren seg yiqeddacen-nniḍen ur d-ttwaskanen ara." + }, + "following": { + "footer": "Ineḍfaren seg yiqeddacen-nniḍen ur d-ttwaskanen ara." + }, + "search": { + "title": "Nadi", + "search_bar": { + "placeholder": "Nadi hashtags d yiseqdacen", + "cancel": "Sefsex" + }, + "recommend": { + "button_text": "Wali akk", + "hash_tag": { + "title": "Ayen mucaɛen ɣef Mastodon", + "description": "Hashtags i d-ijebbden aṭas lwelha", + "people_talking": "%s yimdanen i yettmeslayen" + }, + "accounts": { + "title": "Imiḍanen i tzemreḍ ad tḥemmleḍ", + "description": "Ahat tebɣiḍ ad tḍefreḍ imiḍanen-a", + "follow": "Ḍfeṛ" + } + }, + "searching": { + "segment": { + "all": "Akk", + "people": "Imdanen", + "hashtags": "Ihacṭagen", + "posts": "Tisuffaɣ" + }, + "empty_state": { + "no_results": "Ulac igemmaḍ" + }, + "recent_search": "Inadiyen imaynuten", + "clear": "Sfeḍ" + } + }, + "favorite": { + "title": "Ismenyifen-ik·im" + }, + "notification": { + "title": { + "Everything": "Akk", + "Mentions": "Abdar" + }, + "notification_description": { + "followed_you": "iṭṭafar-ik·ikem", + "favorited_your_post": "yesmenyef tasuffeɣt-ik·im", + "reblogged_your_post": "iɛawed-as asuffeɣ i tsuffeɣt-ik·im", + "mentioned_you": "yebder-ik·ikem-id", + "request_to_follow_you": "issuter aḍfar-inek", + "poll_has_ended": "asenqed iffuk" + }, + "keyobard": { + "show_everything": "Sken yal taɣawsa", + "show_mentions": "Sken tisedmirin" + } + }, + "thread": { + "back_title": "Amagrad", + "title": "Tasuffeɣt sɣur %s" + }, + "settings": { + "title": "Iɣewwaṛen", + "section": { + "appearance": { + "title": "Apparence", + "automatic": "Awurman", + "light": "Yezga d aceεlal", + "dark": "Yezga d aberkan" + }, + "look_and_feel": { + "title": "Wali, tḥalfuḍ", + "use_system": "Seqdec anagraw", + "really_dark": "D aberkan s tidet", + "sorta_dark": "D aberkan cwiya", + "light": "Aceɛlal" + }, + "notifications": { + "title": "Tilɣa", + "favorites": "Yerna tasuffeɣt-iw ɣer yismenyafen-ines", + "follows": "Yeṭṭafar-iyi", + "boosts": "Yules asuffeɣ n tduffeɣt-iw", + "mentions": "Ibder-iyi-d", + "trigger": { + "anyone": "yal yiwen", + "follower": "ameḍfar", + "follow": "yal win ara ḍefreɣ", + "noone": "ula yiwen", + "title": "Selɣu-yi-d mi ara" + } + }, + "preference": { + "title": "Imenyafen", + "true_black_dark_mode": "Askar aberkan n tidet", + "disable_avatar_animation": "Sens ivaṭaren yettembiwilen", + "disable_emoji_animation": "Sens imujiten yettembiwilen", + "using_default_browser": "Seqdec iminig amezwer i twaledyawt n yiseɣwan" + }, + "boring_zone": { + "title": "Tamnaḍt yessefcalen", + "account_settings": "Iɣewwaṛen n umiḍan", + "terms": "Tiwtilin n useqdec", + "privacy": "Tasertit tabaḍnit" + }, + "spicy_zone": { + "title": "Tamnaḍt tamihawt", + "clear": "Sfeḍ takatut tuffirt n umidyat", + "signout": "Senser" + } + }, + "footer": { + "mastodon_description": "Maṣṭudun d aseɣzan s uɣbalu yeldin. Tzemreḍ ad temmleḍ uguren deg GitHub %s (%s)" + }, + "keyboard": { + "close_settings_window": "Mdel asfaylu n iɣewwaṛen" + } + }, + "report": { + "title_report": "Aneqqis", + "title": "Aneqqis %s", + "step1": "Aḥric 1 seg 2", + "step2": "Aḥric 2 seg 2", + "content1": "Tebɣiḍ ad ternuḍ tisuffaɣ-nniḍen ɣer uneqqis?", + "content2": "Yella wayen i ilaqen ad teẓren yimḍebbren ɣef uneqqis-a?", + "report_sent_title": "Tanemmirt ɣef uneqqis, ad nwali deg waya.", + "send": "Azen aneqis", + "skip_to_send": "Azen s war awennit", + "text_placeholder": "Aru neɣ senteḍ iwenniten-nniḍen", + "reported": "YETTWAMMEL" + }, + "preview": { + "keyboard": { + "close_preview": "Mdel timeẓri", + "show_next": "Sken uḍfir", + "show_previous": "Sken udfir" + } + }, + "account_list": { + "tab_bar_hint": "Amaɣnu amiran yettwafernen: %s. Sit berdayen syen teǧǧeḍ aḍad-ik·im i uskan abeddel n umiḍan", + "dismiss_account_switcher": "Sefsex abeddel n umiḍan", + "add_account": "Rnu amiḍan" + }, + "wizard": { + "new_in_mastodon": "Amaynut deg Maṣṭudun", + "multiple_account_switch_intro_description": "Beddel gar waṭas n yimiḍanen s tussda ɣezzifen ɣef tqeffalt n umaɣnu.", + "accessibility_hint": "Sin isitiyen i usefsex n umarag-a" + } + } +} \ No newline at end of file diff --git a/Localization/StringsConvertor/input/kab_KAB/ios-infoPlist.json b/Localization/StringsConvertor/input/kab_KAB/ios-infoPlist.json new file mode 100644 index 000000000..41128876a --- /dev/null +++ b/Localization/StringsConvertor/input/kab_KAB/ios-infoPlist.json @@ -0,0 +1,6 @@ +{ + "NSCameraUsageDescription": "Yettwaseqdac i tuṭṭfa n tewlafin deg usuffeɣ n waddaden", + "NSPhotoLibraryAddUsageDescription": "Yettwaseqdac i usekles n tewlafin deg temkarḍit n tewlafin", + "NewPostShortcutItemTitle": "Tasuffeɣt tamaynut", + "SearchShortcutItemTitle": "Nadi" +} diff --git a/Localization/StringsConvertor/input/kmr_TR/app.json b/Localization/StringsConvertor/input/kmr_TR/app.json index c360eb430..5d1d70fb0 100644 --- a/Localization/StringsConvertor/input/kmr_TR/app.json +++ b/Localization/StringsConvertor/input/kmr_TR/app.json @@ -45,8 +45,8 @@ "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" + "title": "Şandiyê jê bibe", + "message": "Ma tu dixwazî vê şandiyê jê bibî?" }, "clean_cache": { "title": "Pêşbîrê pak bike", @@ -82,6 +82,7 @@ "share_user": "%s parve bike", "share_post": "Şandiyê parve bike", "open_in_safari": "Di Safariyê de veke", + "open_in_browser": "Di gerokê de veke", "find_people": "Mirovan bo şopandinê bibîne", "manually_search": "Ji devlê bi destan lêgerînê bike", "skip": "Derbas bike", @@ -112,7 +113,7 @@ "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_reblog": "Ji nû ve 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ê" @@ -123,7 +124,7 @@ } }, "status": { - "user_reblogged": "%s ji nû ve hate nivîsandin", + "user_reblogged": "%s ji nû ve nivîsand", "user_replied_to": "Bersiv da %s", "show_post": "Şandiyê nîşan bide", "show_user_profile": "Profîla bikarhêner nîşan bide", @@ -139,7 +140,8 @@ "unreblog": "Ji nû ve nivîsandinê vegere", "favorite": "Bijarte", "unfavorite": "Nebijarte", - "menu": "Kulîn" + "menu": "Kulîn", + "hide": "Veşêre" }, "tag": { "url": "URL", @@ -148,6 +150,12 @@ "hashtag": "Hashtag", "email": "E-name", "emoji": "Emojî" + }, + "visibility": { + "unlisted": "Her kes dikare vê şandiyê bibîne lê nayê nîşandan di demnameya gelemperî de.", + "private": "Tenê şopînerên wan dikarin vê şandiyê bibînin.", + "private_from_me": "Tenê şopînerên min dikarin vê şandiyê bibînin.", + "direct": "Tenê bikarhênerê qalkirî dikare vê şandiyê bibîne." } }, "friendship": { @@ -192,10 +200,14 @@ }, "scene": { "welcome": { - "slogan": "Torên civakî\ndi destên te de." + "slogan": "Torên civakî\ndi destên te de.", + "get_started": "Dest pê bike", + "log_in": "Têkeve" }, "server_picker": { "title": "Rajekarekê hilbijêre,\nHer kîjan rajekar be.", + "subtitle": "Li gorî berjewendî, herêm, an jî armancek gelemperî civakekê hilbijêre.", + "subtitle_extend": "Li gorî berjewendî, herêm, an jî armancek gelemperî civakekê hilbijêre. Her civakek ji hêla rêxistinek an kesek bi tevahî serbixwe ve tê xebitandin.", "button": { "category": { "all": "Hemû", @@ -231,7 +243,7 @@ } }, "register": { - "title": "Ji me re hinekî qala xwe bike.", + "title": "Ji me re hinekî qala xwe bike %s", "input": { "avatar": { "delete": "Jê bibe" @@ -248,6 +260,12 @@ }, "password": { "placeholder": "pêborîn", + "require": "Pêdiviya pêborîna te ya herî kêm:", + "character_limit": "8 tîp", + "accessibility": { + "checked": "hate kontrolkirin", + "unchecked": "nehate kontrolkirin" + }, "hint": "Pêborîna te herî kêm divê ji 8 tîpan pêk bê" }, "invite": { @@ -285,7 +303,7 @@ }, "server_rules": { "title": "Hinek rêzikên bingehîn.", - "subtitle": "Ev rêzik ji aliyê rêvebirên %s ve tên sazkirin.", + "subtitle": "Ev rêzik ji aliyê çavdêrê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ê", @@ -298,7 +316,7 @@ "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" + "resend": "Ji nû ve bişîne" }, "dont_receive_email": { "title": "E-nameyê xwe kontrol bike", @@ -401,16 +419,26 @@ "segmented_control": { "posts": "Şandî", "replies": "Bersiv", - "media": "Medya" + "posts_and_replies": "Şandî û bersiv", + "media": "Medya", + "about": "Derbar" }, "relationship_action_alert": { + "confirm_mute_user": { + "title": "Ajimêrê bêdeng bike", + "message": "Ji bo bêdengkirina %s bipejirîne" + }, "confirm_unmute_user": { "title": "Ajimêrê bêdeng neke", - "message": "Ji bo vekirina bêdengkirinê bipejirîne %s" + "message": "Ji bo vekirina bêdengkirinê %s bipejirîne" }, - "confirm_unblock_usre": { + "confirm_block_user": { + "title": "Ajimêr asteng bike", + "message": "Ji bo rakirina astengkirinê %s bipejirîne" + }, + "confirm_unblock_user": { "title": "Astengiyê li ser ajimêr rake", - "message": "Ji bo rakirina astengkirinê bipejirîne %s" + "message": "Ji bo rakirina astengkirinê %s bipejirîne" } } }, @@ -461,12 +489,14 @@ "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", + "notification_description": { + "followed_you": "te şopand", + "favorited_your_post": "şandiya te hez kir", + "reblogged_your_post": "şandiya te ji nû ve nivisand", + "mentioned_you": "qale te kir", + "request_to_follow_you": "dixwazê te bişopîne", + "poll_has_ended": "rapirsî qediya" + }, "keyobard": { "show_everything": "Her tiştî nîşan bide", "show_mentions": "Qalkirinan nîşan bike" @@ -482,9 +512,16 @@ "appearance": { "title": "Xuyang", "automatic": "Xweber", - "light": "Her dem ronî", + "light": "Her dem ronahî", "dark": "Her dem tarî" }, + "look_and_feel": { + "title": "Xuyang", + "use_system": "Pergalê bi kar bîne", + "really_dark": "Tarî", + "sorta_dark": "Hinekî tarî", + "light": "Ronahî" + }, "notifications": { "title": "Agahdarî", "favorites": "Şandiyên min hez kir", @@ -500,7 +537,7 @@ } }, "preference": { - "title": "Hilbijarte", + "title": "Sazkarî", "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", @@ -526,14 +563,17 @@ } }, "report": { + "title_report": "Ragihandin", "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?", + "report_sent_title": "Spas ji bo ragihandina te, em ê binirxînin.", "send": "Ragihandinê bişîne", "skip_to_send": "Bêyî şirove bişîne", - "text_placeholder": "Şiroveyên daxwazkirê binivîsine an jî pê ve bike" + "text_placeholder": "Şiroveyên daxwazkirê binivîsine an jî pê ve bike", + "reported": "HATE RAGIHANDIN" }, "preview": { "keyboard": { diff --git a/Localization/StringsConvertor/input/ko_KR/app.json b/Localization/StringsConvertor/input/ko_KR/app.json index 571b14659..3f9f4c199 100644 --- a/Localization/StringsConvertor/input/ko_KR/app.json +++ b/Localization/StringsConvertor/input/ko_KR/app.json @@ -46,7 +46,7 @@ }, "delete_post": { "title": "정말로 이 게시물을 삭제하시겠습니까?", - "delete": "삭제" + "message": "Are you sure you want to delete this post?" }, "clean_cache": { "title": "캐시 삭제", @@ -82,6 +82,7 @@ "share_user": "%s를 공유", "share_post": "게시물 공유", "open_in_safari": "사파리에서 열기", + "open_in_browser": "Open in Browser", "find_people": "팔로우 할 사람들 찾기", "manually_search": "대신 수동으로 검색하기", "skip": "건너뛰기", @@ -139,7 +140,8 @@ "unreblog": "리블로그 취소", "favorite": "즐겨찾기", "unfavorite": "즐겨찾기 해제", - "menu": "메뉴" + "menu": "메뉴", + "hide": "Hide" }, "tag": { "url": "URL", @@ -148,6 +150,12 @@ "hashtag": "해시태그", "email": "이메일", "emoji": "에모지" + }, + "visibility": { + "unlisted": "Everyone can see this post but not display in the public timeline.", + "private": "Only their followers can see this post.", + "private_from_me": "Only my followers can see this post.", + "direct": "Only mentioned user can see this post." } }, "friendship": { @@ -192,10 +200,14 @@ }, "scene": { "welcome": { - "slogan": "소셜 네트워킹을\n여러분의 손에 돌려드립니다." + "slogan": "소셜 네트워킹을\n여러분의 손에 돌려드립니다.", + "get_started": "Get Started", + "log_in": "Log In" }, "server_picker": { "title": "서버를 고르세요,\n아무 서버나 좋습니다.", + "subtitle": "Pick a community based on your interests, region, or a general purpose one.", + "subtitle_extend": "Pick a community based on your interests, region, or a general purpose one. Each community is operated by an entirely independent organization or individual.", "button": { "category": { "all": "모두", @@ -222,7 +234,7 @@ "category": "분류" }, "input": { - "placeholder": "Find a server or join your own..." + "placeholder": "Search communities" }, "empty_state": { "finding_servers": "Finding available servers...", @@ -231,7 +243,7 @@ } }, "register": { - "title": "Tell us about you.", + "title": "Let’s get you set up on %s", "input": { "avatar": { "delete": "삭제" @@ -248,6 +260,12 @@ }, "password": { "placeholder": "암호", + "require": "Your password needs at least:", + "character_limit": "8 characters", + "accessibility": { + "checked": "checked", + "unchecked": "unchecked" + }, "hint": "암호는 최소 8글자 이상이어야 합니다" }, "invite": { @@ -285,7 +303,7 @@ }, "server_rules": { "title": "Some ground rules.", - "subtitle": "These rules are set by the admins of %s.", + "subtitle": "These are set and enforced by the %s moderators.", "prompt": "By continuing, you’re subject to the terms of service and privacy policy for %s.", "terms_of_service": "terms of service", "privacy_policy": "privacy policy", @@ -295,10 +313,10 @@ }, "confirm_email": { "title": "마지막으로.", - "subtitle": "We just sent an email to %s,\ntap the link to confirm your account.", + "subtitle": "Tap the link we emailed to you to verify your account.", "button": { "open_email_app": "Open Email App", - "dont_receive_email": "I never got an email" + "resend": "Resend" }, "dont_receive_email": { "title": "Check your email", @@ -401,16 +419,26 @@ "segmented_control": { "posts": "게시물", "replies": "답글", - "media": "미디어" + "posts_and_replies": "Posts and Replies", + "media": "미디어", + "about": "About" }, "relationship_action_alert": { + "confirm_mute_user": { + "title": "Mute Account", + "message": "Confirm to mute %s" + }, "confirm_unmute_user": { "title": "계정 뮤트 해제", "message": "%s 뮤트 해제 확인" }, - "confirm_unblock_usre": { - "title": "계정 차단 해제", - "message": "%s 차단 해제 확인" + "confirm_block_user": { + "title": "Block Account", + "message": "Confirm to block %s" + }, + "confirm_unblock_user": { + "title": "Unblock Account", + "message": "Confirm to unblock %s" } } }, @@ -461,12 +489,14 @@ "Everything": "Everything", "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", + "notification_description": { + "followed_you": "followed you", + "favorited_your_post": "favorited your post", + "reblogged_your_post": "reblogged your post", + "mentioned_you": "mentioned you", + "request_to_follow_you": "request to follow you", + "poll_has_ended": "poll has ended" + }, "keyobard": { "show_everything": "Show Everything", "show_mentions": "Show Mentions" @@ -485,6 +515,13 @@ "light": "Always Light", "dark": "Always Dark" }, + "look_and_feel": { + "title": "Look and Feel", + "use_system": "Use System", + "really_dark": "Really Dark", + "sorta_dark": "Sorta Dark", + "light": "Light" + }, "notifications": { "title": "Notifications", "favorites": "Favorites my post", @@ -526,14 +563,17 @@ } }, "report": { + "title_report": "Report", "title": "%s 신고하기", "step1": "1단계 (총 2단계)", "step2": "2단계 (총 2단계)", "content1": "신고에 추가하고 싶은 다른 게시물이 존재하나요?", "content2": "이 신고에 대해 중재자들이 알아야 할 것이 있나요?", + "report_sent_title": "Thanks for reporting, we’ll look into this.", "send": "신고 전송", "skip_to_send": "추가설명 없이 보내기", - "text_placeholder": "추가 설명을 적거나 붙여넣으세요" + "text_placeholder": "추가 설명을 적거나 붙여넣으세요", + "reported": "REPORTED" }, "preview": { "keyboard": { diff --git a/Localization/StringsConvertor/input/nl_NL/app.json b/Localization/StringsConvertor/input/nl_NL/app.json index d8ee1e574..ae8f2d2dd 100644 --- a/Localization/StringsConvertor/input/nl_NL/app.json +++ b/Localization/StringsConvertor/input/nl_NL/app.json @@ -46,7 +46,7 @@ }, "delete_post": { "title": "Weet u zeker dat u dit bericht wilt verwijderen?", - "delete": "Verwijderen" + "message": "Are you sure you want to delete this post?" }, "clean_cache": { "title": "Cache-geheugen Wissen", @@ -82,6 +82,7 @@ "share_user": "Delen %s", "share_post": "Bericht Delen", "open_in_safari": "Open in Safari", + "open_in_browser": "Open in Browser", "find_people": "Zoek mensen om te volgen", "manually_search": "Handmatig zoeken", "skip": "Overslaan", @@ -139,7 +140,8 @@ "unreblog": "Delen ongedaan maken", "favorite": "Toevoegen aan Favorieten", "unfavorite": "Verwijderen uit Favorieten", - "menu": "Menu" + "menu": "Menu", + "hide": "Hide" }, "tag": { "url": "URL", @@ -148,6 +150,12 @@ "hashtag": "Hashtag", "email": "Email", "emoji": "Emoji" + }, + "visibility": { + "unlisted": "Everyone can see this post but not display in the public timeline.", + "private": "Only their followers can see this post.", + "private_from_me": "Only my followers can see this post.", + "direct": "Only mentioned user can see this post." } }, "friendship": { @@ -192,10 +200,14 @@ }, "scene": { "welcome": { - "slogan": "Sociale media terug in uw handen." + "slogan": "Sociale media terug in uw handen.", + "get_started": "Get Started", + "log_in": "Log In" }, "server_picker": { "title": "Kies een server, welke dan ook.", + "subtitle": "Pick a community based on your interests, region, or a general purpose one.", + "subtitle_extend": "Pick a community based on your interests, region, or a general purpose one. Each community is operated by an entirely independent organization or individual.", "button": { "category": { "all": "Alles", @@ -248,6 +260,12 @@ }, "password": { "placeholder": "wachtwoord", + "require": "Your password needs at least:", + "character_limit": "8 characters", + "accessibility": { + "checked": "checked", + "unchecked": "unchecked" + }, "hint": "Uw wachtwoord moet ten minste acht tekens bevatten" }, "invite": { @@ -298,7 +316,7 @@ "subtitle": "We hebben een e-mail gestuurd naar %s,\nklik op de link om uw account te bevestigen.", "button": { "open_email_app": "Email Openen", - "dont_receive_email": "Ik heb geen email ontvangen" + "resend": "Resend" }, "dont_receive_email": { "title": "Controleer uw emailadres", @@ -401,16 +419,26 @@ "segmented_control": { "posts": "Berichten", "replies": "Reacties", - "media": "Media" + "posts_and_replies": "Posts and Replies", + "media": "Media", + "about": "About" }, "relationship_action_alert": { + "confirm_mute_user": { + "title": "Mute Account", + "message": "Confirm to mute %s" + }, "confirm_unmute_user": { "title": "Account Negeren", "message": "Bevestig om %s te negeren" }, - "confirm_unblock_usre": { - "title": "Account niet langer negeren", - "message": "Bevestig om %s te deblokkeren" + "confirm_block_user": { + "title": "Block Account", + "message": "Confirm to block %s" + }, + "confirm_unblock_user": { + "title": "Unblock Account", + "message": "Confirm to unblock %s" } } }, @@ -461,12 +489,14 @@ "Everything": "Alles", "Mentions": "Vermeldingen" }, - "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", + "notification_description": { + "followed_you": "followed you", + "favorited_your_post": "favorited your post", + "reblogged_your_post": "reblogged your post", + "mentioned_you": "mentioned you", + "request_to_follow_you": "request to follow you", + "poll_has_ended": "poll has ended" + }, "keyobard": { "show_everything": "Alles weergeven", "show_mentions": "Vermeldingen weergeven" @@ -485,6 +515,13 @@ "light": "Altijd Licht", "dark": "Altijd Donker" }, + "look_and_feel": { + "title": "Look and Feel", + "use_system": "Use System", + "really_dark": "Really Dark", + "sorta_dark": "Sorta Dark", + "light": "Light" + }, "notifications": { "title": "Meldingen", "favorites": "Mijn bericht als favoriet toevoegt", @@ -526,14 +563,17 @@ } }, "report": { + "title_report": "Report", "title": "Rapporteer %s", "step1": "Stap 1 van 2", "step2": "Stap 2 van 2", "content1": "Zijn er nog meer berichten die u aan het rapport wilt toevoegen?", "content2": "Is er iets anders over dit rapport dat de moderators zouden moeten weten?", + "report_sent_title": "Thanks for reporting, we’ll look into this.", "send": "Stuur rapport", "skip_to_send": "Verstuur zonder opmerkingen", - "text_placeholder": "Schrijf of plak aanvullende opmerkingen" + "text_placeholder": "Schrijf of plak aanvullende opmerkingen", + "reported": "REPORTED" }, "preview": { "keyboard": { diff --git a/Localization/StringsConvertor/input/pt_BR/app.json b/Localization/StringsConvertor/input/pt_BR/app.json index 5c01ae7e0..ad99e178d 100644 --- a/Localization/StringsConvertor/input/pt_BR/app.json +++ b/Localization/StringsConvertor/input/pt_BR/app.json @@ -45,8 +45,8 @@ "message": "Please enable the photo library access permission to save the photo." }, "delete_post": { - "title": "Are you sure you want to delete this post?", - "delete": "Delete" + "title": "Delete Post", + "message": "Are you sure you want to delete this post?" }, "clean_cache": { "title": "Clean Cache", @@ -82,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", @@ -139,7 +140,8 @@ "unreblog": "Undo reblog", "favorite": "Favorite", "unfavorite": "Unfavorite", - "menu": "Menu" + "menu": "Menu", + "hide": "Hide" }, "tag": { "url": "URL", @@ -148,6 +150,12 @@ "hashtag": "Hashtag", "email": "Email", "emoji": "Emoji" + }, + "visibility": { + "unlisted": "Everyone can see this post but not display in the public timeline.", + "private": "Only their followers can see this post.", + "private_from_me": "Only my followers can see this post.", + "direct": "Only mentioned user can see this post." } }, "friendship": { @@ -192,10 +200,14 @@ }, "scene": { "welcome": { - "slogan": "Social networking\nback in your hands." + "slogan": "Social networking\nback in your hands.", + "get_started": "Get Started", + "log_in": "Log In" }, "server_picker": { - "title": "Pick a server,\nany server.", + "title": "Mastodon is made of users in different communities.", + "subtitle": "Pick a community based on your interests, region, or a general purpose one.", + "subtitle_extend": "Pick a community based on your interests, region, or a general purpose one. Each community is operated by an entirely independent organization or individual.", "button": { "category": { "all": "All", @@ -222,7 +234,7 @@ "category": "CATEGORY" }, "input": { - "placeholder": "Find a server or join your own..." + "placeholder": "Search communities" }, "empty_state": { "finding_servers": "Finding available servers...", @@ -231,7 +243,7 @@ } }, "register": { - "title": "Tell us about you.", + "title": "Let’s get you set up on %s", "input": { "avatar": { "delete": "Delete" @@ -248,6 +260,12 @@ }, "password": { "placeholder": "password", + "require": "Your password needs at least:", + "character_limit": "8 characters", + "accessibility": { + "checked": "checked", + "unchecked": "unchecked" + }, "hint": "Your password needs at least eight characters" }, "invite": { @@ -285,7 +303,7 @@ }, "server_rules": { "title": "Some ground rules.", - "subtitle": "These rules are set by the admins of %s.", + "subtitle": "These are set and enforced by the %s moderators.", "prompt": "By continuing, you’re subject to the terms of service and privacy policy for %s.", "terms_of_service": "terms of service", "privacy_policy": "privacy policy", @@ -295,10 +313,10 @@ }, "confirm_email": { "title": "One last thing.", - "subtitle": "We just sent an email to %s,\ntap the link to confirm your account.", + "subtitle": "Tap the link we emailed to you to verify your account.", "button": { "open_email_app": "Open Email App", - "dont_receive_email": "I never got an email" + "resend": "Resend" }, "dont_receive_email": { "title": "Check your email", @@ -401,14 +419,24 @@ "segmented_control": { "posts": "Posts", "replies": "Replies", - "media": "Media" + "posts_and_replies": "Posts and Replies", + "media": "Media", + "about": "About" }, "relationship_action_alert": { + "confirm_mute_user": { + "title": "Mute Account", + "message": "Confirm to mute %s" + }, "confirm_unmute_user": { "title": "Unmute Account", "message": "Confirm to unmute %s" }, - "confirm_unblock_usre": { + "confirm_block_user": { + "title": "Block Account", + "message": "Confirm to block %s" + }, + "confirm_unblock_user": { "title": "Unblock Account", "message": "Confirm to unblock %s" } @@ -461,12 +489,14 @@ "Everything": "Everything", "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", + "notification_description": { + "followed_you": "followed you", + "favorited_your_post": "favorited your post", + "reblogged_your_post": "reblogged your post", + "mentioned_you": "mentioned you", + "request_to_follow_you": "request to follow you", + "poll_has_ended": "poll has ended" + }, "keyobard": { "show_everything": "Show Everything", "show_mentions": "Show Mentions" @@ -485,6 +515,13 @@ "light": "Always Light", "dark": "Always Dark" }, + "look_and_feel": { + "title": "Look and Feel", + "use_system": "Use System", + "really_dark": "Really Dark", + "sorta_dark": "Sorta Dark", + "light": "Light" + }, "notifications": { "title": "Notifications", "favorites": "Favorites my post", @@ -526,14 +563,17 @@ } }, "report": { + "title_report": "Report", "title": "Report %s", "step1": "Step 1 of 2", "step2": "Step 2 of 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?", + "report_sent_title": "Thanks for reporting, we’ll look into this.", "send": "Send Report", "skip_to_send": "Send without comment", - "text_placeholder": "Type or paste additional comments" + "text_placeholder": "Type or paste additional comments", + "reported": "REPORTED" }, "preview": { "keyboard": { diff --git a/Localization/StringsConvertor/input/pt_PT/app.json b/Localization/StringsConvertor/input/pt_PT/app.json index 5c01ae7e0..ad99e178d 100644 --- a/Localization/StringsConvertor/input/pt_PT/app.json +++ b/Localization/StringsConvertor/input/pt_PT/app.json @@ -45,8 +45,8 @@ "message": "Please enable the photo library access permission to save the photo." }, "delete_post": { - "title": "Are you sure you want to delete this post?", - "delete": "Delete" + "title": "Delete Post", + "message": "Are you sure you want to delete this post?" }, "clean_cache": { "title": "Clean Cache", @@ -82,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", @@ -139,7 +140,8 @@ "unreblog": "Undo reblog", "favorite": "Favorite", "unfavorite": "Unfavorite", - "menu": "Menu" + "menu": "Menu", + "hide": "Hide" }, "tag": { "url": "URL", @@ -148,6 +150,12 @@ "hashtag": "Hashtag", "email": "Email", "emoji": "Emoji" + }, + "visibility": { + "unlisted": "Everyone can see this post but not display in the public timeline.", + "private": "Only their followers can see this post.", + "private_from_me": "Only my followers can see this post.", + "direct": "Only mentioned user can see this post." } }, "friendship": { @@ -192,10 +200,14 @@ }, "scene": { "welcome": { - "slogan": "Social networking\nback in your hands." + "slogan": "Social networking\nback in your hands.", + "get_started": "Get Started", + "log_in": "Log In" }, "server_picker": { - "title": "Pick a server,\nany server.", + "title": "Mastodon is made of users in different communities.", + "subtitle": "Pick a community based on your interests, region, or a general purpose one.", + "subtitle_extend": "Pick a community based on your interests, region, or a general purpose one. Each community is operated by an entirely independent organization or individual.", "button": { "category": { "all": "All", @@ -222,7 +234,7 @@ "category": "CATEGORY" }, "input": { - "placeholder": "Find a server or join your own..." + "placeholder": "Search communities" }, "empty_state": { "finding_servers": "Finding available servers...", @@ -231,7 +243,7 @@ } }, "register": { - "title": "Tell us about you.", + "title": "Let’s get you set up on %s", "input": { "avatar": { "delete": "Delete" @@ -248,6 +260,12 @@ }, "password": { "placeholder": "password", + "require": "Your password needs at least:", + "character_limit": "8 characters", + "accessibility": { + "checked": "checked", + "unchecked": "unchecked" + }, "hint": "Your password needs at least eight characters" }, "invite": { @@ -285,7 +303,7 @@ }, "server_rules": { "title": "Some ground rules.", - "subtitle": "These rules are set by the admins of %s.", + "subtitle": "These are set and enforced by the %s moderators.", "prompt": "By continuing, you’re subject to the terms of service and privacy policy for %s.", "terms_of_service": "terms of service", "privacy_policy": "privacy policy", @@ -295,10 +313,10 @@ }, "confirm_email": { "title": "One last thing.", - "subtitle": "We just sent an email to %s,\ntap the link to confirm your account.", + "subtitle": "Tap the link we emailed to you to verify your account.", "button": { "open_email_app": "Open Email App", - "dont_receive_email": "I never got an email" + "resend": "Resend" }, "dont_receive_email": { "title": "Check your email", @@ -401,14 +419,24 @@ "segmented_control": { "posts": "Posts", "replies": "Replies", - "media": "Media" + "posts_and_replies": "Posts and Replies", + "media": "Media", + "about": "About" }, "relationship_action_alert": { + "confirm_mute_user": { + "title": "Mute Account", + "message": "Confirm to mute %s" + }, "confirm_unmute_user": { "title": "Unmute Account", "message": "Confirm to unmute %s" }, - "confirm_unblock_usre": { + "confirm_block_user": { + "title": "Block Account", + "message": "Confirm to block %s" + }, + "confirm_unblock_user": { "title": "Unblock Account", "message": "Confirm to unblock %s" } @@ -461,12 +489,14 @@ "Everything": "Everything", "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", + "notification_description": { + "followed_you": "followed you", + "favorited_your_post": "favorited your post", + "reblogged_your_post": "reblogged your post", + "mentioned_you": "mentioned you", + "request_to_follow_you": "request to follow you", + "poll_has_ended": "poll has ended" + }, "keyobard": { "show_everything": "Show Everything", "show_mentions": "Show Mentions" @@ -485,6 +515,13 @@ "light": "Always Light", "dark": "Always Dark" }, + "look_and_feel": { + "title": "Look and Feel", + "use_system": "Use System", + "really_dark": "Really Dark", + "sorta_dark": "Sorta Dark", + "light": "Light" + }, "notifications": { "title": "Notifications", "favorites": "Favorites my post", @@ -526,14 +563,17 @@ } }, "report": { + "title_report": "Report", "title": "Report %s", "step1": "Step 1 of 2", "step2": "Step 2 of 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?", + "report_sent_title": "Thanks for reporting, we’ll look into this.", "send": "Send Report", "skip_to_send": "Send without comment", - "text_placeholder": "Type or paste additional comments" + "text_placeholder": "Type or paste additional comments", + "reported": "REPORTED" }, "preview": { "keyboard": { diff --git a/Localization/StringsConvertor/input/ro_RO/app.json b/Localization/StringsConvertor/input/ro_RO/app.json index 3927247ee..b9ef116dc 100644 --- a/Localization/StringsConvertor/input/ro_RO/app.json +++ b/Localization/StringsConvertor/input/ro_RO/app.json @@ -45,8 +45,8 @@ "message": "Please enable the photo library access permission to save the photo." }, "delete_post": { - "title": "Are you sure you want to delete this post?", - "delete": "Delete" + "title": "Delete Post", + "message": "Are you sure you want to delete this post?" }, "clean_cache": { "title": "Clean Cache", @@ -82,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", @@ -139,7 +140,8 @@ "unreblog": "Undo reblog", "favorite": "Favorite", "unfavorite": "Unfavorite", - "menu": "Menu" + "menu": "Menu", + "hide": "Hide" }, "tag": { "url": "URL", @@ -148,6 +150,12 @@ "hashtag": "Hashtag", "email": "Email", "emoji": "Emoji" + }, + "visibility": { + "unlisted": "Everyone can see this post but not display in the public timeline.", + "private": "Only their followers can see this post.", + "private_from_me": "Only my followers can see this post.", + "direct": "Only mentioned user can see this post." } }, "friendship": { @@ -192,10 +200,14 @@ }, "scene": { "welcome": { - "slogan": "Social networking\nback in your hands." + "slogan": "Social networking\nback in your hands.", + "get_started": "Get Started", + "log_in": "Log In" }, "server_picker": { - "title": "Pick a server,\nany server.", + "title": "Mastodon is made of users in different communities.", + "subtitle": "Pick a community based on your interests, region, or a general purpose one.", + "subtitle_extend": "Pick a community based on your interests, region, or a general purpose one. Each community is operated by an entirely independent organization or individual.", "button": { "category": { "all": "All", @@ -222,7 +234,7 @@ "category": "CATEGORY" }, "input": { - "placeholder": "Find a server or join your own..." + "placeholder": "Search communities" }, "empty_state": { "finding_servers": "Finding available servers...", @@ -231,7 +243,7 @@ } }, "register": { - "title": "Tell us about you.", + "title": "Let’s get you set up on %s", "input": { "avatar": { "delete": "Delete" @@ -248,6 +260,12 @@ }, "password": { "placeholder": "password", + "require": "Your password needs at least:", + "character_limit": "8 characters", + "accessibility": { + "checked": "checked", + "unchecked": "unchecked" + }, "hint": "Your password needs at least eight characters" }, "invite": { @@ -285,7 +303,7 @@ }, "server_rules": { "title": "Some ground rules.", - "subtitle": "These rules are set by the admins of %s.", + "subtitle": "These are set and enforced by the %s moderators.", "prompt": "By continuing, you’re subject to the terms of service and privacy policy for %s.", "terms_of_service": "terms of service", "privacy_policy": "privacy policy", @@ -295,10 +313,10 @@ }, "confirm_email": { "title": "One last thing.", - "subtitle": "We just sent an email to %s,\ntap the link to confirm your account.", + "subtitle": "Tap the link we emailed to you to verify your account.", "button": { "open_email_app": "Open Email App", - "dont_receive_email": "I never got an email" + "resend": "Resend" }, "dont_receive_email": { "title": "Check your email", @@ -401,14 +419,24 @@ "segmented_control": { "posts": "Posts", "replies": "Replies", - "media": "Media" + "posts_and_replies": "Posts and Replies", + "media": "Media", + "about": "About" }, "relationship_action_alert": { + "confirm_mute_user": { + "title": "Mute Account", + "message": "Confirm to mute %s" + }, "confirm_unmute_user": { "title": "Unmute Account", "message": "Confirm to unmute %s" }, - "confirm_unblock_usre": { + "confirm_block_user": { + "title": "Block Account", + "message": "Confirm to block %s" + }, + "confirm_unblock_user": { "title": "Unblock Account", "message": "Confirm to unblock %s" } @@ -461,12 +489,14 @@ "Everything": "Everything", "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", + "notification_description": { + "followed_you": "followed you", + "favorited_your_post": "favorited your post", + "reblogged_your_post": "reblogged your post", + "mentioned_you": "mentioned you", + "request_to_follow_you": "request to follow you", + "poll_has_ended": "poll has ended" + }, "keyobard": { "show_everything": "Show Everything", "show_mentions": "Show Mentions" @@ -485,6 +515,13 @@ "light": "Always Light", "dark": "Always Dark" }, + "look_and_feel": { + "title": "Look and Feel", + "use_system": "Use System", + "really_dark": "Really Dark", + "sorta_dark": "Sorta Dark", + "light": "Light" + }, "notifications": { "title": "Notifications", "favorites": "Favorites my post", @@ -526,14 +563,17 @@ } }, "report": { + "title_report": "Report", "title": "Report %s", "step1": "Step 1 of 2", "step2": "Step 2 of 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?", + "report_sent_title": "Thanks for reporting, we’ll look into this.", "send": "Send Report", "skip_to_send": "Send without comment", - "text_placeholder": "Type or paste additional comments" + "text_placeholder": "Type or paste additional comments", + "reported": "REPORTED" }, "preview": { "keyboard": { diff --git a/Localization/StringsConvertor/input/ru_RU/app.json b/Localization/StringsConvertor/input/ru_RU/app.json index c1ad3ee49..58cedfc7f 100644 --- a/Localization/StringsConvertor/input/ru_RU/app.json +++ b/Localization/StringsConvertor/input/ru_RU/app.json @@ -46,7 +46,7 @@ }, "delete_post": { "title": "Вы уверены, что хотите удалить этот пост?", - "delete": "Удалить" + "message": "Are you sure you want to delete this post?" }, "clean_cache": { "title": "Очистка кэша", @@ -67,7 +67,7 @@ "done": "Готово", "confirm": "Подтвердить", "continue": "Продолжить", - "compose": "Compose", + "compose": "Написать", "cancel": "Отмена", "discard": "Отмена", "try_again": "Попробовать снова", @@ -82,6 +82,7 @@ "share_user": "Поделиться %s", "share_post": "Поделиться постом", "open_in_safari": "Открыть в Safari", + "open_in_browser": "Открыть в браузере", "find_people": "Подпишитесь на людей", "manually_search": "Найти вручную", "skip": "Пропустить", @@ -139,7 +140,8 @@ "unreblog": "Убрать продвижение", "favorite": "Добавить в избранное", "unfavorite": "Убрать из избранного", - "menu": "Меню" + "menu": "Меню", + "hide": "Hide" }, "tag": { "url": "Ссылка", @@ -148,6 +150,12 @@ "hashtag": "Хэштег", "email": "E-mail", "emoji": "Эмодзи" + }, + "visibility": { + "unlisted": "Everyone can see this post but not display in the public timeline.", + "private": "Only their followers can see this post.", + "private_from_me": "Only my followers can see this post.", + "direct": "Only mentioned user can see this post." } }, "friendship": { @@ -192,10 +200,14 @@ }, "scene": { "welcome": { - "slogan": "Социальная сеть\nпод вашим контролем." + "slogan": "Социальная сеть\nпод вашим контролем.", + "get_started": "Get Started", + "log_in": "Вход" }, "server_picker": { "title": "Выберите сервер,\nлюбой сервер.", + "subtitle": "Выберите сообщество на основе своих интересов, региона или общей тематики.", + "subtitle_extend": "Pick a community based on your interests, region, or a general purpose one. Each community is operated by an entirely independent organization or individual.", "button": { "category": { "all": "Все", @@ -248,6 +260,12 @@ }, "password": { "placeholder": "пароль", + "require": "Your password needs at least:", + "character_limit": "8 characters", + "accessibility": { + "checked": "checked", + "unchecked": "unchecked" + }, "hint": "Пароль должен содержать не менее восьми символов" }, "invite": { @@ -298,7 +316,7 @@ "subtitle": "Мы только что отправили письмо на\n%s.\nНажмите на ссылку в нём, чтобы\nподтвердить свою учётную запись.", "button": { "open_email_app": "Открыть приложение почты", - "dont_receive_email": "Я не получил письма" + "resend": "Resend" }, "dont_receive_email": { "title": "Проверьте свой e-mail адрес", @@ -401,16 +419,26 @@ "segmented_control": { "posts": "Посты", "replies": "Ответы", - "media": "Медиа" + "posts_and_replies": "Posts and Replies", + "media": "Медиа", + "about": "About" }, "relationship_action_alert": { + "confirm_mute_user": { + "title": "Mute Account", + "message": "Confirm to mute %s" + }, "confirm_unmute_user": { "title": "Убрать из игнорируемых", "message": "Убрать %s из игнорируемых?" }, - "confirm_unblock_usre": { - "title": "Разблокировать", - "message": "Убрать %s из списка блокировки?" + "confirm_block_user": { + "title": "Block Account", + "message": "Confirm to block %s" + }, + "confirm_unblock_user": { + "title": "Unblock Account", + "message": "Confirm to unblock %s" } } }, @@ -461,12 +489,14 @@ "Everything": "Все", "Mentions": "Упоминания" }, - "user_followed_you": "%s подписался (-ась)", - "user_favorited your post": "%s favorited your post", - "user_reblogged_your_post": "%s reblogged your post", - "user_mentioned_you": "%s упомянул вас", - "user_requested_to_follow_you": "%s запрашивает подписку", - "user_your_poll_has_ended": "%s Your poll has ended", + "notification_description": { + "followed_you": "followed you", + "favorited_your_post": "favorited your post", + "reblogged_your_post": "reblogged your post", + "mentioned_you": "mentioned you", + "request_to_follow_you": "request to follow you", + "poll_has_ended": "poll has ended" + }, "keyobard": { "show_everything": "Показать все", "show_mentions": "Показать упоминания" @@ -485,6 +515,13 @@ "light": "Светлая тема", "dark": "Тёмная тема" }, + "look_and_feel": { + "title": "Look and Feel", + "use_system": "Use System", + "really_dark": "Really Dark", + "sorta_dark": "Sorta Dark", + "light": "Light" + }, "notifications": { "title": "Уведомления", "favorites": "Добавляет мой пост в избранное", @@ -526,14 +563,17 @@ } }, "report": { + "title_report": "Report", "title": "Пожаловаться на %s", "step1": "Шаг 1 из 2", "step2": "Шаг 2 из 2", "content1": "Есть ли другие сообщения, которые вы хотите добавить в отчёт?", "content2": "Есть ли что-то, что модераторы должны знать об этом сообщении?", + "report_sent_title": "Thanks for reporting, we’ll look into this.", "send": "Пожаловаться", "skip_to_send": "Отправить без комментария", - "text_placeholder": "Дополнительные комментарии" + "text_placeholder": "Дополнительные комментарии", + "reported": "REPORTED" }, "preview": { "keyboard": { diff --git a/Localization/StringsConvertor/input/sv_FI/Localizable.stringsdict b/Localization/StringsConvertor/input/sv_FI/Localizable.stringsdict index 65316e3d0..eec977a68 100644 --- a/Localization/StringsConvertor/input/sv_FI/Localizable.stringsdict +++ b/Localization/StringsConvertor/input/sv_FI/Localizable.stringsdict @@ -13,15 +13,15 @@ <key>NSStringFormatValueTypeKey</key> <string>ld</string> <key>one</key> - <string>1 unread notification</string> + <string>1 lukematon ilmoitus</string> <key>other</key> - <string>%ld unread notification</string> + <string>%ld lukematonta ilmoitusta</string> </dict> </dict> <key>a11y.plural.count.input_limit_exceeds</key> <dict> <key>NSStringLocalizedFormatKey</key> - <string>Input limit exceeds %#@character_count@</string> + <string>Syöterajoitus ylittyy %#@character_count@</string> <key>character_count</key> <dict> <key>NSStringFormatSpecTypeKey</key> @@ -29,15 +29,15 @@ <key>NSStringFormatValueTypeKey</key> <string>ld</string> <key>one</key> - <string>1 character</string> + <string>1 merkki</string> <key>other</key> - <string>%ld characters</string> + <string>%ld merkkiä</string> </dict> </dict> <key>a11y.plural.count.input_limit_remains</key> <dict> <key>NSStringLocalizedFormatKey</key> - <string>Input limit remains %#@character_count@</string> + <string>Syöterajoitus ylittyy %#@character_count@ päästä</string> <key>character_count</key> <dict> <key>NSStringFormatSpecTypeKey</key> @@ -45,9 +45,9 @@ <key>NSStringFormatValueTypeKey</key> <string>ld</string> <key>one</key> - <string>1 character</string> + <string>1 merkki</string> <key>other</key> - <string>%ld characters</string> + <string>%ld merkkiä</string> </dict> </dict> <key>plural.count.metric_formatted.post</key> @@ -61,9 +61,9 @@ <key>NSStringFormatValueTypeKey</key> <string>ld</string> <key>one</key> - <string>post</string> + <string>julkaisu</string> <key>other</key> - <string>posts</string> + <string>julkaisut</string> </dict> </dict> <key>plural.count.post</key> @@ -77,9 +77,9 @@ <key>NSStringFormatValueTypeKey</key> <string>ld</string> <key>one</key> - <string>1 post</string> + <string>1 julkaisu</string> <key>other</key> - <string>%ld posts</string> + <string>%ld julkaisua</string> </dict> </dict> <key>plural.count.favorite</key> @@ -93,9 +93,9 @@ <key>NSStringFormatValueTypeKey</key> <string>ld</string> <key>one</key> - <string>1 favorite</string> + <string>1 suosikki</string> <key>other</key> - <string>%ld favorites</string> + <string>%ld suosikkia</string> </dict> </dict> <key>plural.count.reblog</key> @@ -109,9 +109,9 @@ <key>NSStringFormatValueTypeKey</key> <string>ld</string> <key>one</key> - <string>1 reblog</string> + <string>1 edelleen jako</string> <key>other</key> - <string>%ld reblogs</string> + <string>%ld edelleen jakoa</string> </dict> </dict> <key>plural.count.vote</key> @@ -125,9 +125,9 @@ <key>NSStringFormatValueTypeKey</key> <string>ld</string> <key>one</key> - <string>1 vote</string> + <string>1 ääni</string> <key>other</key> - <string>%ld votes</string> + <string>%ld ääntä</string> </dict> </dict> <key>plural.count.voter</key> @@ -141,9 +141,9 @@ <key>NSStringFormatValueTypeKey</key> <string>ld</string> <key>one</key> - <string>1 voter</string> + <string>1 vastaaja</string> <key>other</key> - <string>%ld voters</string> + <string>%ld vastaajaa</string> </dict> </dict> <key>plural.people_talking</key> @@ -157,9 +157,9 @@ <key>NSStringFormatValueTypeKey</key> <string>ld</string> <key>one</key> - <string>1 people talking</string> + <string>1 ihminen puhuu</string> <key>other</key> - <string>%ld people talking</string> + <string>%ld ihmistä puhuu</string> </dict> </dict> <key>plural.count.following</key> @@ -173,9 +173,9 @@ <key>NSStringFormatValueTypeKey</key> <string>ld</string> <key>one</key> - <string>1 following</string> + <string>1 seurataan</string> <key>other</key> - <string>%ld following</string> + <string>%ld seurataan</string> </dict> </dict> <key>plural.count.follower</key> @@ -189,9 +189,9 @@ <key>NSStringFormatValueTypeKey</key> <string>ld</string> <key>one</key> - <string>1 följare</string> + <string>1 seuraaja</string> <key>other</key> - <string>%ld följare</string> + <string>%ld seuraajaa</string> </dict> </dict> <key>date.year.left</key> @@ -205,9 +205,9 @@ <key>NSStringFormatValueTypeKey</key> <string>ld</string> <key>one</key> - <string>1 year left</string> + <string>1 vuosi jäljellä</string> <key>other</key> - <string>%ld years left</string> + <string>%ld vuotta jäljellä</string> </dict> </dict> <key>date.month.left</key> @@ -221,9 +221,9 @@ <key>NSStringFormatValueTypeKey</key> <string>ld</string> <key>one</key> - <string>1 months left</string> + <string>1 kuukausi jäljellä</string> <key>other</key> - <string>%ld months left</string> + <string>%ld kuukautta jäljellä</string> </dict> </dict> <key>date.day.left</key> @@ -237,9 +237,9 @@ <key>NSStringFormatValueTypeKey</key> <string>ld</string> <key>one</key> - <string>1 day left</string> + <string>1 päivä jäljellä</string> <key>other</key> - <string>%ld days left</string> + <string>%ld päivää jäljellä</string> </dict> </dict> <key>date.hour.left</key> @@ -253,9 +253,9 @@ <key>NSStringFormatValueTypeKey</key> <string>ld</string> <key>one</key> - <string>1 hour left</string> + <string>1 tunti jäljellä</string> <key>other</key> - <string>%ld hours left</string> + <string>%ld tuntia jäljellä</string> </dict> </dict> <key>date.minute.left</key> @@ -269,9 +269,9 @@ <key>NSStringFormatValueTypeKey</key> <string>ld</string> <key>one</key> - <string>1 minute left</string> + <string>1 minuutti jäljellä</string> <key>other</key> - <string>%ld minutes left</string> + <string>%ld minuuttia jäljellä</string> </dict> </dict> <key>date.second.left</key> @@ -285,9 +285,9 @@ <key>NSStringFormatValueTypeKey</key> <string>ld</string> <key>one</key> - <string>1 second left</string> + <string>1 sekuntti</string> <key>other</key> - <string>%ld seconds left</string> + <string>%ld sekunttia jäljellä</string> </dict> </dict> <key>date.year.ago.abbr</key> @@ -301,9 +301,9 @@ <key>NSStringFormatValueTypeKey</key> <string>ld</string> <key>one</key> - <string>1y ago</string> + <string>1v sitten</string> <key>other</key> - <string>%ldy ago</string> + <string>%ldv sitten</string> </dict> </dict> <key>date.month.ago.abbr</key> @@ -317,9 +317,9 @@ <key>NSStringFormatValueTypeKey</key> <string>ld</string> <key>one</key> - <string>1M ago</string> + <string>1kk sitten</string> <key>other</key> - <string>%ldM ago</string> + <string>%ldkk sitten</string> </dict> </dict> <key>date.day.ago.abbr</key> @@ -333,9 +333,9 @@ <key>NSStringFormatValueTypeKey</key> <string>ld</string> <key>one</key> - <string>1d ago</string> + <string>1pv sitten</string> <key>other</key> - <string>%ldd ago</string> + <string>%ldpv sitten</string> </dict> </dict> <key>date.hour.ago.abbr</key> @@ -349,9 +349,9 @@ <key>NSStringFormatValueTypeKey</key> <string>ld</string> <key>one</key> - <string>1h ago</string> + <string>1t sitten</string> <key>other</key> - <string>%ldh ago</string> + <string>%ldt sitten</string> </dict> </dict> <key>date.minute.ago.abbr</key> @@ -365,9 +365,9 @@ <key>NSStringFormatValueTypeKey</key> <string>ld</string> <key>one</key> - <string>1m ago</string> + <string>1min sitten</string> <key>other</key> - <string>%ldm ago</string> + <string>%ldmin sitten</string> </dict> </dict> <key>date.second.ago.abbr</key> @@ -381,9 +381,9 @@ <key>NSStringFormatValueTypeKey</key> <string>ld</string> <key>one</key> - <string>1s ago</string> + <string>1s sitten</string> <key>other</key> - <string>%lds ago</string> + <string>%lds sitten</string> </dict> </dict> </dict> diff --git a/Localization/StringsConvertor/input/sv_FI/app.json b/Localization/StringsConvertor/input/sv_FI/app.json index 7acf48755..669ee4371 100644 --- a/Localization/StringsConvertor/input/sv_FI/app.json +++ b/Localization/StringsConvertor/input/sv_FI/app.json @@ -2,555 +2,595 @@ "common": { "alerts": { "common": { - "please_try_again": "Var god försök igen.", - "please_try_again_later": "Var god försök igen senare." + "please_try_again": "Yritä uudelleen.", + "please_try_again_later": "Yritä uudelleen myöhemmin." }, "sign_up_failure": { - "title": "Sign Up Failure" + "title": "Rekisteröinti epäonnistui" }, "server_error": { - "title": "Serverfel" + "title": "Palvelinvirhe" }, "vote_failure": { "title": "Vote Failure", - "poll_ended": "Omröstningen har avslutats" + "poll_ended": "Kysely on päättynyt" }, "discard_post_content": { - "title": "Discard Draft", + "title": "Hylkää luonnos", "message": "Confirm to discard composed post content." }, "publish_post_failure": { - "title": "Publish Failure", - "message": "Failed to publish the post.\nPlease check your internet connection.", + "title": "Julkaiseminen epäonnistui", + "message": "Julkaisun julkaiseminen epäonnistui.\nTarkista internet-yhteytesi.", "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." + "more_than_one_video": "Ei voi liittä yhtä videota enempää." } }, "edit_profile_failure": { - "title": "Edit Profile Error", - "message": "Cannot edit profile. Please try again." + "title": "Virhe profiilin muokkauksessa", + "message": "Profiilia ei voida muoka. Yritä uudelleen." }, "sign_out": { - "title": "Sign Out", - "message": "Är du säker på att du vill logga ut?", - "confirm": "Sign Out" + "title": "Kirjaudu ulos", + "message": "Haluatko varmasti kirjautua ulos?", + "confirm": "Kirjaudu ulos" }, "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": "Block Domain" + "block_entire_domain": "Estä verkkotunnus" }, "save_photo_failure": { - "title": "Save Photo Failure", + "title": "Kuvan tallentaminen epäonnistui", "message": "Please enable the photo library access permission to save the photo." }, "delete_post": { - "title": "Are you sure you want to delete this post?", - "delete": "Radera" + "title": "Haluatko varmasti poistaa tämän julkaisun?", + "message": "Are you sure you want to delete this post?" }, "clean_cache": { - "title": "Clean Cache", - "message": "Successfully cleaned %s cache." + "title": "Puhdista välimuisti", + "message": "%s välimuisti tyhjennetty onnistuneesti." } }, "controls": { "actions": { - "back": "Back", - "next": "Next", - "previous": "Previous", - "open": "Open", - "add": "Add", - "remove": "Remove", - "edit": "Redigera", - "save": "Spara", + "back": "Takaisin", + "next": "Seuraava", + "previous": "Edellinen", + "open": "Avaa", + "add": "Lisää", + "remove": "Poista", + "edit": "Muokkaa", + "save": "Tallenna", "ok": "OK", - "done": "Done", - "confirm": "Confirm", - "continue": "Fortsätt", - "compose": "Compose", - "cancel": "Avbryt", - "discard": "Discard", - "try_again": "Försök igen", - "take_photo": "Take Photo", - "save_photo": "Save Photo", - "copy_photo": "Copy Photo", - "sign_in": "Sign In", - "sign_up": "Sign Up", - "see_more": "See More", - "preview": "Preview", - "share": "Dela", - "share_user": "Dela %s", - "share_post": "Share Post", - "open_in_safari": "Öppna i Safari", - "find_people": "Find people to follow", + "done": "Valmis", + "confirm": "Vahvista", + "continue": "Jatka", + "compose": "Koosta", + "cancel": "Kumoa", + "discard": "Hylkää", + "try_again": "Yritä uudelleen", + "take_photo": "Ota kuva", + "save_photo": "Tallenna kuva", + "copy_photo": "Kopioi kuva", + "sign_in": "Kirjaudu sisään", + "sign_up": "Rekisteröidy", + "see_more": "Näytä lisää", + "preview": "Esikatselu", + "share": "Jaa", + "share_user": "Jaa %s", + "share_post": "Jaa julkaisu", + "open_in_safari": "Avaa Safarissa", + "open_in_browser": "Open in Browser", + "find_people": "Löydä tilejä seurattavaksi", "manually_search": "Manually search instead", - "skip": "Skip", - "reply": "Reply", - "report_user": "Rapportera %s", - "block_domain": "Block %s", - "unblock_domain": "Unblock %s", - "settings": "Inställningar", - "delete": "Radera" + "skip": "Ohita", + "reply": "Vastaa", + "report_user": "Ilmianna %s", + "block_domain": "Estä %s", + "unblock_domain": "Poista esto %s", + "settings": "Asetukset", + "delete": "Poista" }, "tabs": { - "home": "Home", - "search": "Search", - "notification": "Notification", - "profile": "Profil" + "home": "Koti", + "search": "Haku", + "notification": "Ilmoitus", + "profile": "Profiili" }, "keyboard": { "common": { - "switch_to_tab": "Switch to %s", - "compose_new_post": "Compose New Post", - "show_favorites": "Show Favorites", - "open_settings": "Open Settings" + "switch_to_tab": "Vaihda %s", + "compose_new_post": "Koosta uusi julkaisu", + "show_favorites": "Näytä suosikit", + "open_settings": "Avaa asetukset" }, "timeline": { - "previous_status": "Previous Post", - "next_status": "Next Post", - "open_status": "Open Post", - "open_author_profile": "Open Author's Profile", - "open_reblogger_profile": "Open Reblogger's Profile", - "reply_status": "Reply to Post", + "previous_status": "Edellinen julkaisu", + "next_status": "Seuraava julkaisu", + "open_status": "Avaa julkaisu", + "open_author_profile": "Avaa tekijän profiili", + "open_reblogger_profile": "Avaa edelleen jakajan profiili", + "reply_status": "Vastaa julkaisuun", "toggle_reblog": "Toggle Reblog on Post", "toggle_favorite": "Toggle Favorite on Post", - "toggle_content_warning": "Toggle Content Warning", + "toggle_content_warning": "Vaihda sisältövaroitus", "preview_image": "Preview Image" }, "segmented_control": { "previous_section": "Previous Section", - "next_section": "Next Section" + "next_section": "Seuraava lohko" } }, "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 jakoi edelleen", + "user_replied_to": "Vastasi %s:lle", + "show_post": "Näytä julkaisu", + "show_user_profile": "Näytä tili", + "content_warning": "Sisältövaroitus", + "media_content_warning": "Napauta mistä tahansa paljastaaksesi", "poll": { "vote": "Vote", - "closed": "Closed" + "closed": "Suljettu" }, "actions": { - "reply": "Reply", - "reblog": "Reblog", - "unreblog": "Undo reblog", + "reply": "Vastaa", + "reblog": "Jaa edelleen", + "unreblog": "Peru edelleen jako", "favorite": "Favorite", "unfavorite": "Unfavorite", - "menu": "Meny" + "menu": "Valikko", + "hide": "Dölj" }, "tag": { "url": "URL", "mention": "Mention", - "link": "Link", - "hashtag": "Hashtag", - "email": "Email", + "link": "Linkki", + "hashtag": "Hashtagi", + "email": "Sähköposti", "emoji": "Emoji" + }, + "visibility": { + "unlisted": "Everyone can see this post but not display in the public timeline.", + "private": "Only their followers can see this post.", + "private_from_me": "Only my followers can see this post.", + "direct": "Only mentioned user can see this post." } }, "friendship": { - "follow": "Följ", - "following": "Följer", - "request": "Request", - "pending": "Pending", - "block": "Block", - "block_user": "Block %s", - "block_domain": "Block %s", - "unblock": "Unblock", + "follow": "Seuraa", + "following": "Seurataan", + "request": "Pyydä", + "pending": "Pyydetty", + "block": "Estä", + "block_user": "Estä %s", + "block_domain": "Estä %s", + "unblock": "Poista esto", "unblock_user": "Unblock %s", - "blocked": "Blocked", - "mute": "Mute", - "mute_user": "Mute %s", - "unmute": "Unmute", - "unmute_user": "Unmute %s", - "muted": "Muted", - "edit_info": "Edit Info" + "blocked": "Estetty", + "mute": "Mykistä", + "mute_user": "Mykistä %s", + "unmute": "Poista mykistys", + "unmute_user": "Poista mykistys tililtä %s", + "muted": "Mykistetty", + "edit_info": "Muokkaa profiilia" }, "timeline": { - "filtered": "Filtered", + "filtered": "Suodatettu", "timestamp": { - "now": "Now" + "now": "Nyt" }, "loader": { - "load_missing_posts": "Load missing posts", - "loading_missing_posts": "Loading missing posts...", - "show_more_replies": "Visa fler svar" + "load_missing_posts": "Lataa puuttuvat julkaisut", + "loading_missing_posts": "Ladataan puuttuvia julkaisuja...", + "show_more_replies": "Näytä lisää vastauksia" }, "header": { - "no_status_found": "No Post 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." + "no_status_found": "Julkaisua ei löytynyt", + "blocking_warning": "Et voi tarkastella tämän tilin profiilia\nennen kuin poistat sen esto.\nProfiilisi näyttää tältä hänelle.", + "user_blocking_warning": "Et voi tarkastella tilin %s profiilia\nennen kuin poistat sen esto.\nProfiilisi näyttää tältä hänelle.", + "blocked_warning": "Et voi tarkastella tämän tilin profiilia\nennen kuin hän poistaa eston.", + "user_blocked_warning": "Et voi tarkastella tilin %s profiilia\nennen kuin hän poistaa eston.", + "suspended_warning": "Tämä tili on lakkautettu.", + "user_suspended_warning": "Tili %s on lakkautettu." } } } }, "scene": { "welcome": { - "slogan": "Social networking\nback in your hands." + "slogan": "Sosiaalinen verkostoituminen\ntakaisin käsissäsi.", + "get_started": "Kom igång", + "log_in": "Logga in" }, "server_picker": { - "title": "Pick a server,\nany server.", + "title": "Valitse palvelin,\nmikä tahansa palvelin.", + "subtitle": "Pick a community based on your interests, region, or a general purpose one.", + "subtitle_extend": "Pick a community based on your interests, region, or a general purpose one. Each community is operated by an entirely independent organization or individual.", "button": { "category": { - "all": "All", - "all_accessiblity_description": "Kategori: Alla", - "academia": "academia", - "activism": "activism", - "food": "food", - "furry": "furry", - "games": "games", - "general": "general", - "journalism": "journalism", - "lgbt": "lgbt", - "regional": "regional", - "art": "art", - "music": "music", - "tech": "tech" + "all": "Kaikki", + "all_accessiblity_description": "Kategoria: Kaikki", + "academia": "akateeminen", + "activism": "aktivismi", + "food": "ruoka", + "furry": "turri", + "games": "pelit", + "general": "yleinen", + "journalism": "journalismi", + "lgbt": "hlbt", + "regional": "alueellinen", + "art": "taide", + "music": "musiikki", + "tech": "tekniikka" }, - "see_less": "See Less", - "see_more": "See More" + "see_less": "Näytä vähemmän", + "see_more": "Näytä lisää" }, "label": { - "language": "SPRÅK", - "users": "ANVÄNDARE", - "category": "KATEGORI" + "language": "KIELI", + "users": "TILIÄ", + "category": "KATEGORIA" }, "input": { - "placeholder": "Find a server or join your own..." + "placeholder": "Etsi palvelin tai liity omaan..." }, "empty_state": { - "finding_servers": "Finding available servers...", - "bad_network": "Something went wrong while loading the data. Check your internet connection.", - "no_results": "Inga resultat" + "finding_servers": "Etsistään saatavilla olevia palvelimia...", + "bad_network": "Jokin meni pieleen dataa ladatessa. Tarkista internet-yhteytesi.", + "no_results": "Ei hakutuloksia" } }, "register": { - "title": "Tell us about you.", + "title": "Kerro meille sinusta.", "input": { "avatar": { - "delete": "Radera" + "delete": "Poista" }, "username": { - "placeholder": "username", - "duplicate_prompt": "This username is taken." + "placeholder": "käyttäjänimi", + "duplicate_prompt": "Tämä käyttäjänimi on varattu." }, "display_name": { - "placeholder": "display name" + "placeholder": "näyttönimi" }, "email": { - "placeholder": "email" + "placeholder": "sähköposti" }, "password": { - "placeholder": "password", - "hint": "Your password needs at least eight characters" + "placeholder": "salasana", + "require": "Your password needs at least:", + "character_limit": "8 characters", + "accessibility": { + "checked": "checked", + "unchecked": "unchecked" + }, + "hint": "Salasanassasi on oltava vähintään kahdeksan merkkiä" }, "invite": { - "registration_user_invite_request": "Why do you want to join?" + "registration_user_invite_request": "Miksi haluat liittyä?" } }, "error": { "item": { - "username": "Användarnamn", - "email": "Email", - "password": "Password", - "agreement": "Agreement", + "username": "Käyttäjänimi", + "email": "Sähköposti", + "password": "Salasana", + "agreement": "Hyväksy", "locale": "Locale", - "reason": "Reason" + "reason": "Syy" }, "reason": { - "blocked": "%s contains a disallowed email provider", - "unreachable": "%s does not seem to exist", - "taken": "%s is already in use", + "blocked": "%s sisältää estetyn sähköpostipalveluntarjoajan", + "unreachable": "%s ei näytä olevan olemassa", + "taken": "%s on jo käytössä", "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" + "accepted": "%s täytyy hyväksyä", + "blank": "%s vaaditaan", + "invalid": "%s on virheellinen", + "too_long": "%s on liian pitkä", + "too_short": "%s on liian lyhyt", + "inclusion": "%s ei ole tuettu arvo" }, "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": "Käyttäjänimi voi sisältää ainoastaan aakkosnumerrisia merkkejä ja alaviivoja", + "username_too_long": "Käyttäjänimi on liian pitkä (ei voi olla pidempi kuin 30 merkkiä)", + "email_invalid": "Tämä ei ole kelvollinen sähköpostiosoite", + "password_too_short": "Salasana on liian lyhyt (täytyy olla vähintään 8 merkkiä)" } } }, "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.", - "terms_of_service": "terms of service", - "privacy_policy": "integritetspolicy", + "title": "Joitakin perussääntöjä.", + "subtitle": "Nämä säännöt ovat %s -palvelun asettamia.", + "prompt": "Jatkamalla, hyväksyt palvelun %s palveluehdot ja tietosuojakäytönnön.", + "terms_of_service": "käyttöehdot", + "privacy_policy": "tietosuojakäytäntö", "button": { - "confirm": "I Agree" + "confirm": "Hyväksyn" } }, "confirm_email": { - "title": "One last thing.", - "subtitle": "We just sent an email to %s,\ntap the link to confirm your account.", + "title": "Viimeinen asia.", + "subtitle": "Lähetimme juuri sähköpostin osoitteeseen %s, napauta siinä olevaa linkkiä vahvistaaksesi tilisi.", "button": { - "open_email_app": "Open Email App", - "dont_receive_email": "I never got an email" + "open_email_app": "Avaa sähköpostisovellus", + "resend": "Resend" }, "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": "Tarkista sähköpostisi", + "description": "Tarkista, että sähköpostiosoitteesi on oikea, sekä roskapostikansiosi, jos et vielä ole.", + "resend_email": "Lähetä sähköposti uudelleen" }, "open_email_app": { - "title": "Check your inbox.", - "description": "We just sent you an email. Check your junk folder if you haven’t.", - "mail": "Mail", - "open_email_client": "Open Email Client" + "title": "Tarkasta postilaatikkosi.", + "description": "Lähetimme sinulle juuri sähköpostin. Tarkista myös roskapostikansiosi, jos et vielä ole.", + "mail": "Sähköposti", + "open_email_client": "Avaa sähköpostisovellus" } }, "home_timeline": { - "title": "Home", + "title": "Koti", "navigation_bar_state": { - "offline": "Offline", - "new_posts": "See new posts", - "published": "Published!", - "Publishing": "Publishing post..." + "offline": "Yhteydetön", + "new_posts": "Uusia julkaisuja", + "published": "Julkaistu!", + "Publishing": "Julkaistaan julkaisua..." } }, "suggestion_account": { - "title": "Find People to Follow", - "follow_explain": "When you follow someone, you’ll see their posts in your home feed." + "title": "Löydä tilejä seurattavaksi", + "follow_explain": "Kun seuraat jotakuta, näet hänen julkaisunsa kotisyötteessäsi." }, "compose": { "title": { - "new_post": "New Post", - "new_reply": "New Reply" + "new_post": "Uusi julkaisu", + "new_reply": "Uusi vastaus" }, "media_selection": { - "camera": "Take Photo", - "photo_library": "Photo Library", - "browse": "Bläddra" + "camera": "Ota kuva", + "photo_library": "Kuvakirjasto", + "browse": "Selaa" }, - "content_input_placeholder": "Type or paste what’s on your mind", - "compose_action": "Publicera", - "replying_to_user": "replying to %s", + "content_input_placeholder": "Kirjoita tai liitä, siitä mitä ajattelet", + "compose_action": "Julkaise", + "replying_to_user": "vastaamassa tilille %s", "attachment": { - "photo": "photo", + "photo": "kuva", "video": "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..." + "description_photo": "Kuvaile kuva näkövammaisille...", + "description_video": "Kuvaile video näkövammaisille..." }, "poll": { - "duration_time": "Varaktighet: %s", - "thirty_minutes": "30 minuter", - "one_hour": "1 Hour", - "six_hours": "6 Hours", - "one_day": "1 Day", - "three_days": "3 Days", - "seven_days": "7 Days", - "option_number": "Option %ld" + "duration_time": "Kesto: %s", + "thirty_minutes": "30 minuuttia", + "one_hour": "1 tunti", + "six_hours": "6 tuntia", + "one_day": "1 päivä", + "three_days": "3 päivää", + "seven_days": "7 päivää", + "option_number": "Vaihtoehto %ld" }, "content_warning": { - "placeholder": "Write an accurate warning here..." + "placeholder": "Kirjoita tarkka varoitus tähän..." }, "visibility": { - "public": "Public", - "unlisted": "Unlisted", - "private": "Followers only", - "direct": "Only people I mention" + "public": "Julkinen", + "unlisted": "Listaamaton", + "private": "Vain seuraajat", + "direct": "Vain mainitsemani tilit" }, "auto_complete": { "space_to_add": "Space to add" }, "accessibility": { - "append_attachment": "Add Attachment", - "append_poll": "Add Poll", - "remove_poll": "Remove Poll", - "custom_emoji_picker": "Custom Emoji Picker", - "enable_content_warning": "Enable Content Warning", - "disable_content_warning": "Disable Content Warning", - "post_visibility_menu": "Post Visibility Menu" + "append_attachment": "Lisää liite", + "append_poll": "Lisää kysely", + "remove_poll": "Poista kysely", + "custom_emoji_picker": "Mukautettu emojivalitsin", + "enable_content_warning": "Ota sisältövaroitus käyttöön", + "disable_content_warning": "Poista sisältövaroitus käytöstä", + "post_visibility_menu": "Julkaisun näkyvyysvalikko" }, "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": "Hylkää julkaisu", + "publish_post": "Julkaise julkaisu", + "toggle_poll": "Vaihda kysely", + "toggle_content_warning": "Vaihda sisältövaroitus", + "append_attachment_entry": "Lisää liite - %s", + "select_visibility_entry": "Valitse näkyvyys - %s" } }, "profile": { "dashboard": { - "posts": "posts", - "following": "following", - "followers": "followers" + "posts": "julkaisut", + "following": "seurataan", + "followers": "seuraajat" }, "fields": { - "add_row": "Add Row", + "add_row": "Lisää rivi", "placeholder": { - "label": "Label", - "content": "Content" + "label": "Nimi", + "content": "Sisältö" } }, "segmented_control": { - "posts": "Posts", - "replies": "Replies", - "media": "Media" + "posts": "Julkaisut", + "replies": "Vastaukset", + "posts_and_replies": "Posts and Replies", + "media": "Media", + "about": "Om" }, "relationship_action_alert": { - "confirm_unmute_user": { - "title": "Unmute Account", - "message": "Confirm to unmute %s" + "confirm_mute_user": { + "title": "Mute Account", + "message": "Confirm to mute %s" }, - "confirm_unblock_usre": { + "confirm_unmute_user": { + "title": "Poista tilin mykistys", + "message": "Vahvista, että haluat poistaa mykistyksen tililtä %s" + }, + "confirm_block_user": { + "title": "Block Account", + "message": "Confirm to block %s" + }, + "confirm_unblock_user": { "title": "Unblock Account", "message": "Confirm to unblock %s" } } }, "follower": { - "footer": "Followers from other servers are not displayed." + "footer": "Seuraajia muilta palvelimilta ei näytetä." }, "following": { - "footer": "Follows from other servers are not displayed." + "footer": "Seurauksia muilta palvelimilta ei näytetä." }, "search": { - "title": "Search", + "title": "Haku", "search_bar": { - "placeholder": "Search hashtags and users", - "cancel": "Avbryt" + "placeholder": "Haku", + "cancel": "Kumoa" }, "recommend": { - "button_text": "See All", + "button_text": "Katso kaikki", "hash_tag": { - "title": "Trending on Mastodon", - "description": "Hashtags that are getting quite a bit of attention", - "people_talking": "%s people are talking" + "title": "Trendaavat Mastodonissa", + "description": "Hashtagit, jotka saavat melkoisesti huomiota", + "people_talking": "%s ihmistä puhuu" }, "accounts": { - "title": "Accounts you might like", - "description": "You may like to follow these accounts", - "follow": "Följ" + "title": "Saatat pitää näistä tileistä", + "description": "Haluta ehkä seurata näitä tilejä", + "follow": "Seuraa" } }, "searching": { "segment": { - "all": "All", - "people": "People", - "hashtags": "Hashtags", - "posts": "Posts" + "all": "Kaikki", + "people": "Tilit", + "hashtags": "Hashtagit", + "posts": "Julkaisut" }, "empty_state": { - "no_results": "Inga resultat" + "no_results": "Ei hakutuloksia" }, - "recent_search": "Recent searches", - "clear": "Clear" + "recent_search": "Viimeaikaiset", + "clear": "Tyhjennä" } }, "favorite": { - "title": "Your Favorites" + "title": "Omat suosikit" }, "notification": { "title": { - "Everything": "Everything", - "Mentions": "Mentions" + "Everything": "Kaikki", + "Mentions": "Maininnat" + }, + "notification_description": { + "followed_you": "followed you", + "favorited_your_post": "favorited your post", + "reblogged_your_post": "reblogged your post", + "mentioned_you": "nämnde dig", + "request_to_follow_you": "request to follow you", + "poll_has_ended": "poll has ended" }, - "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 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" + "show_everything": "Näytä kaikki", + "show_mentions": "Näytä maininnat" } }, "thread": { - "back_title": "Post", - "title": "Post from %s" + "back_title": "Julkaisu", + "title": "Julkaisu tililtä %s" }, "settings": { - "title": "Inställningar", + "title": "Asetukset", "section": { "appearance": { - "title": "Appearance", - "automatic": "Automatic", - "light": "Always Light", - "dark": "Always Dark" + "title": "Ulkoasu", + "automatic": "Seuraa järjestelmää", + "light": "Vaalea", + "dark": "Tumma" + }, + "look_and_feel": { + "title": "Look and Feel", + "use_system": "Use System", + "really_dark": "Really Dark", + "sorta_dark": "Sorta Dark", + "light": "Ljust" }, "notifications": { - "title": "Notifications", + "title": "Ilmoitukset", "favorites": "Favorites my post", - "follows": "Follows me", - "boosts": "Reblogs my post", - "mentions": "Mentions me", + "follows": "Seuraa minua", + "boosts": "Omien julkaisujen edelleen jaot", + "mentions": "Mainitsee minut", "trigger": { - "anyone": "anyone", - "follower": "a follower", - "follow": "anyone I follow", - "noone": "no one", - "title": "Notify me when" + "anyone": "kuka tahansa", + "follower": "seuraaja", + "follow": "kuka tahansa, jota seuraan", + "noone": "ei kukaan", + "title": "Ilmoita minulle, kun" } }, "preference": { - "title": "Preferences", - "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" + "title": "Lisäasetukset", + "true_black_dark_mode": "Todellinen mustan tumma tila", + "disable_avatar_animation": "Poista käytöstä animoidut avatarit", + "disable_emoji_animation": "Poista käytöstä animoidut emojit", + "using_default_browser": "Käytä oletusselainta linkkien avaamiseen" }, "boring_zone": { - "title": "The Boring Zone", - "account_settings": "Account Settings", - "terms": "Terms of Service", - "privacy": "Integritetspolicy" + "title": "Tylsä alue", + "account_settings": "Tiliasetukset", + "terms": "Palveluehdot", + "privacy": "Tietosuojakäytäntö" }, "spicy_zone": { - "title": "The Spicy Zone", - "clear": "Clear Media Cache", - "signout": "Logga ut" + "title": "Varovainen alue", + "clear": "Tyhjennä median välimuisti", + "signout": "Kirjaudu ulos" } }, "footer": { - "mastodon_description": "Mastodon is open source software. You can report issues on GitHub at %s (%s)" + "mastodon_description": "Mastodon on avoimen lähdekoodin ohjelmisto. Voit raportoida ongelmasta GitHubissa osoitteessa %s (%s)" }, "keyboard": { - "close_settings_window": "Close Settings Window" + "close_settings_window": "Sulje asetukset" } }, "report": { - "title": "Rapportera %s", - "step1": "Steg 1 av 2", - "step2": "Steg 2 av 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" + "title_report": "Report", + "title": "Ilmianna %s", + "step1": "Vaihe 1/2", + "step2": "Vaihe 2/2", + "content1": "Onko julkaisuja, joita haluaisit lisätä ilmiantoon?", + "content2": "Onko valvojien syytä tietää tästä ilmiannosta?", + "report_sent_title": "Thanks for reporting, we’ll look into this.", + "send": "Lähetä ilmianto", + "skip_to_send": "Lähetä ilman kommentteja", + "text_placeholder": "Kirjoita tai liitä lisäkommentteja", + "reported": "REPORTED" }, "preview": { "keyboard": { - "close_preview": "Close Preview", - "show_next": "Show Next", - "show_previous": "Show Previous" + "close_preview": "Sulje esikatselu", + "show_next": "Näytä seuraava", + "show_previous": "Näytä edellinen" } }, "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" + "tab_bar_hint": "Nykyinen valittu profiili: %s. Kaksoisnapauta ja pidä sitten painettuna näytääksesi tilin vaihtajan", + "dismiss_account_switcher": "Sulje tilin vaihtaja", + "add_account": "Lisää tili" }, "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" + "new_in_mastodon": "Uutta Mastodonissa", + "multiple_account_switch_intro_description": "Vaihda useiden tilien välillä pitämällä profiilipainiketta painettuna.", + "accessibility_hint": "Hylkää tämä ohjattu toiminto kaksoisnapauttamalla" } } } \ No newline at end of file diff --git a/Localization/StringsConvertor/input/sv_FI/ios-infoPlist.json b/Localization/StringsConvertor/input/sv_FI/ios-infoPlist.json index c6db73de0..eb389f3b3 100644 --- a/Localization/StringsConvertor/input/sv_FI/ios-infoPlist.json +++ b/Localization/StringsConvertor/input/sv_FI/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", - "SearchShortcutItemTitle": "Search" + "NSCameraUsageDescription": "Käytetään kuvan ottamiseen julkaisua varten", + "NSPhotoLibraryAddUsageDescription": "Käytetään kuvan tallentamiseen kuvakirjastoon", + "NewPostShortcutItemTitle": "Uusi julkaisu", + "SearchShortcutItemTitle": "Haku" } diff --git a/Localization/StringsConvertor/input/sv_SE/Localizable.stringsdict b/Localization/StringsConvertor/input/sv_SE/Localizable.stringsdict index 65316e3d0..f8da5e395 100644 --- a/Localization/StringsConvertor/input/sv_SE/Localizable.stringsdict +++ b/Localization/StringsConvertor/input/sv_SE/Localizable.stringsdict @@ -29,9 +29,9 @@ <key>NSStringFormatValueTypeKey</key> <string>ld</string> <key>one</key> - <string>1 character</string> + <string>1 tecken</string> <key>other</key> - <string>%ld characters</string> + <string>%ld tecken</string> </dict> </dict> <key>a11y.plural.count.input_limit_remains</key> @@ -45,9 +45,9 @@ <key>NSStringFormatValueTypeKey</key> <string>ld</string> <key>one</key> - <string>1 character</string> + <string>1 tecken</string> <key>other</key> - <string>%ld characters</string> + <string>%ld tecken</string> </dict> </dict> <key>plural.count.metric_formatted.post</key> @@ -125,9 +125,9 @@ <key>NSStringFormatValueTypeKey</key> <string>ld</string> <key>one</key> - <string>1 vote</string> + <string>1 röst</string> <key>other</key> - <string>%ld votes</string> + <string>%ld röster</string> </dict> </dict> <key>plural.count.voter</key> @@ -381,7 +381,7 @@ <key>NSStringFormatValueTypeKey</key> <string>ld</string> <key>one</key> - <string>1s ago</string> + <string>1s sedan</string> <key>other</key> <string>%lds ago</string> </dict> diff --git a/Localization/StringsConvertor/input/sv_SE/app.json b/Localization/StringsConvertor/input/sv_SE/app.json index 7acf48755..59ad0d6ed 100644 --- a/Localization/StringsConvertor/input/sv_SE/app.json +++ b/Localization/StringsConvertor/input/sv_SE/app.json @@ -45,11 +45,11 @@ "message": "Please enable the photo library access permission to save the photo." }, "delete_post": { - "title": "Are you sure you want to delete this post?", - "delete": "Radera" + "title": "Delete Post", + "message": "Are you sure you want to delete this post?" }, "clean_cache": { - "title": "Clean Cache", + "title": "Rensa cache", "message": "Successfully cleaned %s cache." } }, @@ -64,7 +64,7 @@ "edit": "Redigera", "save": "Spara", "ok": "OK", - "done": "Done", + "done": "Klar", "confirm": "Confirm", "continue": "Fortsätt", "compose": "Compose", @@ -72,8 +72,8 @@ "discard": "Discard", "try_again": "Försök igen", "take_photo": "Take Photo", - "save_photo": "Save Photo", - "copy_photo": "Copy Photo", + "save_photo": "Spara foto", + "copy_photo": "Kopiera foto", "sign_in": "Sign In", "sign_up": "Sign Up", "see_more": "See More", @@ -82,6 +82,7 @@ "share_user": "Dela %s", "share_post": "Share Post", "open_in_safari": "Öppna i Safari", + "open_in_browser": "Open in Browser", "find_people": "Find people to follow", "manually_search": "Manually search instead", "skip": "Skip", @@ -139,7 +140,8 @@ "unreblog": "Undo reblog", "favorite": "Favorite", "unfavorite": "Unfavorite", - "menu": "Meny" + "menu": "Meny", + "hide": "Dölj" }, "tag": { "url": "URL", @@ -148,6 +150,12 @@ "hashtag": "Hashtag", "email": "Email", "emoji": "Emoji" + }, + "visibility": { + "unlisted": "Everyone can see this post but not display in the public timeline.", + "private": "Only their followers can see this post.", + "private_from_me": "Only my followers can see this post.", + "direct": "Only mentioned user can see this post." } }, "friendship": { @@ -171,7 +179,7 @@ "timeline": { "filtered": "Filtered", "timestamp": { - "now": "Now" + "now": "Nu" }, "loader": { "load_missing_posts": "Load missing posts", @@ -192,10 +200,14 @@ }, "scene": { "welcome": { - "slogan": "Social networking\nback in your hands." + "slogan": "Social networking\nback in your hands.", + "get_started": "Kom igång", + "log_in": "Logga in" }, "server_picker": { - "title": "Pick a server,\nany server.", + "title": "Mastodon is made of users in different communities.", + "subtitle": "Pick a community based on your interests, region, or a general purpose one.", + "subtitle_extend": "Pick a community based on your interests, region, or a general purpose one. Each community is operated by an entirely independent organization or individual.", "button": { "category": { "all": "All", @@ -222,7 +234,7 @@ "category": "KATEGORI" }, "input": { - "placeholder": "Find a server or join your own..." + "placeholder": "Search communities" }, "empty_state": { "finding_servers": "Finding available servers...", @@ -231,7 +243,7 @@ } }, "register": { - "title": "Tell us about you.", + "title": "Let’s get you set up on %s", "input": { "avatar": { "delete": "Radera" @@ -248,6 +260,12 @@ }, "password": { "placeholder": "password", + "require": "Your password needs at least:", + "character_limit": "8 characters", + "accessibility": { + "checked": "checked", + "unchecked": "unchecked" + }, "hint": "Your password needs at least eight characters" }, "invite": { @@ -258,7 +276,7 @@ "item": { "username": "Användarnamn", "email": "Email", - "password": "Password", + "password": "Lösenord", "agreement": "Agreement", "locale": "Locale", "reason": "Reason" @@ -285,7 +303,7 @@ }, "server_rules": { "title": "Some ground rules.", - "subtitle": "These rules are set by the admins of %s.", + "subtitle": "These are set and enforced by the %s moderators.", "prompt": "By continuing, you’re subject to the terms of service and privacy policy for %s.", "terms_of_service": "terms of service", "privacy_policy": "integritetspolicy", @@ -295,10 +313,10 @@ }, "confirm_email": { "title": "One last thing.", - "subtitle": "We just sent an email to %s,\ntap the link to confirm your account.", + "subtitle": "Tap the link we emailed to you to verify your account.", "button": { "open_email_app": "Open Email App", - "dont_receive_email": "I never got an email" + "resend": "Resend" }, "dont_receive_email": { "title": "Check your email", @@ -332,7 +350,7 @@ }, "media_selection": { "camera": "Take Photo", - "photo_library": "Photo Library", + "photo_library": "Fotobibliotek", "browse": "Bläddra" }, "content_input_placeholder": "Type or paste what’s on your mind", @@ -401,14 +419,24 @@ "segmented_control": { "posts": "Posts", "replies": "Replies", - "media": "Media" + "posts_and_replies": "Posts and Replies", + "media": "Media", + "about": "Om" }, "relationship_action_alert": { + "confirm_mute_user": { + "title": "Mute Account", + "message": "Confirm to mute %s" + }, "confirm_unmute_user": { "title": "Unmute Account", "message": "Confirm to unmute %s" }, - "confirm_unblock_usre": { + "confirm_block_user": { + "title": "Block Account", + "message": "Confirm to block %s" + }, + "confirm_unblock_user": { "title": "Unblock Account", "message": "Confirm to unblock %s" } @@ -450,7 +478,7 @@ "no_results": "Inga resultat" }, "recent_search": "Recent searches", - "clear": "Clear" + "clear": "Rensa" } }, "favorite": { @@ -461,12 +489,14 @@ "Everything": "Everything", "Mentions": "Mentions" }, - "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 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", + "notification_description": { + "followed_you": "followed you", + "favorited_your_post": "favorited your post", + "reblogged_your_post": "reblogged your post", + "mentioned_you": "nämnde dig", + "request_to_follow_you": "request to follow you", + "poll_has_ended": "poll has ended" + }, "keyobard": { "show_everything": "Show Everything", "show_mentions": "Show Mentions" @@ -485,6 +515,13 @@ "light": "Always Light", "dark": "Always Dark" }, + "look_and_feel": { + "title": "Look and Feel", + "use_system": "Use System", + "really_dark": "Really Dark", + "sorta_dark": "Sorta Dark", + "light": "Ljust" + }, "notifications": { "title": "Notifications", "favorites": "Favorites my post", @@ -502,14 +539,14 @@ "preference": { "title": "Preferences", "true_black_dark_mode": "True black dark mode", - "disable_avatar_animation": "Disable animated avatars", - "disable_emoji_animation": "Disable animated emojis", + "disable_avatar_animation": "Inaktivera animerade avatarer", + "disable_emoji_animation": "Inaktivera animerade emojis", "using_default_browser": "Use default browser to open links" }, "boring_zone": { "title": "The Boring Zone", - "account_settings": "Account Settings", - "terms": "Terms of Service", + "account_settings": "Kontoinställningar", + "terms": "Användarvillkor", "privacy": "Integritetspolicy" }, "spicy_zone": { @@ -526,14 +563,17 @@ } }, "report": { + "title_report": "Report", "title": "Rapportera %s", "step1": "Steg 1 av 2", "step2": "Steg 2 av 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", + "report_sent_title": "Thanks for reporting, we’ll look into this.", + "send": "Skicka rapport", "skip_to_send": "Send without comment", - "text_placeholder": "Type or paste additional comments" + "text_placeholder": "Type or paste additional comments", + "reported": "REPORTED" }, "preview": { "keyboard": { diff --git a/Localization/StringsConvertor/input/th_TH/app.json b/Localization/StringsConvertor/input/th_TH/app.json index 7852b5d01..001075b1b 100644 --- a/Localization/StringsConvertor/input/th_TH/app.json +++ b/Localization/StringsConvertor/input/th_TH/app.json @@ -45,8 +45,8 @@ "message": "โปรดเปิดใช้งานสิทธิอนุญาตการเข้าถึงคลังรูปภาพเพื่อบันทึกรูปภาพ" }, "delete_post": { - "title": "คุณแน่ใจหรือไม่ว่าต้องการลบโพสต์นี้?", - "delete": "ลบ" + "title": "ลบโพสต์", + "message": "คุณแน่ใจหรือไม่ว่าต้องการลบโพสต์นี้?" }, "clean_cache": { "title": "ล้างแคช", @@ -82,6 +82,7 @@ "share_user": "แบ่งปัน %s", "share_post": "แบ่งปันโพสต์", "open_in_safari": "เปิดใน Safari", + "open_in_browser": "เปิดในเบราว์เซอร์", "find_people": "ค้นหาผู้คนที่จะติดตาม", "manually_search": "ค้นหาด้วยตนเองแทน", "skip": "ข้าม", @@ -139,7 +140,8 @@ "unreblog": "เลิกทำการดัน", "favorite": "ชื่นชอบ", "unfavorite": "เลิกชื่นชอบ", - "menu": "เมนู" + "menu": "เมนู", + "hide": "ซ่อน" }, "tag": { "url": "URL", @@ -148,6 +150,12 @@ "hashtag": "แฮชแท็ก", "email": "อีเมล", "emoji": "อีโมจิ" + }, + "visibility": { + "unlisted": "ทุกคนสามารถเห็นโพสต์นี้แต่ไม่แสดงในเส้นเวลาสาธารณะ", + "private": "เฉพาะผู้ติดตามของเขาเท่านั้นที่สามารถเห็นโพสต์นี้", + "private_from_me": "เฉพาะผู้ติดตามของฉันเท่านั้นที่สามารถเห็นโพสต์นี้", + "direct": "เฉพาะผู้ใช้ที่กล่าวถึงเท่านั้นที่สามารถเห็นโพสต์นี้" } }, "friendship": { @@ -180,10 +188,10 @@ }, "header": { "no_status_found": "ไม่พบโพสต์", - "blocking_warning": "คุณไม่สามารถดูโปรไฟล์ของผู้ใช้นี้\nจนกว่าคุณจะเลิกปิดกั้นผู้ใช้นี้\nผู้ใช้นี้เห็นโปรไฟล์ของคุณเหมือนกับที่คุณเห็น", - "user_blocking_warning": "คุณไม่สามารถดูโปรไฟล์ของ %s\nจนกว่าคุณจะเลิกปิดกั้นผู้ใช้นี้\nผู้ใช้นี้เห็นโปรไฟล์ของคุณเหมือนกับที่คุณเห็น", - "blocked_warning": "คุณไม่สามารถดูโปรไฟล์ของผู้ใช้นี้\nจนกว่าผู้ใช้นี้จะเลิกปิดกั้นคุณ", - "user_blocked_warning": "คุณไม่สามารถดูโปรไฟล์ของ %s\nจนกว่าผู้ใช้นี้จะเลิกปิดกั้นคุณ", + "blocking_warning": "คุณไม่สามารถดูโปรไฟล์ของผู้ใช้นี้\nจนกว่าคุณจะเลิกปิดกั้นเขา\nโปรไฟล์ของคุณมีลักษณะเช่นนี้สำหรับเขา", + "user_blocking_warning": "คุณไม่สามารถดูโปรไฟล์ของ %s\nจนกว่าคุณจะเลิกปิดกั้นเขา\nโปรไฟล์ของคุณมีลักษณะเช่นนี้สำหรับเขา", + "blocked_warning": "คุณไม่สามารถดูโปรไฟล์ของผู้ใช้นี้\nจนกว่าเขาจะเลิกปิดกั้นคุณ", + "user_blocked_warning": "คุณไม่สามารถดูโปรไฟล์ของ %s\nจนกว่าเขาจะเลิกปิดกั้นคุณ", "suspended_warning": "ผู้ใช้นี้ถูกระงับการใช้งาน", "user_suspended_warning": "บัญชีของ %s ถูกระงับการใช้งาน" } @@ -192,10 +200,14 @@ }, "scene": { "welcome": { - "slogan": "ให้เครือข่ายสังคม\nกลับมาอยู่ในมือของคุณ" + "slogan": "ให้เครือข่ายสังคม\nกลับมาอยู่ในมือของคุณ", + "get_started": "เริ่มต้นใช้งาน", + "log_in": "เข้าสู่ระบบ" }, "server_picker": { - "title": "เลือกเซิร์ฟเวอร์\nอันไหนก็ได้", + "title": "Mastodon ประกอบด้วยผู้ใช้ในชุมชนต่าง ๆ", + "subtitle": "เลือกชุมชนตามความสนใจ, ภูมิภาค หรือวัตถุประสงค์ทั่วไปของคุณ", + "subtitle_extend": "เลือกชุมชนตามความสนใจ, ภูมิภาค หรือวัตถุประสงค์ทั่วไปของคุณ แต่ละชุมชนดำเนินการโดยองค์กรหรือบุคคลที่เป็นอิสระโดยสิ้นเชิง", "button": { "category": { "all": "ทั้งหมด", @@ -222,7 +234,7 @@ "category": "หมวดหมู่" }, "input": { - "placeholder": "ค้นหาเซิร์ฟเวอร์หรือเข้าร่วมของคุณเอง..." + "placeholder": "ค้นหาชุมชน" }, "empty_state": { "finding_servers": "กำลังค้นหาเซิร์ฟเวอร์ที่พร้อมใช้งาน...", @@ -231,7 +243,7 @@ } }, "register": { - "title": "บอกเราเกี่ยวกับคุณ", + "title": "มาตั้งค่าของคุณใน %s กันเลย", "input": { "avatar": { "delete": "ลบ" @@ -248,6 +260,12 @@ }, "password": { "placeholder": "รหัสผ่าน", + "require": "รหัสผ่านของคุณต้องมีอย่างน้อย:", + "character_limit": "8 ตัวอักษร", + "accessibility": { + "checked": "กาเครื่องหมายแล้ว", + "unchecked": "ไม่ได้กาเครื่องหมาย" + }, "hint": "รหัสผ่านของคุณต้องมีอย่างน้อยแปดตัวอักษร" }, "invite": { @@ -285,7 +303,7 @@ }, "server_rules": { "title": "กฎพื้นฐานบางประการ", - "subtitle": "กฎเหล่านี้ถูกตั้งโดยผู้ดูแลของ %s", + "subtitle": "มีการตั้งและบังคับใช้กฎเหล่านี้โดยผู้ควบคุมของ %s", "prompt": "เมื่อคุณดำเนินการต่อ คุณอยู่ภายใต้เงื่อนไขการให้บริการและนโยบายความเป็นส่วนตัวสำหรับ %s", "terms_of_service": "เงื่อนไขการให้บริการ", "privacy_policy": "นโยบายความเป็นส่วนตัว", @@ -295,10 +313,10 @@ }, "confirm_email": { "title": "หนึ่งสิ่งสุดท้าย", - "subtitle": "เราเพิ่งส่งอีเมลไปยัง %s\nแตะที่ลิงก์เพื่อยืนยันบัญชีของคุณ", + "subtitle": "แตะลิงก์ที่เราส่งอีเมลถึงคุณเพื่อยืนยันบัญชีของคุณ", "button": { "open_email_app": "เปิดแอปอีเมล", - "dont_receive_email": "ฉันไม่เคยได้รับอีเมล" + "resend": "ส่งใหม่" }, "dont_receive_email": { "title": "ตรวจสอบอีเมลของคุณ", @@ -401,14 +419,24 @@ "segmented_control": { "posts": "โพสต์", "replies": "การตอบกลับ", - "media": "สื่อ" + "posts_and_replies": "โพสต์และการตอบกลับ", + "media": "สื่อ", + "about": "เกี่ยวกับ" }, "relationship_action_alert": { + "confirm_mute_user": { + "title": "ซ่อนบัญชี", + "message": "ยืนยันเพื่อซ่อน %s" + }, "confirm_unmute_user": { "title": "เลิกซ่อนบัญชี", "message": "ยืนยันเพื่อเลิกซ่อน %s" }, - "confirm_unblock_usre": { + "confirm_block_user": { + "title": "ปิดกั้นบัญชี", + "message": "ยืนยันเพื่อปิดกั้น %s" + }, + "confirm_unblock_user": { "title": "เลิกปิดกั้นบัญชี", "message": "ยืนยันเพื่อเลิกปิดกั้น %s" } @@ -461,12 +489,14 @@ "Everything": "ทุกอย่าง", "Mentions": "การกล่าวถึง" }, - "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 โพลของคุณได้สิ้นสุดแล้ว", + "notification_description": { + "followed_you": "ได้ติดตามคุณ", + "favorited_your_post": "ได้ชื่นชอบโพสต์ของคุณ", + "reblogged_your_post": "ได้ดันโพสต์ของคุณ", + "mentioned_you": "ได้กล่าวถึงคุณ", + "request_to_follow_you": "ขอติดตามคุณ", + "poll_has_ended": "การสำรวจความคิดเห็นได้สิ้นสุดแล้ว" + }, "keyobard": { "show_everything": "แสดงทุกอย่าง", "show_mentions": "แสดงการกล่าวถึง" @@ -485,6 +515,13 @@ "light": "สว่างเสมอ", "dark": "มืดเสมอ" }, + "look_and_feel": { + "title": "ลักษณะที่แสดง", + "use_system": "ใช้ของระบบ", + "really_dark": "มืดมาก", + "sorta_dark": "ค่อนข้างมืด", + "light": "สว่าง" + }, "notifications": { "title": "การแจ้งเตือน", "favorites": "ชื่นชอบโพสต์ของฉัน", @@ -526,14 +563,17 @@ } }, "report": { + "title_report": "รายงาน", "title": "รายงาน %s", "step1": "ขั้นตอนที่ 1 จาก 2", "step2": "ขั้นตอนที่ 2 จาก 2", "content1": "มีโพสต์อื่นใดที่คุณต้องการเพิ่มไปยังรายงานหรือไม่?", "content2": "มีสิ่งใดที่ผู้ควบคุมควรทราบเกี่ยวกับรายงานนี้หรือไม่?", + "report_sent_title": "ขอบคุณสำหรับการรายงาน เราจะตรวจสอบสิ่งนี้", "send": "ส่งรายงาน", "skip_to_send": "ส่งโดยไม่มีความคิดเห็น", - "text_placeholder": "พิมพ์หรือวางความคิดเห็นเพิ่มเติม" + "text_placeholder": "พิมพ์หรือวางความคิดเห็นเพิ่มเติม", + "reported": "รายงานแล้ว" }, "preview": { "keyboard": { diff --git a/Localization/StringsConvertor/input/zh_CN/app.json b/Localization/StringsConvertor/input/zh_CN/app.json index 905afdd86..74ea0529a 100644 --- a/Localization/StringsConvertor/input/zh_CN/app.json +++ b/Localization/StringsConvertor/input/zh_CN/app.json @@ -46,7 +46,7 @@ }, "delete_post": { "title": "确定要删除这条消息吗?", - "delete": "删除" + "message": "确定要删除这个帖子吗?" }, "clean_cache": { "title": "清除缓存", @@ -82,6 +82,7 @@ "share_user": "分享 %s", "share_post": "分享帖子", "open_in_safari": "在 Safari 中打开", + "open_in_browser": "在浏览器中打开", "find_people": "查看推荐关注的用户", "manually_search": "手动搜索用户", "skip": "跳过", @@ -139,7 +140,8 @@ "unreblog": "取消转发", "favorite": "喜欢", "unfavorite": "取消喜欢", - "menu": "菜单" + "menu": "菜单", + "hide": "隐藏" }, "tag": { "url": "URL", @@ -148,6 +150,12 @@ "hashtag": "标签", "email": "电子邮箱", "emoji": "表情" + }, + "visibility": { + "unlisted": "任何人都可以看到这个帖子,但不会在公开的时间线中显示。", + "private": "只有作者的关注者才能看到此帖子。", + "private_from_me": "只有我的关注者才能看到此帖子。", + "direct": "只有提到的用户才能看到此帖子。" } }, "friendship": { @@ -192,10 +200,14 @@ }, "scene": { "welcome": { - "slogan": "社交网络\n回到你的手中。" + "slogan": "社交网络\n回到你的手中。", + "get_started": "开始使用", + "log_in": "登录" }, "server_picker": { "title": "挑选一个服务器,\n任意服务器。", + "subtitle": "根据你的兴趣、区域或一般目的选择一个社区。", + "subtitle_extend": "根据你的兴趣、区域或一般目的选择一个社区。每个社区都由完全独立的组织或个人管理。", "button": { "category": { "all": "全部", @@ -248,6 +260,12 @@ }, "password": { "placeholder": "密码", + "require": "您的密码至少需要:", + "character_limit": "8 个字符", + "accessibility": { + "checked": "已选中", + "unchecked": "未选中" + }, "hint": "密码长度至少为 8 个字符" }, "invite": { @@ -298,7 +316,7 @@ "subtitle": "我们刚刚向 %s 发送了一封电子邮件,\n点击链接确认你的帐户。", "button": { "open_email_app": "打开电子邮件应用", - "dont_receive_email": "我还没有收到电子邮件" + "resend": "重新发送" }, "dont_receive_email": { "title": "请检查你的邮箱。", @@ -401,14 +419,24 @@ "segmented_control": { "posts": "帖子", "replies": "回复", - "media": "媒体" + "posts_and_replies": "帖子与回复", + "media": "媒体", + "about": "关于" }, "relationship_action_alert": { + "confirm_mute_user": { + "title": "静音账户", + "message": "确认静音 %s" + }, "confirm_unmute_user": { "title": "取消静音账户", "message": "确认取消静音 %s" }, - "confirm_unblock_usre": { + "confirm_block_user": { + "title": "屏蔽帐户", + "message": "确认屏蔽 %s" + }, + "confirm_unblock_user": { "title": "解除屏蔽帐户", "message": "确认取消屏蔽 %s" } @@ -461,12 +489,14 @@ "Everything": "全部", "Mentions": "提及" }, - "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 你的投票已经结束", + "notification_description": { + "followed_you": "关注了你", + "favorited_your_post": "喜欢了你的帖子", + "reblogged_your_post": "转发了你的帖子", + "mentioned_you": "提及了你", + "request_to_follow_you": "关注请求", + "poll_has_ended": "投票已结束" + }, "keyobard": { "show_everything": "显示全部", "show_mentions": "显示提及" @@ -485,6 +515,13 @@ "light": "浅色", "dark": "深色" }, + "look_and_feel": { + "title": "外观和风格", + "use_system": "跟随系统", + "really_dark": "暗色", + "sorta_dark": "深色", + "light": "浅色" + }, "notifications": { "title": "通知", "favorites": "喜欢我的帖子", @@ -526,14 +563,17 @@ } }, "report": { + "title_report": "举报", "title": "举报 %s", "step1": "步骤 1 / 2", "step2": "步骤 2 / 2", "content1": "是否有帖子需要举报?", "content2": "是否有关于此举报的详细描述信息?", + "report_sent_title": "感谢提交举报,我们将会进行处理。", "send": "发送举报", "skip_to_send": "直接发送", - "text_placeholder": "输入或粘贴额外的注释" + "text_placeholder": "输入或粘贴额外的注释", + "reported": "已报告" }, "preview": { "keyboard": { diff --git a/Localization/StringsConvertor/input/zh_TW/app.json b/Localization/StringsConvertor/input/zh_TW/app.json index 5c01ae7e0..be2442e4b 100644 --- a/Localization/StringsConvertor/input/zh_TW/app.json +++ b/Localization/StringsConvertor/input/zh_TW/app.json @@ -2,21 +2,21 @@ "common": { "alerts": { "common": { - "please_try_again": "Please try again.", - "please_try_again_later": "Please try again later." + "please_try_again": "請再試一次。", + "please_try_again_later": "請稍候再試。" }, "sign_up_failure": { - "title": "Sign Up Failure" + "title": "註冊失敗" }, "server_error": { - "title": "Server Error" + "title": "伺服器錯誤" }, "vote_failure": { "title": "Vote Failure", "poll_ended": "The poll has ended" }, "discard_post_content": { - "title": "Discard Draft", + "title": "捨棄草稿", "message": "Confirm to discard composed post content." }, "publish_post_failure": { @@ -32,9 +32,9 @@ "message": "Cannot edit profile. Please try again." }, "sign_out": { - "title": "Sign Out", + "title": "登出", "message": "Are you sure you want to sign out?", - "confirm": "Sign Out" + "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.", @@ -45,8 +45,8 @@ "message": "Please enable the photo library access permission to save the photo." }, "delete_post": { - "title": "Are you sure you want to delete this post?", - "delete": "Delete" + "title": "Delete Post", + "message": "Are you sure you want to delete this post?" }, "clean_cache": { "title": "Clean Cache", @@ -61,47 +61,48 @@ "open": "Open", "add": "Add", "remove": "Remove", - "edit": "Edit", + "edit": "編輯", "save": "Save", "ok": "OK", - "done": "Done", + "done": "完成", "confirm": "Confirm", - "continue": "Continue", + "continue": "繼續", "compose": "Compose", - "cancel": "Cancel", + "cancel": "取消", "discard": "Discard", "try_again": "Try Again", "take_photo": "Take Photo", - "save_photo": "Save Photo", + "save_photo": "儲存照片", "copy_photo": "Copy Photo", - "sign_in": "Sign In", - "sign_up": "Sign Up", + "sign_in": "登入", + "sign_up": "註冊", "see_more": "See More", "preview": "Preview", - "share": "Share", + "share": "分享", "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", - "reply": "Reply", + "skip": "跳過", + "reply": "回覆", "report_user": "Report %s", - "block_domain": "Block %s", - "unblock_domain": "Unblock %s", - "settings": "Settings", - "delete": "Delete" + "block_domain": "封鎖 %s", + "unblock_domain": "解除封鎖 %s", + "settings": "設定", + "delete": "刪除" }, "tabs": { - "home": "Home", - "search": "Search", - "notification": "Notification", - "profile": "Profile" + "home": "首頁", + "search": "搜尋", + "notification": "通知", + "profile": "個人檔案" }, "keyboard": { "common": { - "switch_to_tab": "Switch to %s", - "compose_new_post": "Compose New Post", + "switch_to_tab": "切換至 %s", + "compose_new_post": "發佈貼文", "show_favorites": "Show Favorites", "open_settings": "Open Settings" }, @@ -130,37 +131,44 @@ "content_warning": "Content Warning", "media_content_warning": "Tap anywhere to reveal", "poll": { - "vote": "Vote", + "vote": "投票", "closed": "Closed" }, "actions": { - "reply": "Reply", + "reply": "回覆", "reblog": "Reblog", "unreblog": "Undo reblog", "favorite": "Favorite", "unfavorite": "Unfavorite", - "menu": "Menu" + "menu": "Menu", + "hide": "Hide" }, "tag": { "url": "URL", "mention": "Mention", "link": "Link", "hashtag": "Hashtag", - "email": "Email", + "email": "電子郵件", "emoji": "Emoji" + }, + "visibility": { + "unlisted": "Everyone can see this post but not display in the public timeline.", + "private": "Only their followers can see this post.", + "private_from_me": "Only my followers can see this post.", + "direct": "Only mentioned user can see this post." } }, "friendship": { - "follow": "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", + "block_user": "封鎖 %s", + "block_domain": "封鎖 %s", + "unblock": "解除封鎖", + "unblock_user": "解除封鎖 %s", + "blocked": "已封鎖", "mute": "Mute", "mute_user": "Mute %s", "unmute": "Unmute", @@ -192,10 +200,14 @@ }, "scene": { "welcome": { - "slogan": "Social networking\nback in your hands." + "slogan": "Social networking\nback in your hands.", + "get_started": "Get Started", + "log_in": "登入" }, "server_picker": { - "title": "Pick a server,\nany server.", + "title": "Mastodon is made of users in different communities.", + "subtitle": "Pick a community based on your interests, region, or a general purpose one.", + "subtitle_extend": "Pick a community based on your interests, region, or a general purpose one. Each community is operated by an entirely independent organization or individual.", "button": { "category": { "all": "All", @@ -222,7 +234,7 @@ "category": "CATEGORY" }, "input": { - "placeholder": "Find a server or join your own..." + "placeholder": "Search communities" }, "empty_state": { "finding_servers": "Finding available servers...", @@ -231,10 +243,10 @@ } }, "register": { - "title": "Tell us about you.", + "title": "Let’s get you set up on %s", "input": { "avatar": { - "delete": "Delete" + "delete": "刪除" }, "username": { "placeholder": "username", @@ -247,7 +259,13 @@ "placeholder": "email" }, "password": { - "placeholder": "password", + "placeholder": "密碼", + "require": "Your password needs at least:", + "character_limit": "8 個字元", + "accessibility": { + "checked": "checked", + "unchecked": "unchecked" + }, "hint": "Your password needs at least eight characters" }, "invite": { @@ -257,8 +275,8 @@ "error": { "item": { "username": "Username", - "email": "Email", - "password": "Password", + "email": "電子郵件", + "password": "密碼", "agreement": "Agreement", "locale": "Locale", "reason": "Reason" @@ -285,7 +303,7 @@ }, "server_rules": { "title": "Some ground rules.", - "subtitle": "These rules are set by the admins of %s.", + "subtitle": "These are set and enforced by the %s moderators.", "prompt": "By continuing, you’re subject to the terms of service and privacy policy for %s.", "terms_of_service": "terms of service", "privacy_policy": "privacy policy", @@ -295,10 +313,10 @@ }, "confirm_email": { "title": "One last thing.", - "subtitle": "We just sent an email to %s,\ntap the link to confirm your account.", + "subtitle": "Tap the link we emailed to you to verify your account.", "button": { "open_email_app": "Open Email App", - "dont_receive_email": "I never got an email" + "resend": "Resend" }, "dont_receive_email": { "title": "Check your email", @@ -340,14 +358,14 @@ "replying_to_user": "replying to %s", "attachment": { "photo": "photo", - "video": "video", + "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..." }, "poll": { "duration_time": "Duration: %s", - "thirty_minutes": "30 minutes", + "thirty_minutes": "30 分鐘", "one_hour": "1 Hour", "six_hours": "6 Hours", "one_day": "1 Day", @@ -399,16 +417,26 @@ } }, "segmented_control": { - "posts": "Posts", + "posts": "貼文", "replies": "Replies", - "media": "Media" + "posts_and_replies": "Posts and Replies", + "media": "Media", + "about": "About" }, "relationship_action_alert": { + "confirm_mute_user": { + "title": "Mute Account", + "message": "Confirm to mute %s" + }, "confirm_unmute_user": { "title": "Unmute Account", "message": "Confirm to unmute %s" }, - "confirm_unblock_usre": { + "confirm_block_user": { + "title": "Block Account", + "message": "Confirm to block %s" + }, + "confirm_unblock_user": { "title": "Unblock Account", "message": "Confirm to unblock %s" } @@ -421,10 +449,10 @@ "footer": "Follows from other servers are not displayed." }, "search": { - "title": "Search", + "title": "搜尋", "search_bar": { "placeholder": "Search hashtags and users", - "cancel": "Cancel" + "cancel": "取消" }, "recommend": { "button_text": "See All", @@ -436,7 +464,7 @@ "accounts": { "title": "Accounts you might like", "description": "You may like to follow these accounts", - "follow": "Follow" + "follow": "追蹤" } }, "searching": { @@ -444,7 +472,7 @@ "all": "All", "people": "People", "hashtags": "Hashtags", - "posts": "Posts" + "posts": "貼文" }, "empty_state": { "no_results": "No results" @@ -461,12 +489,14 @@ "Everything": "Everything", "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", + "notification_description": { + "followed_you": "followed you", + "favorited_your_post": "favorited your post", + "reblogged_your_post": "reblogged your post", + "mentioned_you": "mentioned you", + "request_to_follow_you": "request to follow you", + "poll_has_ended": "poll has ended" + }, "keyobard": { "show_everything": "Show Everything", "show_mentions": "Show Mentions" @@ -477,14 +507,21 @@ "title": "Post from %s" }, "settings": { - "title": "Settings", + "title": "設定", "section": { "appearance": { "title": "Appearance", - "automatic": "Automatic", + "automatic": "自動", "light": "Always Light", "dark": "Always Dark" }, + "look_and_feel": { + "title": "Look and Feel", + "use_system": "Use System", + "really_dark": "Really Dark", + "sorta_dark": "Sorta Dark", + "light": "Light" + }, "notifications": { "title": "Notifications", "favorites": "Favorites my post", @@ -515,7 +552,7 @@ "spicy_zone": { "title": "The Spicy Zone", "clear": "Clear Media Cache", - "signout": "Sign Out" + "signout": "登出" } }, "footer": { @@ -526,14 +563,17 @@ } }, "report": { + "title_report": "Report", "title": "Report %s", "step1": "Step 1 of 2", "step2": "Step 2 of 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?", + "report_sent_title": "Thanks for reporting, we’ll look into this.", "send": "Send Report", "skip_to_send": "Send without comment", - "text_placeholder": "Type or paste additional comments" + "text_placeholder": "Type or paste additional comments", + "reported": "REPORTED" }, "preview": { "keyboard": { diff --git a/Localization/app.json b/Localization/app.json index 6d3b2fcc2..f0dc0ebf1 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -45,8 +45,8 @@ "message": "Please enable the photo library access permission to save the photo." }, "delete_post": { - "title": "Are you sure you want to delete this post?", - "delete": "Delete" + "title": "Delete Post", + "message": "Are you sure you want to delete this post?" }, "clean_cache": { "title": "Clean Cache", @@ -130,6 +130,7 @@ "show_user_profile": "Show user profile", "content_warning": "Content Warning", "media_content_warning": "Tap anywhere to reveal", + "tap_to_reveal": "Tap to reveal", "poll": { "vote": "Vote", "closed": "Closed" @@ -140,7 +141,12 @@ "unreblog": "Undo reblog", "favorite": "Favorite", "unfavorite": "Unfavorite", - "menu": "Menu" + "menu": "Menu", + "hide": "Hide", + "show_image": "Show image", + "show_gif": "Show GIF", + "show_video_player": "Show video player", + "tap_then_hold_to_show_menu": "Tap then hold to show menu" }, "tag": { "url": "URL", @@ -149,6 +155,12 @@ "hashtag": "Hashtag", "email": "Email", "emoji": "Emoji" + }, + "visibility": { + "unlisted": "Everyone can see this post but not display in the public timeline.", + "private": "Only their followers can see this post.", + "private_from_me": "Only my followers can see this post.", + "direct": "Only mentioned user can see this post." } }, "friendship": { @@ -193,10 +205,14 @@ }, "scene": { "welcome": { - "slogan": "Social networking\nback in your hands." + "slogan": "Social networking\nback in your hands.", + "get_started": "Get Started", + "log_in": "Log In" }, "server_picker": { - "title": "Pick a server,\nany server.", + "title": "Mastodon is made of users in different communities.", + "subtitle": "Pick a community based on your interests, region, or a general purpose one.", + "subtitle_extend": "Pick a community based on your interests, region, or a general purpose one. Each community is operated by an entirely independent organization or individual.", "button": { "category": { "all": "All", @@ -223,7 +239,7 @@ "category": "CATEGORY" }, "input": { - "placeholder": "Find a server or join your own..." + "placeholder": "Search communities" }, "empty_state": { "finding_servers": "Finding available servers...", @@ -232,7 +248,7 @@ } }, "register": { - "title": "Tell us about you.", + "title": "Let’s get you set up on %s", "input": { "avatar": { "delete": "Delete" @@ -249,6 +265,12 @@ }, "password": { "placeholder": "password", + "require": "Your password needs at least:", + "character_limit": "8 characters", + "accessibility": { + "checked": "checked", + "unchecked": "unchecked" + }, "hint": "Your password needs at least eight characters" }, "invite": { @@ -286,7 +308,7 @@ }, "server_rules": { "title": "Some ground rules.", - "subtitle": "These rules are set by the admins of %s.", + "subtitle": "These are set and enforced by the %s moderators.", "prompt": "By continuing, you’re subject to the terms of service and privacy policy for %s.", "terms_of_service": "terms of service", "privacy_policy": "privacy policy", @@ -296,10 +318,10 @@ }, "confirm_email": { "title": "One last thing.", - "subtitle": "We just sent an email to %s,\ntap the link to confirm your account.", + "subtitle": "Tap the link we emailed to you to verify your account.", "button": { "open_email_app": "Open Email App", - "dont_receive_email": "I never got an email" + "resend": "Resend" }, "dont_receive_email": { "title": "Check your email", @@ -402,17 +424,33 @@ "segmented_control": { "posts": "Posts", "replies": "Replies", - "media": "Media" + "posts_and_replies": "Posts and Replies", + "media": "Media", + "about": "About" }, "relationship_action_alert": { + "confirm_mute_user": { + "title": "Mute Account", + "message": "Confirm to mute %s" + }, "confirm_unmute_user": { "title": "Unmute Account", "message": "Confirm to unmute %s" }, - "confirm_unblock_usre": { + "confirm_block_user": { + "title": "Block Account", + "message": "Confirm to block %s" + }, + "confirm_unblock_user": { "title": "Unblock Account", "message": "Confirm to unblock %s" } + }, + "accessibility": { + "show_avatar_image": "Show avatar image", + "edit_avatar_image": "Edit avatar image", + "show_banner_image": "Show banner image", + "double_tap_to_open_the_list": "Double tap to open the list" } }, "follower": { @@ -462,12 +500,14 @@ "Everything": "Everything", "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", + "notification_description": { + "followed_you": "followed you", + "favorited_your_post": "favorited your post", + "reblogged_your_post": "reblogged your post", + "mentioned_you": "mentioned you", + "request_to_follow_you": "request to follow you", + "poll_has_ended": "poll has ended" + }, "keyobard": { "show_everything": "Show Everything", "show_mentions": "Show Mentions" @@ -486,6 +526,13 @@ "light": "Always Light", "dark": "Always Dark" }, + "look_and_feel": { + "title": "Look and Feel", + "use_system": "Use System", + "really_dark": "Really Dark", + "sorta_dark": "Sorta Dark", + "light": "Light" + }, "notifications": { "title": "Notifications", "favorites": "Favorites my post", @@ -505,7 +552,8 @@ "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" + "using_default_browser": "Use default browser to open links", + "open_links_in_mastodon": "Open links in Mastodon" }, "boring_zone": { "title": "The Boring Zone", @@ -527,14 +575,17 @@ } }, "report": { + "title_report": "Report", "title": "Report %s", "step1": "Step 1 of 2", "step2": "Step 2 of 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?", + "report_sent_title": "Thanks for reporting, we’ll look into this.", "send": "Send Report", "skip_to_send": "Send without comment", - "text_placeholder": "Type or paste additional comments" + "text_placeholder": "Type or paste additional comments", + "reported": "REPORTED" }, "preview": { "keyboard": { diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 6b7644e3e..69596a202 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -11,64 +11,38 @@ 0F2021FB2613262F000C64BF /* HashtagTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F2021FA2613262F000C64BF /* HashtagTimelineViewController.swift */; }; 0F202201261326E6000C64BF /* HashtagTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */; }; 0F20220726134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */; }; - 0F20220D26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20220C26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift */; }; 0F202213261351F5000C64BF /* APIService+HashtagTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F202212261351F5000C64BF /* APIService+HashtagTimeline.swift */; }; - 0F202227261411BB000C64BF /* HashtagTimelineViewController+Provider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F202226261411BA000C64BF /* HashtagTimelineViewController+Provider.swift */; }; 0F20222D261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20222C261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift */; }; - 0F20223326145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20223226145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift */; }; 0F20223926146553000C64BF /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20223826146553000C64BF /* Array.swift */; }; 0FAA0FDF25E0B57E0017CCDE /* WelcomeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA0FDE25E0B57E0017CCDE /* WelcomeViewController.swift */; }; 0FAA101225E105390017CCDE /* PrimaryActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA101125E105390017CCDE /* PrimaryActionButton.swift */; }; 0FAA101C25E10E760017CCDE /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA101B25E10E760017CCDE /* UIFont.swift */; }; 0FAA102725E1126A0017CCDE /* MastodonPickServerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA102625E1126A0017CCDE /* MastodonPickServerViewController.swift */; }; 0FB3D2F725E4C24D00AAD544 /* MastodonPickServerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FB3D2F625E4C24D00AAD544 /* MastodonPickServerViewModel.swift */; }; - 0FB3D2FE25E4CB6400AAD544 /* PickServerTitleCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FB3D2FD25E4CB6400AAD544 /* PickServerTitleCell.swift */; }; - 0FB3D30825E524C600AAD544 /* PickServerCategoriesCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FB3D30725E524C600AAD544 /* PickServerCategoriesCell.swift */; }; + 0FB3D2FE25E4CB6400AAD544 /* OnboardingHeadlineTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FB3D2FD25E4CB6400AAD544 /* OnboardingHeadlineTableViewCell.swift */; }; 0FB3D30F25E525CD00AAD544 /* PickServerCategoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FB3D30E25E525CD00AAD544 /* PickServerCategoryView.swift */; }; 0FB3D31E25E534C700AAD544 /* PickServerCategoryCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FB3D31D25E534C700AAD544 /* PickServerCategoryCollectionViewCell.swift */; }; - 0FB3D33225E5F50E00AAD544 /* PickServerSearchCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FB3D33125E5F50E00AAD544 /* PickServerSearchCell.swift */; }; 0FB3D33825E6401400AAD544 /* PickServerCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FB3D33725E6401400AAD544 /* PickServerCell.swift */; }; 164F0EBC267D4FE400249499 /* BoopSound.caf in Resources */ = {isa = PBXBuildFile; fileRef = 164F0EBB267D4FE400249499 /* BoopSound.caf */; }; 18BC7629F65E6DB12CB8416D /* Pods_Mastodon_MastodonUITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */; }; - 2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */; }; - 2D084B8D26258EA3003AA3AF /* NotificationViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D084B8C26258EA3003AA3AF /* NotificationViewModel+Diffable.swift */; }; - 2D084B9326259545003AA3AF /* NotificationViewModel+LoadLatestState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D084B9226259545003AA3AF /* NotificationViewModel+LoadLatestState.swift */; }; - 2D0B7A1D261D839600B44727 /* SearchHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D0B7A1C261D839600B44727 /* SearchHistory.swift */; }; - 2D152A8C25C295CC009AA50C /* StatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A8B25C295CC009AA50C /* StatusView.swift */; }; - 2D152A9225C2980C009AA50C /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A9125C2980C009AA50C /* UIFont.swift */; }; 2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198642261BF09500F0B013 /* SearchResultItem.swift */; }; 2D198649261C0B8500F0B013 /* SearchResultSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198648261C0B8500F0B013 /* SearchResultSection.swift */; }; - 2D206B7225F5D27F00143C56 /* AudioContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B7125F5D27F00143C56 /* AudioContainerView.swift */; }; 2D206B8625F5FB0900143C56 /* Double.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B8525F5FB0900143C56 /* Double.swift */; }; - 2D206B8C25F6015000143C56 /* AudioPlaybackService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B8B25F6015000143C56 /* AudioPlaybackService.swift */; }; 2D206B9225F60EA700143C56 /* UIControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B9125F60EA700143C56 /* UIControl.swift */; }; - 2D24E11D2626D8B100A59D4F /* NotificationStatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D24E11C2626D8B100A59D4F /* NotificationStatusTableViewCell.swift */; }; 2D24E1232626ED9D00A59D4F /* UIView+Gesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D24E1222626ED9D00A59D4F /* UIView+Gesture.swift */; }; - 2D24E12D2626FD2E00A59D4F /* NotificationViewModel+LoadOldestState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D24E12C2626FD2E00A59D4F /* NotificationViewModel+LoadOldestState.swift */; }; 2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */; }; 2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAB925CB9B0500C9ED86 /* UIView.swift */; }; - 2D32EADA25CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAD925CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift */; }; - 2D34D9CB261489930081BFC0 /* SearchViewController+Recommend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D34D9CA261489930081BFC0 /* SearchViewController+Recommend.swift */; }; 2D34D9D126148D9E0081BFC0 /* APIService+Recommend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D34D9D026148D9E0081BFC0 /* APIService+Recommend.swift */; }; 2D34D9DB261494120081BFC0 /* APIService+Search.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D34D9DA261494120081BFC0 /* APIService+Search.swift */; }; - 2D34D9E226149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D34D9E126149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift */; }; 2D35237A26256D920031AF25 /* NotificationSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D35237926256D920031AF25 /* NotificationSection.swift */; }; 2D364F7225E66D7500204FDC /* MastodonResendEmailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D364F7125E66D7500204FDC /* MastodonResendEmailViewController.swift */; }; 2D364F7825E66D8300204FDC /* MastodonResendEmailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D364F7725E66D8300204FDC /* MastodonResendEmailViewModel.swift */; }; - 2D38F1C625CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D38F1C525CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift */; }; 2D38F1D525CD465300561493 /* HomeTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D38F1D425CD465300561493 /* HomeTimelineViewController.swift */; }; - 2D38F1DF25CD46A400561493 /* HomeTimelineViewController+Provider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D38F1DE25CD46A400561493 /* HomeTimelineViewController+Provider.swift */; }; 2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D38F1E425CD46C100561493 /* HomeTimelineViewModel.swift */; }; 2D38F1EB25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D38F1EA25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift */; }; - 2D38F1F125CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D38F1F025CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift */; }; 2D38F1F725CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D38F1F625CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift */; }; - 2D38F1FE25CD481700561493 /* StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D38F1FD25CD481700561493 /* StatusProvider.swift */; }; 2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D38F20725CD491300561493 /* DisposeBagCollectable.swift */; }; 2D3F9E0425DFA133004262D9 /* UITapGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */; }; - 2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF7D25C82218004A627A /* ActionToolBarContainer.swift */; }; - 2D42FF8525C8224F004A627A /* HitTestExpandedButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF8425C8224F004A627A /* HitTestExpandedButton.swift */; }; - 2D42FF8F25C8228A004A627A /* UIButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF8E25C8228A004A627A /* UIButton.swift */; }; - 2D45E5BF25C9549700A6D639 /* PublicTimelineViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D45E5BE25C9549700A6D639 /* PublicTimelineViewModel+State.swift */; }; 2D4AD89C263165B500613EFC /* SuggestionAccountCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D4AD89B263165B500613EFC /* SuggestionAccountCollectionViewCell.swift */; }; 2D4AD8A226316CD200613EFC /* SelectedAccountSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D4AD8A126316CD200613EFC /* SelectedAccountSection.swift */; }; 2D4AD8A826316D3500613EFC /* SelectedAccountItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D4AD8A726316D3500613EFC /* SelectedAccountItem.swift */; }; @@ -81,81 +55,50 @@ 2D5A3D3825CF8D9F002347D6 /* ScrollViewContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5A3D3725CF8D9F002347D6 /* ScrollViewContainer.swift */; }; 2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5A3D6125CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift */; }; 2D607AD826242FC500B70763 /* NotificationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D607AD726242FC500B70763 /* NotificationViewModel.swift */; }; - 2D6125472625436B00299647 /* Notification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D6125462625436B00299647 /* Notification.swift */; }; 2D61254D262547C200299647 /* APIService+Notification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61254C262547C200299647 /* APIService+Notification.swift */; }; - 2D61335825C188A000CAE157 /* APIService+Persist+Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335725C188A000CAE157 /* APIService+Persist+Status.swift */; }; 2D61335E25C1894B00CAE157 /* APIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335D25C1894B00CAE157 /* APIService.swift */; }; 2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = 2D61336825C18A4F00CAE157 /* AlamofireNetworkActivityIndicator */; }; 2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */; }; 2D694A7425F9EB4E0038ADDC /* ContentWarningOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D694A7325F9EB4E0038ADDC /* ContentWarningOverlayView.swift */; }; - 2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */; }; - 2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Status.swift */; }; 2D6DE40026141DF600A63F6A /* SearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D6DE3FF26141DF600A63F6A /* SearchViewModel.swift */; }; - 2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */; }; - 2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76316A25C14D4C00929FB9 /* PublicTimelineViewModel.swift */; }; - 2D76317D25C14DF500929FB9 /* PublicTimelineViewController+Provider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76317C25C14DF400929FB9 /* PublicTimelineViewController+Provider.swift */; }; - 2D76318325C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76318225C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift */; }; 2D76319F25C1521200929FB9 /* StatusSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76319E25C1521200929FB9 /* StatusSection.swift */; }; 2D7631A825C1535600929FB9 /* StatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D7631A725C1535600929FB9 /* StatusTableViewCell.swift */; }; - 2D7631B325C159F700929FB9 /* Item.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D7631B225C159F700929FB9 /* Item.swift */; }; 2D7867192625B77500211898 /* NotificationItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D7867182625B77500211898 /* NotificationItem.swift */; }; - 2D79E701261EA5550011E398 /* APIService+CoreData+Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D79E700261EA5550011E398 /* APIService+CoreData+Tag.swift */; }; 2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D82B9FE25E7863200E36F0F /* OnboardingViewControllerAppearance.swift */; }; 2D82BA0525E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D82BA0425E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift */; }; 2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D8434F425FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift */; }; 2D8434FB25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D8434FA25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift */; }; 2D84350525FF858100EECE90 /* UIScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D84350425FF858100EECE90 /* UIScrollView.swift */; }; 2D8FCA082637EABB00137F46 /* APIService+FollowRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D8FCA072637EABB00137F46 /* APIService+FollowRequest.swift */; }; - 2D927F0225C7E4F2004F19B8 /* Mention.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F0125C7E4F2004F19B8 /* Mention.swift */; }; - 2D927F0825C7E9A8004F19B8 /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F0725C7E9A8004F19B8 /* Tag.swift */; }; - 2D927F0E25C7E9C9004F19B8 /* History.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F0D25C7E9C9004F19B8 /* History.swift */; }; - 2D927F1425C7EDD9004F19B8 /* Emoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F1325C7EDD9004F19B8 /* Emoji.swift */; }; 2D939AB525EDD8A90076FA61 /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D939AB425EDD8A90076FA61 /* String.swift */; }; 2D939AC825EE14620076FA61 /* CropViewController in Frameworks */ = {isa = PBXBuildFile; productRef = 2D939AC725EE14620076FA61 /* CropViewController */; }; 2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D939AE725EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift */; }; 2D9DB967263A76FB007C1D71 /* BlockDomainService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D9DB966263A76FB007C1D71 /* BlockDomainService.swift */; }; - 2D9DB969263A833E007C1D71 /* DomainBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D9DB968263A833E007C1D71 /* DomainBlock.swift */; }; 2D9DB96B263A91D1007C1D71 /* APIService+DomainBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D9DB96A263A91D1007C1D71 /* APIService+DomainBlock.swift */; }; 2DA504692601ADE7008F4E6C /* SawToothView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA504682601ADE7008F4E6C /* SawToothView.swift */; }; 2DA6054725F716A2006356F9 /* PlaybackState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA6054625F716A2006356F9 /* PlaybackState.swift */; }; - 2DA6055125F74407006356F9 /* AudioContainerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA6055025F74407006356F9 /* AudioContainerViewModel.swift */; }; 2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */; }; 2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */; }; - 2DA7D05725CA693F00804E11 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D05625CA693F00804E11 /* Application.swift */; }; 2DAC9E38262FC2320062E1A6 /* SuggestionAccountViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DAC9E37262FC2320062E1A6 /* SuggestionAccountViewController.swift */; }; 2DAC9E3E262FC2400062E1A6 /* SuggestionAccountViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DAC9E3D262FC2400062E1A6 /* SuggestionAccountViewModel.swift */; }; 2DAC9E46262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DAC9E45262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift */; }; 2DB72C8C262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DB72C8B262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift */; }; 2DCB73FD2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DCB73FC2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift */; }; - 2DE0FAC12615F04D00CDF649 /* RecommendHashTagSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DE0FAC02615F04D00CDF649 /* RecommendHashTagSection.swift */; }; - 2DE0FAC82615F5F000CDF649 /* SearchRecommendAccountsCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DE0FAC72615F5F000CDF649 /* SearchRecommendAccountsCollectionViewCell.swift */; }; 2DE0FACE2615F7AD00CDF649 /* RecommendAccountSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DE0FACD2615F7AD00CDF649 /* RecommendAccountSection.swift */; }; 2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF123A625C3B0210020F248 /* ActiveLabel.swift */; }; - 2DF75B9B25D0E27500694EC8 /* StatusProviderFacade.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75B9A25D0E27500694EC8 /* StatusProviderFacade.swift */; }; - 2DF75BA125D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BA025D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift */; }; 2DF75BA725D10E1000694EC8 /* APIService+Favorite.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BA625D10E1000694EC8 /* APIService+Favorite.swift */; }; - 2DF75BB925D1474100694EC8 /* ManagedObjectObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BB825D1474100694EC8 /* ManagedObjectObserver.swift */; }; - 2DF75BC725D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BC625D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift */; }; - 2DFAD5372617010500F9EE7C /* SearchResultTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DFAD5362617010500F9EE7C /* SearchResultTableViewCell.swift */; }; 4278334D6033AEEE0A1C5155 /* Pods_ShareActionExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A32B0CACBF35F4CC3CFAA043 /* Pods_ShareActionExtension.framework */; }; 5B24BBDA262DB14800A9381B /* ReportViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B24BBD7262DB14800A9381B /* ReportViewModel.swift */; }; 5B24BBDB262DB14800A9381B /* ReportViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B24BBD8262DB14800A9381B /* ReportViewModel+Diffable.swift */; }; 5B24BBE2262DB19100A9381B /* APIService+Report.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B24BBE1262DB19100A9381B /* APIService+Report.swift */; }; - 5B8E055826319E47006E3C53 /* ReportFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8E055726319E47006E3C53 /* ReportFooterView.swift */; }; 5B90C45E262599800002E742 /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C456262599800002E742 /* SettingsViewModel.swift */; }; 5B90C45F262599800002E742 /* SettingsToggleTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C459262599800002E742 /* SettingsToggleTableViewCell.swift */; }; 5B90C460262599800002E742 /* SettingsAppearanceTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C45A262599800002E742 /* SettingsAppearanceTableViewCell.swift */; }; 5B90C461262599800002E742 /* SettingsLinkTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C45B262599800002E742 /* SettingsLinkTableViewCell.swift */; }; 5B90C462262599800002E742 /* SettingsSectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C45C262599800002E742 /* SettingsSectionHeader.swift */; }; - 5B90C46E26259B2C0002E742 /* Subscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C46C26259B2C0002E742 /* Subscription.swift */; }; - 5B90C46F26259B2C0002E742 /* Setting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C46D26259B2C0002E742 /* Setting.swift */; }; - 5B90C47F26259BA90002E742 /* SubscriptionAlerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C47E26259BA90002E742 /* SubscriptionAlerts.swift */; }; 5B90C48526259BF10002E742 /* APIService+Subscriptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C48426259BF10002E742 /* APIService+Subscriptions.swift */; }; 5B90C48B26259C120002E742 /* APIService+CoreData+Subscriptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C48A26259C120002E742 /* APIService+CoreData+Subscriptions.swift */; }; 5BB04FD5262E7AFF0043BFF6 /* ReportViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB04FD4262E7AFF0043BFF6 /* ReportViewController.swift */; }; - 5BB04FDB262EA3070043BFF6 /* ReportHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB04FDA262EA3070043BFF6 /* ReportHeaderView.swift */; }; - 5BB04FE9262EFC300043BFF6 /* ReportedStatusTableviewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB04FE8262EFC300043BFF6 /* ReportedStatusTableviewCell.swift */; }; - 5BB04FEF262F0DCB0043BFF6 /* ReportViewModel+Data.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB04FEE262F0DCB0043BFF6 /* ReportViewModel+Data.swift */; }; 5BB04FF5262F0E6D0043BFF6 /* ReportSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB04FF4262F0E6D0043BFF6 /* ReportSection.swift */; }; 5D0393902612D259007FE196 /* WebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D03938F2612D259007FE196 /* WebViewController.swift */; }; 5D0393962612D266007FE196 /* WebViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D0393952612D266007FE196 /* WebViewModel.swift */; }; @@ -164,13 +107,7 @@ 5DDDF1932617442700311060 /* Mastodon+Entity+Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DDDF1922617442700311060 /* Mastodon+Entity+Account.swift */; }; 5DDDF1992617447F00311060 /* Mastodon+Entity+Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DDDF1982617447F00311060 /* Mastodon+Entity+Tag.swift */; }; 5DDDF1A92617489F00311060 /* Mastodon+Entity+History.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DDDF1A82617489F00311060 /* Mastodon+Entity+History.swift */; }; - 5DF1054125F886D400D6C0D4 /* VideoPlaybackService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1054025F886D400D6C0D4 /* VideoPlaybackService.swift */; }; - 5DF1054725F8870E00D6C0D4 /* VideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1054625F8870E00D6C0D4 /* VideoPlayerViewModel.swift */; }; 5DF1056425F887CB00D6C0D4 /* AVPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */; }; - 5DF1057925F88A1D00D6C0D4 /* PlayerContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1057825F88A1D00D6C0D4 /* PlayerContainerView.swift */; }; - 5DF1057F25F88A4100D6C0D4 /* TouchBlockingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1057E25F88A4100D6C0D4 /* TouchBlockingView.swift */; }; - 5DF1058525F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1058425F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift */; }; - 5DFC35DF262068D20045711D /* SearchViewController+Follow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DFC35DE262068D20045711D /* SearchViewController+Follow.swift */; }; 5E0DEC05797A7E6933788DDB /* Pods_MastodonTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */; }; 5E44BF88AD33646E64727BCF /* Pods_MastodonTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CD92E0F10BDE4FE7C4B999F2 /* Pods_MastodonTests.framework */; }; 87FFDA5D898A5C42ADCB35E7 /* Pods_Mastodon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */; }; @@ -183,7 +120,14 @@ DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140CE25C42AEE00F9F3CF /* OSLog.swift */; }; DB01E23326A98F0900C3965B /* MastodonMeta in Frameworks */ = {isa = PBXBuildFile; productRef = DB01E23226A98F0900C3965B /* MastodonMeta */; }; DB01E23526A98F0900C3965B /* MetaTextKit in Frameworks */ = {isa = PBXBuildFile; productRef = DB01E23426A98F0900C3965B /* MetaTextKit */; }; - DB023295267F0AB800031745 /* ASMetaEditableTextNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB023294267F0AB800031745 /* ASMetaEditableTextNode.swift */; }; + DB023D26279FFB0A005AC798 /* ShareActivityProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB023D25279FFB0A005AC798 /* ShareActivityProvider.swift */; }; + DB023D2827A0FABD005AC798 /* NotificationTableViewCellDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB023D2727A0FABD005AC798 /* NotificationTableViewCellDelegate.swift */; }; + DB023D2A27A0FE5C005AC798 /* DataSourceProvider+NotificationTableViewCellDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB023D2927A0FE5C005AC798 /* DataSourceProvider+NotificationTableViewCellDelegate.swift */; }; + DB023D2C27A10464005AC798 /* NotificationTimelineViewController+DataSourceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB023D2B27A10464005AC798 /* NotificationTimelineViewController+DataSourceProvider.swift */; }; + DB025B78278D606A002F581E /* StatusItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB025B77278D606A002F581E /* StatusItem.swift */; }; + DB025B93278D6501002F581E /* Persistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB025B92278D6501002F581E /* Persistence.swift */; }; + DB025B95278D6530002F581E /* Persistence+MastodonUser.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB025B94278D6530002F581E /* Persistence+MastodonUser.swift */; }; + DB025B97278D66D5002F581E /* MastodonUser+Property.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB025B96278D66D5002F581E /* MastodonUser+Property.swift */; }; 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 */; }; @@ -192,30 +136,61 @@ 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 */; }; + DB0617EB277EF3820030EE79 /* GradientBorderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0617EA277EF3820030EE79 /* GradientBorderView.swift */; }; + DB0617ED277F02C50030EE79 /* OnboardingNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0617EC277F02C50030EE79 /* OnboardingNavigationController.swift */; }; + DB0617EF277F12720030EE79 /* NavigationActionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0617EE277F12720030EE79 /* NavigationActionView.swift */; }; + DB0617F1278413D00030EE79 /* PickServerServerSectionTableHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0617F0278413D00030EE79 /* PickServerServerSectionTableHeaderView.swift */; }; + DB0617F527855AB90030EE79 /* ServerRuleSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0617F427855AB90030EE79 /* ServerRuleSection.swift */; }; + DB0617FD27855BFE0030EE79 /* ServerRuleItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0617FC27855BFE0030EE79 /* ServerRuleItem.swift */; }; + DB0617FF27855D6C0030EE79 /* MastodonServerRulesViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0617FE27855D6C0030EE79 /* MastodonServerRulesViewModel+Diffable.swift */; }; + DB0618012785732C0030EE79 /* ServerRulesTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0618002785732C0030EE79 /* ServerRulesTableViewCell.swift */; }; + DB0618032785A7100030EE79 /* RegisterSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0618022785A7100030EE79 /* RegisterSection.swift */; }; + DB0618052785A73D0030EE79 /* RegisterItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0618042785A73D0030EE79 /* RegisterItem.swift */; }; + DB0618072785A8880030EE79 /* MastodonRegisterViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0618062785A8880030EE79 /* MastodonRegisterViewModel+Diffable.swift */; }; + DB06180A2785B2AB0030EE79 /* MastodonRegisterAvatarTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0618092785B2AB0030EE79 /* MastodonRegisterAvatarTableViewCell.swift */; }; DB084B5725CBC56C00F898ED /* Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB084B5625CBC56C00F898ED /* Status.swift */; }; DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */; }; DB0C946526A6FD4D0088FB11 /* AlamofireImage in Frameworks */ = {isa = PBXBuildFile; productRef = DB0C946426A6FD4D0088FB11 /* AlamofireImage */; }; - DB0C946B26A700AB0088FB11 /* MastodonUser+Property.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0C946A26A700AB0088FB11 /* MastodonUser+Property.swift */; }; - DB0C946C26A700CE0088FB11 /* MastodonUser+Property.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0C946A26A700AB0088FB11 /* MastodonUser+Property.swift */; }; - DB0C946F26A7D2A80088FB11 /* AvatarImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0C946E26A7D2A80088FB11 /* AvatarImageView.swift */; }; - 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 */; }; + DB0FCB68279507EF006C02E2 /* DataSourceFacade+Meta.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0FCB67279507EF006C02E2 /* DataSourceFacade+Meta.swift */; }; + DB0FCB6C27950E29006C02E2 /* MastodonMentionContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0FCB6B27950E29006C02E2 /* MastodonMentionContainer.swift */; }; + DB0FCB6E27950E6B006C02E2 /* MastodonMention.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0FCB6D27950E6B006C02E2 /* MastodonMention.swift */; }; + DB0FCB7027951368006C02E2 /* TimelineMiddleLoaderTableViewCell+ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0FCB6F27951368006C02E2 /* TimelineMiddleLoaderTableViewCell+ViewModel.swift */; }; + DB0FCB7227952986006C02E2 /* NamingState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0FCB7127952986006C02E2 /* NamingState.swift */; }; + DB0FCB7427956939006C02E2 /* DataSourceFacade+Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0FCB7327956939006C02E2 /* DataSourceFacade+Status.swift */; }; + DB0FCB76279571C5006C02E2 /* ThreadViewController+DataSourceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0FCB75279571C5006C02E2 /* ThreadViewController+DataSourceProvider.swift */; }; + DB0FCB7827957678006C02E2 /* DataSourceProvider+UITableViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0FCB7727957678006C02E2 /* DataSourceProvider+UITableViewDelegate.swift */; }; + DB0FCB7A279576A2006C02E2 /* DataSourceFacade+Thread.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0FCB79279576A2006C02E2 /* DataSourceFacade+Thread.swift */; }; + DB0FCB7C2795821F006C02E2 /* StatusThreadRootTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0FCB7B2795821F006C02E2 /* StatusThreadRootTableViewCell.swift */; }; + DB0FCB7E27958957006C02E2 /* StatusThreadRootTableViewCell+ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0FCB7D27958957006C02E2 /* StatusThreadRootTableViewCell+ViewModel.swift */; }; + DB0FCB8027968F70006C02E2 /* MastodonStatusThreadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0FCB7F27968F70006C02E2 /* MastodonStatusThreadViewModel.swift */; }; + DB0FCB822796AC78006C02E2 /* UserTimelineViewController+DataSourceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0FCB812796AC78006C02E2 /* UserTimelineViewController+DataSourceProvider.swift */; }; + DB0FCB842796B2A2006C02E2 /* FavoriteViewController+DataSourceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0FCB832796B2A2006C02E2 /* FavoriteViewController+DataSourceProvider.swift */; }; + DB0FCB862796BDA1006C02E2 /* SearchSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0FCB852796BDA1006C02E2 /* SearchSection.swift */; }; + DB0FCB882796BDA9006C02E2 /* SearchItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0FCB872796BDA9006C02E2 /* SearchItem.swift */; }; + DB0FCB8C2796BF8D006C02E2 /* SearchViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0FCB8B2796BF8D006C02E2 /* SearchViewModel+Diffable.swift */; }; + DB0FCB8E2796C0B7006C02E2 /* TrendCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0FCB8D2796C0B7006C02E2 /* TrendCollectionViewCell.swift */; }; + DB0FCB902796C5EB006C02E2 /* APIService+Trend.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0FCB8F2796C5EB006C02E2 /* APIService+Trend.swift */; }; + DB0FCB922796DE19006C02E2 /* TrendSectionHeaderCollectionReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0FCB912796DE19006C02E2 /* TrendSectionHeaderCollectionReusableView.swift */; }; + DB0FCB942797E2B0006C02E2 /* SearchResultViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0FCB932797E2B0006C02E2 /* SearchResultViewModel+Diffable.swift */; }; + DB0FCB962797E6C2006C02E2 /* SearchResultViewController+DataSourceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0FCB952797E6C2006C02E2 /* SearchResultViewController+DataSourceProvider.swift */; }; + DB0FCB982797F6BF006C02E2 /* UserTableViewCell+ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0FCB972797F6BF006C02E2 /* UserTableViewCell+ViewModel.swift */; }; + DB0FCB9A2797F7AD006C02E2 /* UserView+Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0FCB992797F7AD006C02E2 /* UserView+Configuration.swift */; }; + DB0FCB9C27980AB6006C02E2 /* HashtagTimelineViewController+DataSourceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0FCB9B27980AB6006C02E2 /* HashtagTimelineViewController+DataSourceProvider.swift */; }; DB118A8225E4B6E600FAB162 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DB118A8125E4B6E600FAB162 /* Preview Assets.xcassets */; }; + DB159C2B27A17BAC0068DC77 /* DataSourceFacade+Media.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB159C2A27A17BAC0068DC77 /* DataSourceFacade+Media.swift */; }; + DB179267278D5A4A00B71DEB /* MastodonSDK in Frameworks */ = {isa = PBXBuildFile; productRef = DB179266278D5A4A00B71DEB /* MastodonSDK */; }; 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 */; }; DB1D843426579931000346B3 /* TableViewControllerNavigateable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1D843326579931000346B3 /* TableViewControllerNavigateable.swift */; }; - DB1D843626579DB5000346B3 /* StatusProvider+TableViewControllerNavigateable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1D843526579DB5000346B3 /* StatusProvider+TableViewControllerNavigateable.swift */; }; DB1D84382657B275000346B3 /* SegmentedControlNavigateable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1D84372657B275000346B3 /* SegmentedControlNavigateable.swift */; }; DB1E346825F518E20079D7DF /* CategoryPickerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1E346725F518E20079D7DF /* CategoryPickerSection.swift */; }; DB1E347825F519300079D7DF /* PickServerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1E347725F519300079D7DF /* PickServerItem.swift */; }; - DB1EE7B2267F9525000CC337 /* StatusProvider+StatusNodeDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1EE7B1267F9525000CC337 /* StatusProvider+StatusNodeDelegate.swift */; }; DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD43525F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift */; }; DB1FD44425F26CCC004CFCFC /* PickServerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD44325F26CCC004CFCFC /* PickServerSection.swift */; }; DB1FD45025F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD44F25F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift */; }; @@ -225,8 +200,22 @@ DB2B3ABC25E37E15007045F9 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = DB2B3ABE25E37E15007045F9 /* InfoPlist.strings */; }; DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */; }; DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2FF50F260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift */; }; + DB336F1C278D697E0031E64B /* MastodonUser.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FAE225CA7181005A8AC7 /* MastodonUser.swift */; }; + DB336F21278D6D960031E64B /* MastodonEmoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB336F20278D6D960031E64B /* MastodonEmoji.swift */; }; + DB336F23278D6DED0031E64B /* MastodonEmojiContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB336F22278D6DED0031E64B /* MastodonEmojiContainer.swift */; }; + DB336F28278D6EC70031E64B /* MastodonFieldContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB336F27278D6EC70031E64B /* MastodonFieldContainer.swift */; }; + DB336F2A278D6F2B0031E64B /* MastodonField.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB336F29278D6F2B0031E64B /* MastodonField.swift */; }; + DB336F2C278D6FC30031E64B /* Persistence+Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB336F2B278D6FC30031E64B /* Persistence+Status.swift */; }; + DB336F2E278D71AF0031E64B /* Status+Property.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB336F2D278D71AF0031E64B /* Status+Property.swift */; }; + DB336F32278D77330031E64B /* Persistence+Poll.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB336F31278D77330031E64B /* Persistence+Poll.swift */; }; + DB336F34278D77730031E64B /* Persistence+PollOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB336F33278D77730031E64B /* Persistence+PollOption.swift */; }; + DB336F36278D77A40031E64B /* PollOption+Property.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB336F35278D77A40031E64B /* PollOption+Property.swift */; }; + DB336F38278D7AAF0031E64B /* Poll+Property.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB336F37278D7AAF0031E64B /* Poll+Property.swift */; }; + DB336F3D278D80040031E64B /* FeedFetchedResultsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB336F3C278D80040031E64B /* FeedFetchedResultsController.swift */; }; + DB336F3F278E668C0031E64B /* StatusTableViewCell+ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB336F3E278E668C0031E64B /* StatusTableViewCell+ViewModel.swift */; }; + DB336F41278E68480031E64B /* StatusView+Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB336F40278E68480031E64B /* StatusView+Configuration.swift */; }; + DB336F43278EB1690031E64B /* MediaView+Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB336F42278EB1680031E64B /* MediaView+Configuration.swift */; }; DB35FC1F2612F1D9006193C9 /* ProfileRelationshipActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC1E2612F1D9006193C9 /* ProfileRelationshipActionButton.swift */; }; - DB35FC252612FD7A006193C9 /* ProfileFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC242612FD7A006193C9 /* ProfileFieldView.swift */; }; DB36679D268AB91B0027D07F /* ComposeStatusAttachmentTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB36679C268AB91B0027D07F /* ComposeStatusAttachmentTableViewCell.swift */; }; DB36679F268ABAF20027D07F /* ComposeStatusAttachmentSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB36679E268ABAF20027D07F /* ComposeStatusAttachmentSection.swift */; }; DB3667A1268ABB2E0027D07F /* ComposeStatusAttachmentItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB3667A0268ABB2E0027D07F /* ComposeStatusAttachmentItem.swift */; }; @@ -234,7 +223,6 @@ DB3667A6268AE2620027D07F /* ComposeStatusPollSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB3667A5268AE2620027D07F /* ComposeStatusPollSection.swift */; }; DB3667A8268AE2900027D07F /* ComposeStatusPollItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB3667A7268AE2900027D07F /* ComposeStatusPollItem.swift */; }; DB3D0FF325BAA61700EAA174 /* AlamofireImage in Frameworks */ = {isa = PBXBuildFile; productRef = DB3D0FF225BAA61700EAA174 /* AlamofireImage */; }; - DB3D100D25BAA75E00EAA174 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = DB3D100F25BAA75E00EAA174 /* Localizable.strings */; }; DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DD525BAA00100D1B89D /* AppDelegate.swift */; }; DB427DD825BAA00100D1B89D /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DD725BAA00100D1B89D /* SceneDelegate.swift */; }; DB427DDD25BAA00100D1B89D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = DB427DDB25BAA00100D1B89D /* Main.storyboard */; }; @@ -242,45 +230,36 @@ DB427DE225BAA00100D1B89D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = DB427DE025BAA00100D1B89D /* LaunchScreen.storyboard */; }; DB427DED25BAA00100D1B89D /* MastodonTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DEC25BAA00100D1B89D /* MastodonTests.swift */; }; DB427DF825BAA00100D1B89D /* MastodonUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DF725BAA00100D1B89D /* MastodonUITests.swift */; }; - DB44384F25E8C1FA008912A2 /* CALayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB44384E25E8C1FA008912A2 /* CALayer.swift */; }; DB443CD42694627B00159B29 /* AppearanceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB443CD32694627B00159B29 /* AppearanceView.swift */; }; DB44767B260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB44767A260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift */; }; DB447681260B3ED600B66B82 /* CustomEmojiPickerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB447680260B3ED600B66B82 /* CustomEmojiPickerSection.swift */; }; DB44768B260B3F2100B66B82 /* CustomEmojiPickerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB44768A260B3F2100B66B82 /* CustomEmojiPickerItem.swift */; }; DB447691260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB447690260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift */; }; DB447697260B439000B66B82 /* CustomEmojiPickerHeaderCollectionReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB447696260B439000B66B82 /* CustomEmojiPickerHeaderCollectionReusableView.swift */; }; - DB4481AD25EE155900BEFB67 /* Poll.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4481AC25EE155900BEFB67 /* Poll.swift */; }; - DB4481B325EE16D000BEFB67 /* PollOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4481B225EE16D000BEFB67 /* PollOption.swift */; }; DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4481B825EE289600BEFB67 /* UITableView.swift */; }; - DB4481C625EE2ADA00BEFB67 /* PollSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4481C525EE2ADA00BEFB67 /* PollSection.swift */; }; - DB4481CC25EE2AFE00BEFB67 /* PollItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4481CB25EE2AFE00BEFB67 /* PollItem.swift */; }; DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FAB525CA5485005A8AC7 /* UIAlertController.swift */; }; DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FAD625CA6C76005A8AC7 /* UIBarButtonItem.swift */; }; - DB45FADD25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FADC25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift */; }; DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FAE225CA7181005A8AC7 /* MastodonUser.swift */; }; - DB45FAED25CA7A9A005A8AC7 /* MastodonAuthentication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FAEC25CA7A9A005A8AC7 /* MastodonAuthentication.swift */; }; DB45FAF925CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */; }; DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */; }; DB45FB1D25CA9D23005A8AC7 /* APIService+HomeTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */; }; DB47229725F9EFAD00DA7F53 /* NSManagedObjectContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB47229625F9EFAD00DA7F53 /* NSManagedObjectContext.swift */; }; + DB47AB6227CF752B00CD73C7 /* MastodonUISnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB47AB6127CF752B00CD73C7 /* MastodonUISnapshotTests.swift */; }; DB482A3F261331E8008AE74C /* UserTimelineViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB482A3E261331E8008AE74C /* UserTimelineViewModel+State.swift */; }; - 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 */; }; DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A62A25FF36C700B98345 /* APIService+CustomEmoji.swift */; }; - DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A63C25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift */; }; + DB4AA6B327BA34B6009EC082 /* CellFrameCacheContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4AA6B227BA34B6009EC082 /* CellFrameCacheContainer.swift */; }; DB4F0963269ED06300D62E92 /* SearchResultViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4F0962269ED06300D62E92 /* SearchResultViewController.swift */; }; DB4F0966269ED52200D62E92 /* SearchResultViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4F0965269ED52200D62E92 /* SearchResultViewModel.swift */; }; DB4F0968269ED8AD00D62E92 /* SearchHistoryTableHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4F0967269ED8AD00D62E92 /* SearchHistoryTableHeaderView.swift */; }; DB4F096A269EDAD200D62E92 /* SearchResultViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4F0969269EDAD200D62E92 /* SearchResultViewModel+State.swift */; }; - DB4F096C269EFA2000D62E92 /* SearchResultViewController+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4F096B269EFA2000D62E92 /* SearchResultViewController+StatusProvider.swift */; }; DB4F097526A037F500D62E92 /* SearchHistoryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4F097426A037F500D62E92 /* SearchHistoryViewModel.swift */; }; DB4F097B26A039FF00D62E92 /* SearchHistorySection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4F097A26A039FF00D62E92 /* SearchHistorySection.swift */; }; DB4F097D26A03A5B00D62E92 /* SearchHistoryItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4F097C26A03A5B00D62E92 /* SearchHistoryItem.swift */; }; @@ -290,17 +269,14 @@ DB51D172262832380062B7A1 /* BlurHashDecode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB51D170262832380062B7A1 /* BlurHashDecode.swift */; }; DB51D173262832380062B7A1 /* BlurHashEncode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB51D171262832380062B7A1 /* BlurHashEncode.swift */; }; DB552D4F26BBD10C00E481F6 /* OrderedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = DB552D4E26BBD10C00E481F6 /* OrderedCollections */; }; - DB564BD0269F2F83001E39A7 /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = DB564BCE269F2F83001E39A7 /* Localizable.stringsdict */; }; DB564BD3269F3B35001E39A7 /* StatusFilterService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB564BD2269F3B35001E39A7 /* StatusFilterService.swift */; }; - DB59F0FE25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift */; }; - 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 */; }; + DB603111279EB38500A935FE /* DataSourceFacade+Mute.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB603110279EB38500A935FE /* DataSourceFacade+Mute.swift */; }; + DB603113279EBEBA00A935FE /* DataSourceFacade+Block.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB603112279EBEBA00A935FE /* DataSourceFacade+Block.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 */; }; @@ -314,11 +290,41 @@ DB6180F626391D580018D199 /* MediaPreviewableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6180F526391D580018D199 /* MediaPreviewableViewController.swift */; }; 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 */; }; + DB63F7452799056400455B82 /* HashtagTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB63F7442799056400455B82 /* HashtagTableViewCell.swift */; }; + DB63F74727990B0600455B82 /* DataSourceFacade+Hashtag.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB63F74627990B0600455B82 /* DataSourceFacade+Hashtag.swift */; }; + DB63F7492799126300455B82 /* FollowerListViewController+DataSourceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB63F7482799126300455B82 /* FollowerListViewController+DataSourceProvider.swift */; }; + DB63F74B279914A000455B82 /* FollowingListViewController+DataSourceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB63F74A279914A000455B82 /* FollowingListViewController+DataSourceProvider.swift */; }; + DB63F74D27993F5B00455B82 /* SearchHistoryUserCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB63F74C27993F5B00455B82 /* SearchHistoryUserCollectionViewCell.swift */; }; + DB63F74F2799405600455B82 /* SearchHistoryViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB63F74E2799405600455B82 /* SearchHistoryViewModel+Diffable.swift */; }; + DB63F752279944AA00455B82 /* SearchHistorySectionHeaderCollectionReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB63F751279944AA00455B82 /* SearchHistorySectionHeaderCollectionReusableView.swift */; }; + DB63F7542799491600455B82 /* DataSourceFacade+SearchHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB63F7532799491600455B82 /* DataSourceFacade+SearchHistory.swift */; }; + DB63F756279949BD00455B82 /* Persistence+SearchHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB63F755279949BD00455B82 /* Persistence+SearchHistory.swift */; }; + DB63F75A279953F200455B82 /* SearchHistoryUserCollectionViewCell+ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB63F759279953F200455B82 /* SearchHistoryUserCollectionViewCell+ViewModel.swift */; }; + DB63F75C279956D000455B82 /* Persistence+Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB63F75B279956D000455B82 /* Persistence+Tag.swift */; }; + DB63F75E27995B3B00455B82 /* Tag+Property.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB63F75D27995B3B00455B82 /* Tag+Property.swift */; }; + DB63F76227996B6600455B82 /* SearchHistoryViewController+DataSourceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB63F76127996B6600455B82 /* SearchHistoryViewController+DataSourceProvider.swift */; }; + DB63F764279A5E3C00455B82 /* NotificationTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB63F763279A5E3C00455B82 /* NotificationTimelineViewController.swift */; }; + DB63F767279A5EB300455B82 /* NotificationTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB63F766279A5EB300455B82 /* NotificationTimelineViewModel.swift */; }; + DB63F769279A5EBB00455B82 /* NotificationTimelineViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB63F768279A5EBB00455B82 /* NotificationTimelineViewModel+Diffable.swift */; }; + DB63F76B279A5ED300455B82 /* NotificationTimelineViewModel+LoadOldestState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB63F76A279A5ED300455B82 /* NotificationTimelineViewModel+LoadOldestState.swift */; }; + DB63F76F279A7D1100455B82 /* NotificationTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB63F76E279A7D1100455B82 /* NotificationTableViewCell.swift */; }; + DB63F771279A858500455B82 /* Persistence+Notification.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB63F770279A858500455B82 /* Persistence+Notification.swift */; }; + DB63F773279A87DC00455B82 /* Notification+Property.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB63F772279A87DC00455B82 /* Notification+Property.swift */; }; + DB63F775279A997D00455B82 /* NotificationTableViewCell+ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB63F774279A997D00455B82 /* NotificationTableViewCell+ViewModel.swift */; }; + DB63F777279A9A2A00455B82 /* NotificationView+Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB63F776279A9A2A00455B82 /* NotificationView+Configuration.swift */; }; + DB63F779279ABF9C00455B82 /* DataSourceFacade+Reblog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB63F778279ABF9C00455B82 /* DataSourceFacade+Reblog.swift */; }; + DB63F77B279ACAE500455B82 /* DataSourceFacade+Favorite.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB63F77A279ACAE500455B82 /* DataSourceFacade+Favorite.swift */; }; DB647C5926F1EA2700F7F82C /* WizardPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB647C5826F1EA2700F7F82C /* WizardPreference.swift */; }; + DB65C63727A2AF6C008BAC2E /* ReportItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB65C63627A2AF6C008BAC2E /* ReportItem.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 */; }; + DB6746E7278ED633008A6B94 /* MastodonAuthenticationBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBC50C0278ED49200AF0CC6 /* MastodonAuthenticationBox.swift */; }; + DB6746E8278ED639008A6B94 /* MastodonAuthenticationBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBC50C0278ED49200AF0CC6 /* MastodonAuthenticationBox.swift */; }; + DB6746E9278ED63F008A6B94 /* MastodonAuthenticationBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBC50C0278ED49200AF0CC6 /* MastodonAuthenticationBox.swift */; }; + DB6746EB278ED8B0008A6B94 /* PollOptionView+Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6746EA278ED8B0008A6B94 /* PollOptionView+Configuration.swift */; }; + DB6746ED278F45F0008A6B94 /* AutoGenerateProtocolRelayDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6746EC278F45F0008A6B94 /* AutoGenerateProtocolRelayDelegate.swift */; }; + DB6746F0278F463B008A6B94 /* AutoGenerateProtocolDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6746EF278F463B008A6B94 /* AutoGenerateProtocolDelegate.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 */; }; @@ -328,28 +334,31 @@ DB6804832637CD4C00430867 /* AppShared.h in Headers */ = {isa = PBXBuildFile; fileRef = DB6804812637CD4C00430867 /* AppShared.h */; settings = {ATTRIBUTES = (Public, ); }; }; DB6804862637CD4C00430867 /* AppShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB68047F2637CD4C00430867 /* AppShared.framework */; }; DB6804872637CD4C00430867 /* AppShared.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = DB68047F2637CD4C00430867 /* AppShared.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - DB6804922637CD8700430867 /* AppName.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6804912637CD8700430867 /* AppName.swift */; }; - DB6804A52637CDCC00430867 /* AppShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB68047F2637CD4C00430867 /* AppShared.framework */; }; DB6804D12637CE4700430867 /* UserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6804D02637CE4700430867 /* UserDefaults.swift */; }; DB6804FD2637CFEC00430867 /* AppSecret.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6804FC2637CFEC00430867 /* AppSecret.swift */; }; DB6805102637D0F800430867 /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = DB68050F2637D0F800430867 /* KeychainAccess */; }; - DB6805262637D7DD00430867 /* AppShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB68047F2637CD4C00430867 /* AppShared.framework */; }; DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */; }; DB68A04A25E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68A04925E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift */; }; DB68A05D25E9055900CFDF14 /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = DB68A05C25E9055900CFDF14 /* Settings.bundle */; }; DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68A06225E905E000CFDF14 /* UIApplication.swift */; }; + DB697DD1278F4871004EF2F7 /* AutoGenerateTableViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB697DD0278F4871004EF2F7 /* AutoGenerateTableViewDelegate.swift */; }; + DB697DD4278F4927004EF2F7 /* StatusTableViewCellDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB697DD3278F4927004EF2F7 /* StatusTableViewCellDelegate.swift */; }; + DB697DD6278F4C29004EF2F7 /* DataSourceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB697DD5278F4C29004EF2F7 /* DataSourceProvider.swift */; }; + DB697DD9278F4CED004EF2F7 /* HomeTimelineViewController+DataSourceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB697DD8278F4CED004EF2F7 /* HomeTimelineViewController+DataSourceProvider.swift */; }; + DB697DDB278F4DE3004EF2F7 /* DataSourceProvider+StatusTableViewCellDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB697DDA278F4DE3004EF2F7 /* DataSourceProvider+StatusTableViewCellDelegate.swift */; }; + DB697DDD278F521D004EF2F7 /* DataSourceFacade.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB697DDC278F521D004EF2F7 /* DataSourceFacade.swift */; }; + DB697DDF278F524F004EF2F7 /* DataSourceFacade+Profile.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB697DDE278F524F004EF2F7 /* DataSourceFacade+Profile.swift */; }; + DB697DE1278F5296004EF2F7 /* DataSourceFacade+Model.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB697DE0278F5296004EF2F7 /* DataSourceFacade+Model.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 */; }; @@ -369,18 +378,12 @@ 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 */; }; - DB71FD4625F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD4525F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift */; }; - DB71FD4C25F8C80E00512AE1 /* StatusPrefetchingService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD4B25F8C80E00512AE1 /* StatusPrefetchingService.swift */; }; 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 */; }; @@ -391,27 +394,14 @@ 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 */; }; + DB8481152788121200BBEABA /* MastodonRegisterTextFieldTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8481142788121200BBEABA /* MastodonRegisterTextFieldTableViewCell.swift */; }; + DB84811727883C2600BBEABA /* MastodonRegisterPasswordHintTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB84811627883C2600BBEABA /* MastodonRegisterPasswordHintTableViewCell.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 */; }; - DB87D4572609DD5300D12C0D /* DeleteBackwardResponseTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB87D4562609DD5300D12C0D /* DeleteBackwardResponseTextField.swift */; }; - DB89B9F725C10FD0008580ED /* CoreDataStack.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */; }; - DB89B9FE25C10FD0008580ED /* CoreDataStackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89B9FD25C10FD0008580ED /* CoreDataStackTests.swift */; }; - DB89BA0025C10FD0008580ED /* CoreDataStack.h in Headers */ = {isa = PBXBuildFile; fileRef = DB89B9F025C10FD0008580ED /* CoreDataStack.h */; settings = {ATTRIBUTES = (Public, ); }; }; - DB89BA0325C10FD0008580ED /* CoreDataStack.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */; }; - DB89BA0425C10FD0008580ED /* CoreDataStack.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - DB89BA1225C1105C008580ED /* CoreDataStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89BA1125C1105C008580ED /* CoreDataStack.swift */; }; - DB89BA1B25C1107F008580ED /* Collection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89BA1825C1107F008580ED /* Collection.swift */; }; - DB89BA1C25C1107F008580ED /* NSManagedObjectContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89BA1925C1107F008580ED /* NSManagedObjectContext.swift */; }; - DB89BA1D25C1107F008580ED /* URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89BA1A25C1107F008580ED /* URL.swift */; }; - DB89BA2725C110B4008580ED /* Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89BA2625C110B4008580ED /* Status.swift */; }; - DB89BA3725C1145C008580ED /* CoreData.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = DB89BA3525C1145C008580ED /* CoreData.xcdatamodeld */; }; - DB89BA4325C1165F008580ED /* NetworkUpdatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89BA4125C1165F008580ED /* NetworkUpdatable.swift */; }; - DB89BA4425C1165F008580ED /* Managed.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89BA4225C1165F008580ED /* Managed.swift */; }; - DB8AF52525C131D1002E6C99 /* MastodonUser.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF52425C131D1002E6C99 /* MastodonUser.swift */; }; + DB894CC427A5490600684B74 /* BlurhashImageCacheService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB894CC327A5490600684B74 /* BlurhashImageCacheService.swift */; }; DB8AF52E25C13561002E6C99 /* ViewStateStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF52B25C13561002E6C99 /* ViewStateStore.swift */; }; DB8AF52F25C13561002E6C99 /* DocumentStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF52C25C13561002E6C99 /* DocumentStore.swift */; }; DB8AF53025C13561002E6C99 /* AppContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF52D25C13561002E6C99 /* AppContext.swift */; }; @@ -419,14 +409,11 @@ DB8AF54525C13647002E6C99 /* NeedsDependency.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF54325C13647002E6C99 /* NeedsDependency.swift */; }; DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF54F25C13703002E6C99 /* MainTabBarController.swift */; }; DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF55C25C138B7002E6C99 /* UIViewController.swift */; }; - DB8AF56825C13E2A002E6C99 /* HomeTimelineIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */; }; + DB8F7076279E954700E1225B /* DataSourceFacade+Follow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8F7075279E954700E1225B /* DataSourceFacade+Follow.swift */; }; DB8FABC726AEC7B2008E5AF4 /* Intents.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB8FAB9E26AEC3A2008E5AF4 /* Intents.framework */; }; DB8FABCA26AEC7B2008E5AF4 /* IntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8FABC926AEC7B2008E5AF4 /* IntentHandler.swift */; }; DB8FABCE26AEC7B2008E5AF4 /* MastodonIntent.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = DB8FABC626AEC7B2008E5AF4 /* MastodonIntent.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - DB8FABD726AEC873008E5AF4 /* AppShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB68047F2637CD4C00430867 /* AppShared.framework */; }; - DB8FABDC26AEC87B008E5AF4 /* CoreDataStack.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */; }; DB9282B225F3222800823B15 /* PickServerEmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9282B125F3222800823B15 /* PickServerEmptyStateView.swift */; }; - DB92CF7225E7BB98002C1017 /* PollOptionTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB92CF7125E7BB98002C1017 /* PollOptionTableViewCell.swift */; }; DB938EE62623F50700E5B6C1 /* ThreadViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938EE52623F50700E5B6C1 /* ThreadViewController.swift */; }; DB938EED2623F79B00E5B6C1 /* ThreadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938EEC2623F79B00E5B6C1 /* ThreadViewModel.swift */; }; DB938F0326240EA300E5B6C1 /* CachedThreadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938F0226240EA300E5B6C1 /* CachedThreadViewModel.swift */; }; @@ -434,15 +421,25 @@ DB938F0F2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938F0E2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift */; }; DB938F1526241FDF00E5B6C1 /* APIService+Thread.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938F1426241FDF00E5B6C1 /* APIService+Thread.swift */; }; DB938F1F2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938F1E2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift */; }; - DB938F25262438D600E5B6C1 /* ThreadViewController+Provider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938F24262438D600E5B6C1 /* ThreadViewController+Provider.swift */; }; DB938F3326243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938F3226243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift */; }; - DB97131F2666078B00BD1E90 /* Date.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB97131E2666078B00BD1E90 /* Date.swift */; }; DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98336A25C9420100AD9700 /* APIService+App.swift */; }; DB98337125C9443200AD9700 /* APIService+Authentication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98337025C9443200AD9700 /* APIService+Authentication.swift */; }; DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98337E25C9452D00AD9700 /* APIService+APIError.swift */; }; - DB98338725C945ED00AD9700 /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98338525C945ED00AD9700 /* Strings.swift */; }; - DB98338825C945ED00AD9700 /* Assets.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98338625C945ED00AD9700 /* Assets.swift */; }; DB98339C25C96DE600AD9700 /* APIService+Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98339B25C96DE600AD9700 /* APIService+Account.swift */; }; + DB98EB4727B0DFAA0082E365 /* ReportViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98EB4627B0DFAA0082E365 /* ReportViewModel+State.swift */; }; + DB98EB4927B0F0CD0082E365 /* ReportStatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98EB4827B0F0CD0082E365 /* ReportStatusTableViewCell.swift */; }; + DB98EB4C27B0F2BC0082E365 /* ReportStatusTableViewCell+ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98EB4B27B0F2BC0082E365 /* ReportStatusTableViewCell+ViewModel.swift */; }; + DB98EB5327B0F9890082E365 /* ReportHeadlineTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98EB5227B0F9890082E365 /* ReportHeadlineTableViewCell.swift */; }; + DB98EB5627B0FF1B0082E365 /* ReportViewControllerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98EB5527B0FF1B0082E365 /* ReportViewControllerAppearance.swift */; }; + DB98EB5927B109890082E365 /* ReportSupplementaryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98EB5827B109890082E365 /* ReportSupplementaryViewController.swift */; }; + DB98EB5C27B10A730082E365 /* ReportSupplementaryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98EB5B27B10A730082E365 /* ReportSupplementaryViewModel.swift */; }; + DB98EB5E27B10A7A0082E365 /* ReportSupplementaryViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98EB5D27B10A7A0082E365 /* ReportSupplementaryViewModel+Diffable.swift */; }; + DB98EB6027B10E150082E365 /* ReportCommentTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98EB5F27B10E150082E365 /* ReportCommentTableViewCell.swift */; }; + DB98EB6227B215EB0082E365 /* ReportResultViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98EB6127B215EB0082E365 /* ReportResultViewController.swift */; }; + DB98EB6527B216500082E365 /* ReportResultViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98EB6427B216500082E365 /* ReportResultViewModel.swift */; }; + DB98EB6727B216560082E365 /* ReportResultViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98EB6627B216560082E365 /* ReportResultViewModel+Diffable.swift */; }; + DB98EB6927B21A7C0082E365 /* ReportResultActionTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98EB6827B21A7C0082E365 /* ReportResultActionTableViewCell.swift */; }; + DB98EB6B27B243470082E365 /* SettingsAppearanceTableViewCell+ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98EB6A27B243470082E365 /* SettingsAppearanceTableViewCell+ViewModel.swift */; }; DB9A486C26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A486B26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift */; }; DB9A487E2603456B008B817C /* UITextView+Placeholder in Frameworks */ = {isa = PBXBuildFile; productRef = DB9A487D2603456B008B817C /* UITextView+Placeholder */; }; DB9A488A26034D40008B817C /* ComposeViewModel+PublishState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A488926034D40008B817C /* ComposeViewModel+PublishState.swift */; }; @@ -451,10 +448,6 @@ DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6BE825E4F5340051B173 /* SearchViewController.swift */; }; DB9D6BF825E4F5690051B173 /* NotificationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6BF725E4F5690051B173 /* NotificationViewController.swift */; }; DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */; }; - DB9D6C0E25E4F9780051B173 /* MosaicImageViewContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C0D25E4F9780051B173 /* MosaicImageViewContainer.swift */; }; - DB9D6C2425E502C60051B173 /* MosaicImageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C2225E502C60051B173 /* MosaicImageViewModel.swift */; }; - DB9D6C2E25E504AC0051B173 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C2D25E504AC0051B173 /* Attachment.swift */; }; - 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 */; }; @@ -462,7 +455,6 @@ 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 */; }; DBA465932696B495002B41DB /* APIService+WebFinger.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA465922696B495002B41DB /* APIService+WebFinger.swift */; }; DBA465952696E387002B41DB /* AppPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA465942696E387002B41DB /* AppPreference.swift */; }; DBA4B0F626C269880077136E /* Intents.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = DBA4B0F926C269880077136E /* Intents.stringsdict */; }; @@ -476,31 +468,23 @@ DBA5E7AB263BD3F5004598BB /* TimelineTableViewCellContextMenuConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA5E7AA263BD3F5004598BB /* TimelineTableViewCellContextMenuConfiguration.swift */; }; DBA94434265CBB5300C537E1 /* ProfileFieldSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA94433265CBB5300C537E1 /* ProfileFieldSection.swift */; }; DBA94436265CBB7400C537E1 /* ProfileFieldItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA94435265CBB7400C537E1 /* ProfileFieldItem.swift */; }; - DBA94438265CBD4D00C537E1 /* ProfileHeaderViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA94437265CBD4D00C537E1 /* ProfileHeaderViewModel+Diffable.swift */; }; - DBA9443A265CC0FC00C537E1 /* Fields.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA94439265CC0FC00C537E1 /* Fields.swift */; }; DBA9443E265CFA6400C537E1 /* ProfileFieldCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA9443D265CFA6400C537E1 /* ProfileFieldCollectionViewCell.swift */; }; DBA94440265D137600C537E1 /* Mastodon+Entity+Field.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA9443F265D137600C537E1 /* Mastodon+Entity+Field.swift */; }; DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */; }; 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 */; }; - 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 */; }; - DBAC649B267DF8C8007FE9FD /* ActivityIndicatorNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAC649A267DF8C8007FE9FD /* ActivityIndicatorNode.swift */; }; DBAC649E267DFE43007FE9FD /* DiffableDataSources in Frameworks */ = {isa = PBXBuildFile; productRef = DBAC649D267DFE43007FE9FD /* DiffableDataSources */; }; DBAC64A1267E6D02007FE9FD /* Fuzi in Frameworks */ = {isa = PBXBuildFile; productRef = DBAC64A0267E6D02007FE9FD /* Fuzi */; }; - DBAE3F682615DD60004B8251 /* UserProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F672615DD60004B8251 /* UserProvider.swift */; }; - DBAE3F822615DDA3004B8251 /* ProfileViewController+UserProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F812615DDA3004B8251 /* ProfileViewController+UserProvider.swift */; }; - DBAE3F882615DDF4004B8251 /* UserProviderFacade.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F872615DDF4004B8251 /* UserProviderFacade.swift */; }; DBAE3F8E2616E0B1004B8251 /* APIService+Block.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F8D2616E0B1004B8251 /* APIService+Block.swift */; }; DBAE3F942616E28B004B8251 /* APIService+Follow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F932616E28B004B8251 /* APIService+Follow.swift */; }; DBAE3F9E2616E308004B8251 /* APIService+Mute.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F9D2616E308004B8251 /* APIService+Mute.swift */; }; DBAE3FAF26172FC0004B8251 /* RemoteProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3FAE26172FC0004B8251 /* RemoteProfileViewModel.swift */; }; - DBAEDE5C267A058D00D25FF5 /* BlurhashImageCacheService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAEDE5B267A058D00D25FF5 /* BlurhashImageCacheService.swift */; }; - DBAFB7352645463500371D5F /* Emojis.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAFB7342645463500371D5F /* Emojis.swift */; }; DBB3BA2A26A81C020004F2D4 /* FLAnimatedImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB3BA2926A81C020004F2D4 /* FLAnimatedImageView.swift */; }; DBB3BA2B26A81D060004F2D4 /* FLAnimatedImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB3BA2926A81C020004F2D4 /* FLAnimatedImageView.swift */; }; + DBB45B5627B39FC9002DC5A7 /* MediaPreviewVideoViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB45B5527B39FC9002DC5A7 /* MediaPreviewVideoViewController.swift */; }; + DBB45B5927B39FE4002DC5A7 /* MediaPreviewVideoViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB45B5827B39FE4002DC5A7 /* MediaPreviewVideoViewModel.swift */; }; + DBB45B5B27B3A109002DC5A7 /* MediaPreviewTransitionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB45B5A27B3A109002DC5A7 /* MediaPreviewTransitionViewController.swift */; }; + DBB45B6027B50A4F002DC5A7 /* RecommendAccountItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB45B5F27B50A4F002DC5A7 /* RecommendAccountItem.swift */; }; + DBB45B6227B51112002DC5A7 /* SuggestionAccountViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB45B6127B51112002DC5A7 /* SuggestionAccountViewModel+Diffable.swift */; }; DBB525082611EAC0002F1F29 /* Tabman in Frameworks */ = {isa = PBXBuildFile; productRef = DBB525072611EAC0002F1F29 /* Tabman */; }; DBB5250E2611EBAF002F1F29 /* ProfileSegmentedViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */; }; DBB525212611EBD6002F1F29 /* ProfilePagingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525202611EBD6002F1F29 /* ProfilePagingViewController.swift */; }; @@ -517,19 +501,12 @@ DBB8AB4826AED09C00F6D281 /* MastodonSDK in Frameworks */ = {isa = PBXBuildFile; productRef = DBB8AB4726AED09C00F6D281 /* MastodonSDK */; }; DBB8AB4A26AED0B500F6D281 /* APIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB8AB4926AED0B500F6D281 /* APIService.swift */; }; DBB8AB4C26AED11300F6D281 /* APIService+APIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98337E25C9452D00AD9700 /* APIService+APIError.swift */; }; - DBB8AB4D26AED12B00F6D281 /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98338525C945ED00AD9700 /* Strings.swift */; }; - DBB8AB4E26AED12E00F6D281 /* Assets.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98338625C945ED00AD9700 /* Assets.swift */; }; DBB8AB4F26AED13F00F6D281 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DB427DDE25BAA00100D1B89D /* Assets.xcassets */; }; - DBB8AB5026AED14400F6D281 /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = DB564BCE269F2F83001E39A7 /* Localizable.stringsdict */; }; - DBB8AB5126AED14600F6D281 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = DB3D100F25BAA75E00EAA174 /* Localizable.strings */; }; DBB8AB5226AED1B300F6D281 /* APIService+Status+Publish.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBFEF07A26A6BCE8006D7ED1 /* APIService+Status+Publish.swift */; }; - DBB8AB5326AED25100F6D281 /* MastodonAuthenticationBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBFEF07626A691FB006D7ED1 /* MastodonAuthenticationBox.swift */; }; DBB9759C262462E1004620BD /* ThreadMetaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB9759B262462E1004620BD /* ThreadMetaView.swift */; }; DBBC24A826A52F9000398BB9 /* ComposeToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBC24A726A52F9000398BB9 /* ComposeToolbarView.swift */; }; DBBC24AA26A5301B00398BB9 /* MastodonSDK in Frameworks */ = {isa = PBXBuildFile; productRef = DBBC24A926A5301B00398BB9 /* MastodonSDK */; }; DBBC24AC26A53D9300398BB9 /* ComposeStatusContentTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBC24AB26A53D9300398BB9 /* ComposeStatusContentTableViewCell.swift */; }; - DBBC24AE26A53DC100398BB9 /* ReplicaStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBC24AD26A53DC100398BB9 /* ReplicaStatusView.swift */; }; - DBBC24B526A540AE00398BB9 /* AvatarConfigurableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBC24B426A540AE00398BB9 /* AvatarConfigurableView.swift */; }; DBBC24B826A5421800398BB9 /* CommonOSLog in Frameworks */ = {isa = PBXBuildFile; productRef = DBBC24B726A5421800398BB9 /* CommonOSLog */; }; DBBC24BC26A542F500398BB9 /* ThemeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBC24BB26A542F500398BB9 /* ThemeService.swift */; }; DBBC24C026A5443100398BB9 /* MastodonTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBC24BE26A5443100398BB9 /* MastodonTheme.swift */; }; @@ -540,10 +517,8 @@ DBBC24C826A5456400398BB9 /* ThemeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBC24BB26A542F500398BB9 /* ThemeService.swift */; }; DBBC24C926A5456400398BB9 /* MastodonTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBC24BE26A5443100398BB9 /* MastodonTheme.swift */; }; DBBC24CB26A546C000398BB9 /* ThemePreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD376AB2692ECDB007FEC24 /* ThemePreference.swift */; }; - DBBC24CD26A5471E00398BB9 /* MastodonExtension in Frameworks */ = {isa = PBXBuildFile; productRef = DBBC24CC26A5471E00398BB9 /* MastodonExtension */; }; DBBC24CF26A547AE00398BB9 /* ThemeService+Appearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBC24CE26A547AE00398BB9 /* ThemeService+Appearance.swift */; }; DBBC24D126A5484F00398BB9 /* UITextView+Placeholder in Frameworks */ = {isa = PBXBuildFile; productRef = DBBC24D026A5484F00398BB9 /* UITextView+Placeholder */; }; - DBBC24D226A5488600398BB9 /* AvatarConfigurableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBC24B426A540AE00398BB9 /* AvatarConfigurableView.swift */; }; DBBC24DC26A54BCB00398BB9 /* MastodonRegex.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBC24D626A54BCB00398BB9 /* MastodonRegex.swift */; }; DBBC24DE26A54BCB00398BB9 /* MastodonMetricFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBC24D826A54BCB00398BB9 /* MastodonMetricFormatter.swift */; }; DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */; }; @@ -557,26 +532,10 @@ DBC6461826A170AB00B0E31B /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = DBC6461626A170AB00B0E31B /* MainInterface.storyboard */; }; DBC6461C26A170AB00B0E31B /* ShareActionExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = DBC6461226A170AB00B0E31B /* ShareActionExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; DBC6462326A1712000B0E31B /* ShareViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBC6462226A1712000B0E31B /* ShareViewModel.swift */; }; - DBC6462526A1720B00B0E31B /* MastodonUI in Frameworks */ = {isa = PBXBuildFile; productRef = DBC6462426A1720B00B0E31B /* MastodonUI */; }; - DBC6462626A1736000B0E31B /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = DB564BCE269F2F83001E39A7 /* Localizable.stringsdict */; }; - DBC6462726A1736000B0E31B /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = DB3D100F25BAA75E00EAA174 /* Localizable.strings */; }; DBC6462826A1736300B0E31B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DB427DDE25BAA00100D1B89D /* Assets.xcassets */; }; - DBC6462926A1736700B0E31B /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98338525C945ED00AD9700 /* Strings.swift */; }; - DBC6462B26A1738900B0E31B /* MastodonUI in Frameworks */ = {isa = PBXBuildFile; productRef = DBC6462A26A1738900B0E31B /* MastodonUI */; }; - DBC6462C26A176B000B0E31B /* Assets.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98338625C945ED00AD9700 /* Assets.swift */; }; - DBC6463326A195DB00B0E31B /* AppShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB68047F2637CD4C00430867 /* AppShared.framework */; }; - DBC6463726A195DB00B0E31B /* CoreDataStack.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */; }; DBC7A672260C897100E57475 /* StatusContentWarningEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBC7A671260C897100E57475 /* StatusContentWarningEditorView.swift */; }; DBC7A67C260DFADE00E57475 /* StatusPublishService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBC7A67B260DFADE00E57475 /* StatusPublishService.swift */; }; DBCBCBF4267CB070000F5B51 /* Decode85.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCBCBF3267CB070000F5B51 /* Decode85.swift */; }; - DBCBCBFC2680ADB7000F5B51 /* AsyncHomeTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCBCBFB2680ADB7000F5B51 /* AsyncHomeTimelineViewController.swift */; }; - DBCBCBFF2680AE98000F5B51 /* AsyncHomeTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCBCBFE2680AE98000F5B51 /* AsyncHomeTimelineViewModel.swift */; }; - DBCBCC012680AF2A000F5B51 /* AsyncHomeTimelineViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCBCC002680AF2A000F5B51 /* AsyncHomeTimelineViewModel+Diffable.swift */; }; - DBCBCC032680AF6E000F5B51 /* AsyncHomeTimelineViewController+DebugAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCBCC022680AF6E000F5B51 /* AsyncHomeTimelineViewController+DebugAction.swift */; }; - DBCBCC052680AFB9000F5B51 /* AsyncHomeTimelineViewController+Provider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCBCC042680AFB9000F5B51 /* AsyncHomeTimelineViewController+Provider.swift */; }; - DBCBCC072680AFEC000F5B51 /* AsyncHomeTimelineViewModel+LoadLatestState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCBCC062680AFEC000F5B51 /* AsyncHomeTimelineViewModel+LoadLatestState.swift */; }; - DBCBCC092680B01B000F5B51 /* AsyncHomeTimelineViewModel+LoadMiddleState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCBCC082680B01B000F5B51 /* AsyncHomeTimelineViewModel+LoadMiddleState.swift */; }; - DBCBCC0B2680B03F000F5B51 /* AsyncHomeTimelineViewModel+LoadOldestState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCBCC0A2680B03F000F5B51 /* AsyncHomeTimelineViewModel+LoadOldestState.swift */; }; DBCBCC0D2680B908000F5B51 /* HomeTimelinePreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCBCC0C2680B908000F5B51 /* HomeTimelinePreference.swift */; }; DBCBED1726132DB500B49291 /* UserTimelineViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCBED1626132DB500B49291 /* UserTimelineViewModel+Diffable.swift */; }; DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCBED1C26132E1A00B49291 /* StatusFetchedResultsController.swift */; }; @@ -584,21 +543,24 @@ DBCC3B36261440BA0045B23D /* UINavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B35261440BA0045B23D /* UINavigationController.swift */; }; DBCC3B8F26148F7B0045B23D /* CachedProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B8E26148F7B0045B23D /* CachedProfileViewModel.swift */; }; DBCC3B9526157E6E0045B23D /* APIService+Relationship.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B9426157E6E0045B23D /* APIService+Relationship.swift */; }; - DBCC3B9B261584A00045B23D /* PrivateNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B9A2615849F0045B23D /* PrivateNote.swift */; }; DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCCC71D25F73297007E1AB6 /* APIService+Reblog.swift */; }; DBD376AC2692ECDB007FEC24 /* ThemePreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD376AB2692ECDB007FEC24 /* ThemePreference.swift */; }; DBD376B2269302A4007FEC24 /* UITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD376B1269302A4007FEC24 /* UITableViewCell.swift */; }; + DBD5B1F627BCD3D200BD6B38 /* SuggestionAccountTableViewCell+ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD5B1F527BCD3D200BD6B38 /* SuggestionAccountTableViewCell+ViewModel.swift */; }; + DBD5B1F827BCFD9D00BD6B38 /* DataSourceProvider+TableViewControllerNavigateable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD5B1F727BCFD9D00BD6B38 /* DataSourceProvider+TableViewControllerNavigateable.swift */; }; + DBD5B1FA27BD013700BD6B38 /* DataSourceProvider+StatusTableViewControllerNavigateable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD5B1F927BD013700BD6B38 /* DataSourceProvider+StatusTableViewControllerNavigateable.swift */; }; DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */; }; DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */; }; DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */; }; + DBE3CA6827A39CAB00AFE27B /* AppShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB68047F2637CD4C00430867 /* AppShared.framework */; }; + DBE3CA6B27A39CAF00AFE27B /* AppShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB68047F2637CD4C00430867 /* AppShared.framework */; }; + DBE3CA6E27A39CB300AFE27B /* AppShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB68047F2637CD4C00430867 /* AppShared.framework */; }; DBE3CDBB261C427900430CC6 /* TimelineHeaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CDBA261C427900430CC6 /* TimelineHeaderTableViewCell.swift */; }; DBE3CDCF261C42ED00430CC6 /* TimelineHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CDCE261C42ED00430CC6 /* TimelineHeaderView.swift */; }; DBE3CDEC261C6B2900430CC6 /* FavoriteViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CDEB261C6B2900430CC6 /* FavoriteViewController.swift */; }; DBE3CDFB261C6CA500430CC6 /* FavoriteViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CDFA261C6CA500430CC6 /* FavoriteViewModel.swift */; }; DBE3CE01261D623D00430CC6 /* FavoriteViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CE00261D623D00430CC6 /* FavoriteViewModel+State.swift */; }; DBE3CE07261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CE06261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift */; }; - DBE3CE0D261D767100430CC6 /* FavoriteViewController+Provider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CE0C261D767100430CC6 /* FavoriteViewController+Provider.swift */; }; - 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 */; }; @@ -616,6 +578,10 @@ DBF96326262EC0A6001D8D25 /* AuthenticationServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DBF96325262EC0A6001D8D25 /* AuthenticationServices.framework */; }; DBF9814A265E24F500E4BA07 /* ProfileFieldCollectionViewHeaderFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF98149265E24F500E4BA07 /* ProfileFieldCollectionViewHeaderFooterView.swift */; }; DBF9814C265E339500E4BA07 /* ProfileFieldAddEntryCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF9814B265E339500E4BA07 /* ProfileFieldAddEntryCollectionViewCell.swift */; }; + DBFEEC96279BDC67004F81DD /* ProfileAboutViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBFEEC95279BDC67004F81DD /* ProfileAboutViewController.swift */; }; + DBFEEC99279BDCDE004F81DD /* ProfileAboutViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBFEEC98279BDCDE004F81DD /* ProfileAboutViewModel.swift */; }; + DBFEEC9B279BDDD9004F81DD /* ProfileAboutViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBFEEC9A279BDDD9004F81DD /* ProfileAboutViewModel+Diffable.swift */; }; + DBFEEC9D279C12C1004F81DD /* ProfileFieldEditCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBFEEC9C279C12C1004F81DD /* ProfileFieldEditCollectionViewCell.swift */; }; DBFEF05B26A57715006D7ED1 /* ComposeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBFEF05726A576EE006D7ED1 /* ComposeViewModel.swift */; }; DBFEF05C26A57715006D7ED1 /* StatusEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBFEF05526A576EE006D7ED1 /* StatusEditorView.swift */; }; DBFEF05D26A57715006D7ED1 /* ContentWarningEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBFEF05826A576EE006D7ED1 /* ContentWarningEditorView.swift */; }; @@ -628,8 +594,6 @@ DBFEF06F26A690C4006D7ED1 /* APIService+APIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98337E25C9452D00AD9700 /* APIService+APIError.swift */; }; DBFEF07326A6913D006D7ED1 /* APIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBFEF07226A6913D006D7ED1 /* APIService.swift */; }; DBFEF07526A69192006D7ED1 /* APIService+Media.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A488F26035963008B817C /* APIService+Media.swift */; }; - DBFEF07726A691FB006D7ED1 /* MastodonAuthenticationBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBFEF07626A691FB006D7ED1 /* MastodonAuthenticationBox.swift */; }; - DBFEF07826A69209006D7ED1 /* MastodonAuthenticationBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBFEF07626A691FB006D7ED1 /* MastodonAuthenticationBox.swift */; }; DBFEF07B26A6BCE8006D7ED1 /* APIService+Status+Publish.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBFEF07A26A6BCE8006D7ED1 /* APIService+Status+Publish.swift */; }; DBFEF07C26A6BD0A006D7ED1 /* APIService+Status+Publish.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBFEF07A26A6BCE8006D7ED1 /* APIService+Status+Publish.swift */; }; EE93E8E8F9E0C39EAAEBD92F /* Pods_AppShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F4A2A2D7000E477CA459ADA9 /* Pods_AppShared.framework */; }; @@ -671,34 +635,6 @@ remoteGlobalIDString = DB68047E2637CD4C00430867; remoteInfo = AppShared; }; - DB6805282637D7DD00430867 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = DB427DCA25BAA00100D1B89D /* Project object */; - proxyType = 1; - remoteGlobalIDString = DB68047E2637CD4C00430867; - remoteInfo = AppShared; - }; - DB89B9F825C10FD0008580ED /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = DB427DCA25BAA00100D1B89D /* Project object */; - proxyType = 1; - remoteGlobalIDString = DB89B9ED25C10FD0008580ED; - remoteInfo = CoreDataStack; - }; - DB89B9FA25C10FD0008580ED /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = DB427DCA25BAA00100D1B89D /* Project object */; - proxyType = 1; - remoteGlobalIDString = DB427DD125BAA00100D1B89D; - remoteInfo = Mastodon; - }; - DB89BA0125C10FD0008580ED /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = DB427DCA25BAA00100D1B89D /* Project object */; - proxyType = 1; - remoteGlobalIDString = DB89B9ED25C10FD0008580ED; - remoteInfo = CoreDataStack; - }; DB8FABCC26AEC7B2008E5AF4 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = DB427DCA25BAA00100D1B89D /* Project object */; @@ -713,13 +649,6 @@ remoteGlobalIDString = DB68047E2637CD4C00430867; remoteInfo = AppShared; }; - DB8FABDE26AEC87B008E5AF4 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = DB427DCA25BAA00100D1B89D /* Project object */; - proxyType = 1; - remoteGlobalIDString = DB89B9ED25C10FD0008580ED; - remoteInfo = CoreDataStack; - }; DBC6461A26A170AB00B0E31B /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = DB427DCA25BAA00100D1B89D /* Project object */; @@ -734,13 +663,6 @@ remoteGlobalIDString = DB68047E2637CD4C00430867; remoteInfo = AppShared; }; - DBC6463926A195DB00B0E31B /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = DB427DCA25BAA00100D1B89D /* Project object */; - proxyType = 1; - remoteGlobalIDString = DB89B9ED25C10FD0008580ED; - remoteInfo = CoreDataStack; - }; DBF8AE18263293E400C9C23C /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = DB427DCA25BAA00100D1B89D /* Project object */; @@ -758,7 +680,6 @@ dstSubfolderSpec = 10; files = ( DB6804872637CD4C00430867 /* AppShared.framework in Embed Frameworks */, - DB89BA0425C10FD0008580ED /* CoreDataStack.framework in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; @@ -779,69 +700,46 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 0655B257371274BEB7EB1C19 /* Pods-Mastodon.release snapshot.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.release snapshot.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.release snapshot.xcconfig"; sourceTree = "<group>"; }; + 0827D1674B2523503E8605F6 /* Pods-Mastodon-MastodonUITests.release snapshot.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.release snapshot.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.release snapshot.xcconfig"; sourceTree = "<group>"; }; 0F1E2D0A2615C39400C38565 /* DoubleTitleLabelNavigationBarTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DoubleTitleLabelNavigationBarTitleView.swift; sourceTree = "<group>"; }; 0F2021FA2613262F000C64BF /* HashtagTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineViewController.swift; sourceTree = "<group>"; }; 0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineViewModel.swift; sourceTree = "<group>"; }; 0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+Diffable.swift"; sourceTree = "<group>"; }; - 0F20220C26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+LoadLatestState.swift"; sourceTree = "<group>"; }; 0F202212261351F5000C64BF /* APIService+HashtagTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+HashtagTimeline.swift"; sourceTree = "<group>"; }; - 0F202226261411BA000C64BF /* HashtagTimelineViewController+Provider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewController+Provider.swift"; sourceTree = "<group>"; }; 0F20222C261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+LoadOldestState.swift"; sourceTree = "<group>"; }; - 0F20223226145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+LoadMiddleState.swift"; sourceTree = "<group>"; }; 0F20223826146553000C64BF /* Array.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Array.swift; sourceTree = "<group>"; }; 0FAA0FDE25E0B57E0017CCDE /* WelcomeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeViewController.swift; sourceTree = "<group>"; }; 0FAA101125E105390017CCDE /* PrimaryActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryActionButton.swift; sourceTree = "<group>"; }; 0FAA101B25E10E760017CCDE /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = "<group>"; }; 0FAA102625E1126A0017CCDE /* MastodonPickServerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPickServerViewController.swift; sourceTree = "<group>"; }; 0FB3D2F625E4C24D00AAD544 /* MastodonPickServerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPickServerViewModel.swift; sourceTree = "<group>"; }; - 0FB3D2FD25E4CB6400AAD544 /* PickServerTitleCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerTitleCell.swift; sourceTree = "<group>"; }; - 0FB3D30725E524C600AAD544 /* PickServerCategoriesCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerCategoriesCell.swift; sourceTree = "<group>"; }; + 0FB3D2FD25E4CB6400AAD544 /* OnboardingHeadlineTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingHeadlineTableViewCell.swift; sourceTree = "<group>"; }; 0FB3D30E25E525CD00AAD544 /* PickServerCategoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerCategoryView.swift; sourceTree = "<group>"; }; 0FB3D31D25E534C700AAD544 /* PickServerCategoryCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerCategoryCollectionViewCell.swift; sourceTree = "<group>"; }; - 0FB3D33125E5F50E00AAD544 /* PickServerSearchCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerSearchCell.swift; sourceTree = "<group>"; }; 0FB3D33725E6401400AAD544 /* PickServerCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerCell.swift; sourceTree = "<group>"; }; 159AC43EFE0A1F95FCB358A4 /* Pods-MastodonIntent.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonIntent.release.xcconfig"; path = "Target Support Files/Pods-MastodonIntent/Pods-MastodonIntent.release.xcconfig"; sourceTree = "<group>"; }; 164F0EBB267D4FE400249499 /* BoopSound.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = BoopSound.caf; sourceTree = "<group>"; }; 1D6D967E77A5357E2C6110D9 /* Pods-Mastodon.asdk - debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.asdk - debug.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.asdk - debug.xcconfig"; sourceTree = "<group>"; }; - 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+PublicTimeline.swift"; sourceTree = "<group>"; }; - 2D084B8C26258EA3003AA3AF /* NotificationViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationViewModel+Diffable.swift"; sourceTree = "<group>"; }; - 2D084B9226259545003AA3AF /* NotificationViewModel+LoadLatestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationViewModel+LoadLatestState.swift"; sourceTree = "<group>"; }; - 2D0B7A1C261D839600B44727 /* SearchHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistory.swift; sourceTree = "<group>"; }; - 2D152A8B25C295CC009AA50C /* StatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusView.swift; sourceTree = "<group>"; }; - 2D152A9125C2980C009AA50C /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = "<group>"; }; + 2C12EB4B3699D5D597027962 /* Pods-MastodonIntent.release snapshot.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonIntent.release snapshot.xcconfig"; path = "Target Support Files/Pods-MastodonIntent/Pods-MastodonIntent.release snapshot.xcconfig"; sourceTree = "<group>"; }; 2D198642261BF09500F0B013 /* SearchResultItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultItem.swift; sourceTree = "<group>"; }; 2D198648261C0B8500F0B013 /* SearchResultSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultSection.swift; sourceTree = "<group>"; }; - 2D206B7125F5D27F00143C56 /* AudioContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioContainerView.swift; sourceTree = "<group>"; }; 2D206B8525F5FB0900143C56 /* Double.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Double.swift; sourceTree = "<group>"; }; - 2D206B8B25F6015000143C56 /* AudioPlaybackService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlaybackService.swift; sourceTree = "<group>"; }; 2D206B9125F60EA700143C56 /* UIControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIControl.swift; sourceTree = "<group>"; }; - 2D24E11C2626D8B100A59D4F /* NotificationStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationStatusTableViewCell.swift; sourceTree = "<group>"; }; 2D24E1222626ED9D00A59D4F /* UIView+Gesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Gesture.swift"; sourceTree = "<group>"; }; - 2D24E12C2626FD2E00A59D4F /* NotificationViewModel+LoadOldestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationViewModel+LoadOldestState.swift"; sourceTree = "<group>"; }; 2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMiddleLoaderTableViewCell.swift; sourceTree = "<group>"; }; 2D32EAB925CB9B0500C9ED86 /* UIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIView.swift; sourceTree = "<group>"; }; - 2D32EAD925CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewModel+LoadMiddleState.swift"; sourceTree = "<group>"; }; - 2D34D9CA261489930081BFC0 /* SearchViewController+Recommend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewController+Recommend.swift"; sourceTree = "<group>"; }; 2D34D9D026148D9E0081BFC0 /* APIService+Recommend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Recommend.swift"; sourceTree = "<group>"; }; 2D34D9DA261494120081BFC0 /* APIService+Search.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Search.swift"; sourceTree = "<group>"; }; - 2D34D9E126149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRecommendTagsCollectionViewCell.swift; sourceTree = "<group>"; }; 2D35237926256D920031AF25 /* NotificationSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSection.swift; sourceTree = "<group>"; }; 2D364F7125E66D7500204FDC /* MastodonResendEmailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonResendEmailViewController.swift; sourceTree = "<group>"; }; 2D364F7725E66D8300204FDC /* MastodonResendEmailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonResendEmailViewModel.swift; sourceTree = "<group>"; }; - 2D38F1C525CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentOffsetAdjustableTimelineViewControllerDelegate.swift; sourceTree = "<group>"; }; 2D38F1D425CD465300561493 /* HomeTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineViewController.swift; sourceTree = "<group>"; }; - 2D38F1DE25CD46A400561493 /* HomeTimelineViewController+Provider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeTimelineViewController+Provider.swift"; sourceTree = "<group>"; }; 2D38F1E425CD46C100561493 /* HomeTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineViewModel.swift; sourceTree = "<group>"; }; 2D38F1EA25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeTimelineViewModel+LoadLatestState.swift"; sourceTree = "<group>"; }; - 2D38F1F025CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeTimelineViewModel+LoadMiddleState.swift"; sourceTree = "<group>"; }; 2D38F1F625CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeTimelineViewModel+LoadOldestState.swift"; sourceTree = "<group>"; }; - 2D38F1FD25CD481700561493 /* StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusProvider.swift; sourceTree = "<group>"; }; 2D38F20725CD491300561493 /* DisposeBagCollectable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisposeBagCollectable.swift; sourceTree = "<group>"; }; 2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITapGestureRecognizer.swift; sourceTree = "<group>"; }; - 2D42FF7D25C82218004A627A /* ActionToolBarContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionToolBarContainer.swift; sourceTree = "<group>"; }; - 2D42FF8425C8224F004A627A /* HitTestExpandedButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HitTestExpandedButton.swift; sourceTree = "<group>"; }; - 2D42FF8E25C8228A004A627A /* UIButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIButton.swift; sourceTree = "<group>"; }; - 2D45E5BE25C9549700A6D639 /* PublicTimelineViewModel+State.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewModel+State.swift"; sourceTree = "<group>"; }; 2D4AD89B263165B500613EFC /* SuggestionAccountCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAccountCollectionViewCell.swift; sourceTree = "<group>"; }; 2D4AD8A126316CD200613EFC /* SelectedAccountSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectedAccountSection.swift; sourceTree = "<group>"; }; 2D4AD8A726316D3500613EFC /* SelectedAccountItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectedAccountItem.swift; sourceTree = "<group>"; }; @@ -853,86 +751,56 @@ 2D5A3D3725CF8D9F002347D6 /* ScrollViewContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollViewContainer.swift; sourceTree = "<group>"; }; 2D5A3D6125CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeTimelineViewController+DebugAction.swift"; sourceTree = "<group>"; }; 2D607AD726242FC500B70763 /* NotificationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationViewModel.swift; sourceTree = "<group>"; }; - 2D6125462625436B00299647 /* Notification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notification.swift; sourceTree = "<group>"; }; 2D61254C262547C200299647 /* APIService+Notification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Notification.swift"; sourceTree = "<group>"; }; - 2D61335725C188A000CAE157 /* APIService+Persist+Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+Status.swift"; sourceTree = "<group>"; }; 2D61335D25C1894B00CAE157 /* APIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIService.swift; sourceTree = "<group>"; }; 2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error+Detail.swift"; sourceTree = "<group>"; }; 2D694A7325F9EB4E0038ADDC /* ContentWarningOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentWarningOverlayView.swift; sourceTree = "<group>"; }; - 2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = "<group>"; }; - 2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Status.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Status.swift"; sourceTree = "<group>"; }; 2D6DE3FF26141DF600A63F6A /* SearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewModel.swift; sourceTree = "<group>"; }; - 2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineViewController.swift; sourceTree = "<group>"; }; - 2D76316A25C14D4C00929FB9 /* PublicTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineViewModel.swift; sourceTree = "<group>"; }; - 2D76317C25C14DF400929FB9 /* PublicTimelineViewController+Provider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewController+Provider.swift"; sourceTree = "<group>"; }; - 2D76318225C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewModel+Diffable.swift"; sourceTree = "<group>"; }; 2D76319E25C1521200929FB9 /* StatusSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusSection.swift; sourceTree = "<group>"; }; 2D7631A725C1535600929FB9 /* StatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableViewCell.swift; sourceTree = "<group>"; }; - 2D7631B225C159F700929FB9 /* Item.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Item.swift; sourceTree = "<group>"; }; 2D7867182625B77500211898 /* NotificationItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationItem.swift; sourceTree = "<group>"; }; - 2D79E700261EA5550011E398 /* APIService+CoreData+Tag.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Tag.swift"; sourceTree = "<group>"; }; 2D82B9FE25E7863200E36F0F /* OnboardingViewControllerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewControllerAppearance.swift; sourceTree = "<group>"; }; 2D82BA0425E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonResendEmailViewModelNavigationDelegateShim.swift; sourceTree = "<group>"; }; 2D8434F425FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineNavigationBarTitleViewModel.swift; sourceTree = "<group>"; }; 2D8434FA25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineNavigationBarTitleView.swift; sourceTree = "<group>"; }; 2D84350425FF858100EECE90 /* UIScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIScrollView.swift; sourceTree = "<group>"; }; 2D8FCA072637EABB00137F46 /* APIService+FollowRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+FollowRequest.swift"; sourceTree = "<group>"; }; - 2D927F0125C7E4F2004F19B8 /* Mention.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mention.swift; sourceTree = "<group>"; }; - 2D927F0725C7E9A8004F19B8 /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = "<group>"; }; - 2D927F0D25C7E9C9004F19B8 /* History.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = History.swift; sourceTree = "<group>"; }; - 2D927F1325C7EDD9004F19B8 /* Emoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Emoji.swift; sourceTree = "<group>"; }; 2D939AB425EDD8A90076FA61 /* String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = "<group>"; }; 2D939AE725EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonRegisterViewController+Avatar.swift"; sourceTree = "<group>"; }; 2D9DB966263A76FB007C1D71 /* BlockDomainService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockDomainService.swift; sourceTree = "<group>"; }; - 2D9DB968263A833E007C1D71 /* DomainBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainBlock.swift; sourceTree = "<group>"; }; 2D9DB96A263A91D1007C1D71 /* APIService+DomainBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+DomainBlock.swift"; sourceTree = "<group>"; }; 2DA504682601ADE7008F4E6C /* SawToothView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SawToothView.swift; sourceTree = "<group>"; }; 2DA6054625F716A2006356F9 /* PlaybackState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackState.swift; sourceTree = "<group>"; }; - 2DA6055025F74407006356F9 /* AudioContainerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioContainerViewModel.swift; sourceTree = "<group>"; }; 2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineLoaderTableViewCell.swift; sourceTree = "<group>"; }; 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineBottomLoaderTableViewCell.swift; sourceTree = "<group>"; }; 2DA7D05025CA545E00804E11 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = "<group>"; }; - 2DA7D05625CA693F00804E11 /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = "<group>"; }; 2DAC9E37262FC2320062E1A6 /* SuggestionAccountViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAccountViewController.swift; sourceTree = "<group>"; }; 2DAC9E3D262FC2400062E1A6 /* SuggestionAccountViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAccountViewModel.swift; sourceTree = "<group>"; }; 2DAC9E45262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAccountTableViewCell.swift; sourceTree = "<group>"; }; 2DB72C8B262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Notification+Type.swift"; sourceTree = "<group>"; }; 2DCB73FC2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRecommendCollectionHeader.swift; sourceTree = "<group>"; }; - 2DE0FAC02615F04D00CDF649 /* RecommendHashTagSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecommendHashTagSection.swift; sourceTree = "<group>"; }; - 2DE0FAC72615F5F000CDF649 /* SearchRecommendAccountsCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRecommendAccountsCollectionViewCell.swift; sourceTree = "<group>"; }; 2DE0FACD2615F7AD00CDF649 /* RecommendAccountSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecommendAccountSection.swift; sourceTree = "<group>"; }; 2DF123A625C3B0210020F248 /* ActiveLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveLabel.swift; sourceTree = "<group>"; }; - 2DF75B9A25D0E27500694EC8 /* StatusProviderFacade.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusProviderFacade.swift; sourceTree = "<group>"; }; - 2DF75BA025D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+StatusTableViewCellDelegate.swift"; sourceTree = "<group>"; }; 2DF75BA625D10E1000694EC8 /* APIService+Favorite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Favorite.swift"; sourceTree = "<group>"; }; - 2DF75BB825D1474100694EC8 /* ManagedObjectObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedObjectObserver.swift; sourceTree = "<group>"; }; - 2DF75BC625D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedObjectContextObjectsDidChange.swift; sourceTree = "<group>"; }; - 2DFAD5362617010500F9EE7C /* SearchResultTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultTableViewCell.swift; sourceTree = "<group>"; }; 2E1F6A67FDF9771D3E064FDC /* Pods-Mastodon.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.debug.xcconfig"; sourceTree = "<group>"; }; 374AA339A20E0FAC75BCDA6D /* Pods_NotificationService.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_NotificationService.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3B7FD8F28DDA8FBCE5562B78 /* Pods-NotificationService.asdk - debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NotificationService.asdk - debug.xcconfig"; path = "Target Support Files/Pods-NotificationService/Pods-NotificationService.asdk - debug.xcconfig"; sourceTree = "<group>"; }; 3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_MastodonUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 3E08A432F40BA7B9CAA9DB68 /* Pods-AppShared.release snapshot.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AppShared.release snapshot.xcconfig"; path = "Target Support Files/Pods-AppShared/Pods-AppShared.release snapshot.xcconfig"; sourceTree = "<group>"; }; 452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MastodonTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 459EA4F43058CAB47719E963 /* Pods-Mastodon-MastodonUITests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.debug.xcconfig"; sourceTree = "<group>"; }; 46DAB0EBDDFB678347CD96FF /* Pods-MastodonTests.asdk - release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonTests.asdk - release.xcconfig"; path = "Target Support Files/Pods-MastodonTests/Pods-MastodonTests.asdk - release.xcconfig"; sourceTree = "<group>"; }; 5B24BBD7262DB14800A9381B /* ReportViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReportViewModel.swift; sourceTree = "<group>"; }; 5B24BBD8262DB14800A9381B /* ReportViewModel+Diffable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ReportViewModel+Diffable.swift"; sourceTree = "<group>"; }; 5B24BBE1262DB19100A9381B /* APIService+Report.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+Report.swift"; sourceTree = "<group>"; }; - 5B8E055726319E47006E3C53 /* ReportFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportFooterView.swift; sourceTree = "<group>"; }; 5B90C456262599800002E742 /* SettingsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = "<group>"; }; 5B90C459262599800002E742 /* SettingsToggleTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsToggleTableViewCell.swift; sourceTree = "<group>"; }; 5B90C45A262599800002E742 /* SettingsAppearanceTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsAppearanceTableViewCell.swift; sourceTree = "<group>"; }; 5B90C45B262599800002E742 /* SettingsLinkTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsLinkTableViewCell.swift; sourceTree = "<group>"; }; 5B90C45C262599800002E742 /* SettingsSectionHeader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsSectionHeader.swift; sourceTree = "<group>"; }; - 5B90C46C26259B2C0002E742 /* Subscription.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Subscription.swift; sourceTree = "<group>"; }; - 5B90C46D26259B2C0002E742 /* Setting.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Setting.swift; sourceTree = "<group>"; }; - 5B90C47E26259BA90002E742 /* SubscriptionAlerts.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubscriptionAlerts.swift; sourceTree = "<group>"; }; 5B90C48426259BF10002E742 /* APIService+Subscriptions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+Subscriptions.swift"; sourceTree = "<group>"; }; 5B90C48A26259C120002E742 /* APIService+CoreData+Subscriptions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Subscriptions.swift"; sourceTree = "<group>"; }; 5BB04FD4262E7AFF0043BFF6 /* ReportViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportViewController.swift; sourceTree = "<group>"; }; - 5BB04FDA262EA3070043BFF6 /* ReportHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportHeaderView.swift; sourceTree = "<group>"; }; - 5BB04FE8262EFC300043BFF6 /* ReportedStatusTableviewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportedStatusTableviewCell.swift; sourceTree = "<group>"; }; - 5BB04FEE262F0DCB0043BFF6 /* ReportViewModel+Data.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ReportViewModel+Data.swift"; sourceTree = "<group>"; }; 5BB04FF4262F0E6D0043BFF6 /* ReportSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportSection.swift; sourceTree = "<group>"; }; 5CE45680252519F42FEA2D13 /* Pods-ShareActionExtension.asdk - release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareActionExtension.asdk - release.xcconfig"; path = "Target Support Files/Pods-ShareActionExtension/Pods-ShareActionExtension.asdk - release.xcconfig"; sourceTree = "<group>"; }; 5D03938F2612D259007FE196 /* WebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewController.swift; sourceTree = "<group>"; }; @@ -942,13 +810,7 @@ 5DDDF1922617442700311060 /* Mastodon+Entity+Account.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Account.swift"; sourceTree = "<group>"; }; 5DDDF1982617447F00311060 /* Mastodon+Entity+Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Tag.swift"; sourceTree = "<group>"; }; 5DDDF1A82617489F00311060 /* Mastodon+Entity+History.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+History.swift"; sourceTree = "<group>"; }; - 5DF1054025F886D400D6C0D4 /* VideoPlaybackService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlaybackService.swift; sourceTree = "<group>"; }; - 5DF1054625F8870E00D6C0D4 /* VideoPlayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerViewModel.swift; sourceTree = "<group>"; }; 5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AVPlayer.swift; sourceTree = "<group>"; }; - 5DF1057825F88A1D00D6C0D4 /* PlayerContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerContainerView.swift; sourceTree = "<group>"; }; - 5DF1057E25F88A4100D6C0D4 /* TouchBlockingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TouchBlockingView.swift; sourceTree = "<group>"; }; - 5DF1058425F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NeedsDependency+AVPlayerViewControllerDelegate.swift"; sourceTree = "<group>"; }; - 5DFC35DE262068D20045711D /* SearchViewController+Follow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewController+Follow.swift"; sourceTree = "<group>"; }; 6130CBE4B26E3C976ACC1688 /* Pods-ShareActionExtension.asdk - debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareActionExtension.asdk - debug.xcconfig"; path = "Target Support Files/Pods-ShareActionExtension/Pods-ShareActionExtension.asdk - debug.xcconfig"; sourceTree = "<group>"; }; 75E3471C898DDD9631729B6E /* Pods-Mastodon.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.release.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.release.xcconfig"; sourceTree = "<group>"; }; 77EE917BC055E6621C0452B6 /* Pods-ShareActionExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareActionExtension.debug.xcconfig"; path = "Target Support Files/Pods-ShareActionExtension/Pods-ShareActionExtension.debug.xcconfig"; sourceTree = "<group>"; }; @@ -956,6 +818,8 @@ 819CEC9DCAD8E8E7BD85A7BB /* Pods-Mastodon.asdk.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.asdk.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.asdk.xcconfig"; sourceTree = "<group>"; }; 861BE60ED27430771CFD578D /* Pods-MastodonIntent.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonIntent.debug.xcconfig"; path = "Target Support Files/Pods-MastodonIntent/Pods-MastodonIntent.debug.xcconfig"; sourceTree = "<group>"; }; 8850E70A1D5FF51432E43653 /* Pods-Mastodon-MastodonUITests.asdk - release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.asdk - release.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.asdk - release.xcconfig"; sourceTree = "<group>"; }; + 8ADD558BE5B8255E5764A54F /* Pods-NotificationService.release snapshot.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NotificationService.release snapshot.xcconfig"; path = "Target Support Files/Pods-NotificationService/Pods-NotificationService.release snapshot.xcconfig"; sourceTree = "<group>"; }; + 8E79CCBE51FBC3F7FE8CF49F /* Pods-MastodonTests.release snapshot.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonTests.release snapshot.xcconfig"; path = "Target Support Files/Pods-MastodonTests/Pods-MastodonTests.release snapshot.xcconfig"; sourceTree = "<group>"; }; 8ED8C4B1F1BA2DCFF2926BB1 /* Pods-Mastodon-NotificationService.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-NotificationService.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-NotificationService/Pods-Mastodon-NotificationService.debug.xcconfig"; sourceTree = "<group>"; }; 9553C689FFA9EBC880CAB78D /* Pods-NotificationService.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NotificationService.debug.xcconfig"; path = "Target Support Files/Pods-NotificationService/Pods-NotificationService.debug.xcconfig"; sourceTree = "<group>"; }; 95AD0663479892A2109EEFD0 /* Pods-ShareActionExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareActionExtension.release.xcconfig"; path = "Target Support Files/Pods-ShareActionExtension/Pods-ShareActionExtension.release.xcconfig"; sourceTree = "<group>"; }; @@ -977,7 +841,14 @@ DB0009A826AEE5DC009B9D2D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.intentdefinition; name = Base; path = Base.lproj/Intents.intentdefinition; sourceTree = "<group>"; }; DB0009AD26AEE5E4009B9D2D /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Intents.strings; sourceTree = "<group>"; }; DB0140CE25C42AEE00F9F3CF /* OSLog.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OSLog.swift; sourceTree = "<group>"; }; - DB023294267F0AB800031745 /* ASMetaEditableTextNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ASMetaEditableTextNode.swift; sourceTree = "<group>"; }; + DB023D25279FFB0A005AC798 /* ShareActivityProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareActivityProvider.swift; sourceTree = "<group>"; }; + DB023D2727A0FABD005AC798 /* NotificationTableViewCellDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationTableViewCellDelegate.swift; sourceTree = "<group>"; }; + DB023D2927A0FE5C005AC798 /* DataSourceProvider+NotificationTableViewCellDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceProvider+NotificationTableViewCellDelegate.swift"; sourceTree = "<group>"; }; + DB023D2B27A10464005AC798 /* NotificationTimelineViewController+DataSourceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationTimelineViewController+DataSourceProvider.swift"; sourceTree = "<group>"; }; + DB025B77278D606A002F581E /* StatusItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusItem.swift; sourceTree = "<group>"; }; + DB025B92278D6501002F581E /* Persistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Persistence.swift; sourceTree = "<group>"; }; + DB025B94278D6530002F581E /* Persistence+MastodonUser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Persistence+MastodonUser.swift"; sourceTree = "<group>"; }; + DB025B96278D66D5002F581E /* MastodonUser+Property.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonUser+Property.swift"; sourceTree = "<group>"; }; DB029E94266A20430062874E /* MastodonAuthenticationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonAuthenticationController.swift; sourceTree = "<group>"; }; DB02CDAA26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadReplyLoaderTableViewCell.swift; sourceTree = "<group>"; }; DB02CDBE2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptiveUserInterfaceStyleBarButtonItem.swift; sourceTree = "<group>"; }; @@ -986,30 +857,66 @@ DB03F7F22689AEA3007B274C /* ComposeRepliedToStatusContentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeRepliedToStatusContentTableViewCell.swift; sourceTree = "<group>"; }; DB03F7F42689B782007B274C /* ComposeTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeTableView.swift; sourceTree = "<group>"; }; DB040ED026538E3C00BEE9D8 /* Trie.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Trie.swift; sourceTree = "<group>"; }; + DB0617EA277EF3820030EE79 /* GradientBorderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GradientBorderView.swift; sourceTree = "<group>"; }; + DB0617EC277F02C50030EE79 /* OnboardingNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingNavigationController.swift; sourceTree = "<group>"; }; + DB0617EE277F12720030EE79 /* NavigationActionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationActionView.swift; sourceTree = "<group>"; }; + DB0617F0278413D00030EE79 /* PickServerServerSectionTableHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerServerSectionTableHeaderView.swift; sourceTree = "<group>"; }; + DB0617F427855AB90030EE79 /* ServerRuleSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerRuleSection.swift; sourceTree = "<group>"; }; + DB0617FC27855BFE0030EE79 /* ServerRuleItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerRuleItem.swift; sourceTree = "<group>"; }; + DB0617FE27855D6C0030EE79 /* MastodonServerRulesViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonServerRulesViewModel+Diffable.swift"; sourceTree = "<group>"; }; + DB0618002785732C0030EE79 /* ServerRulesTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerRulesTableViewCell.swift; sourceTree = "<group>"; }; + DB0618022785A7100030EE79 /* RegisterSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterSection.swift; sourceTree = "<group>"; }; + DB0618042785A73D0030EE79 /* RegisterItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterItem.swift; sourceTree = "<group>"; }; + DB0618062785A8880030EE79 /* MastodonRegisterViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonRegisterViewModel+Diffable.swift"; sourceTree = "<group>"; }; + DB0618092785B2AB0030EE79 /* MastodonRegisterAvatarTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterAvatarTableViewCell.swift; sourceTree = "<group>"; }; DB084B5625CBC56C00F898ED /* Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Status.swift; sourceTree = "<group>"; }; DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Instance.swift"; sourceTree = "<group>"; }; - DB0C946A26A700AB0088FB11 /* MastodonUser+Property.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonUser+Property.swift"; sourceTree = "<group>"; }; - DB0C946E26A7D2A80088FB11 /* AvatarImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarImageView.swift; sourceTree = "<group>"; }; - DB0C947126A7D2D70088FB11 /* AvatarButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarButton.swift; sourceTree = "<group>"; }; DB0C947626A7FE840088FB11 /* NotificationAvatarButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationAvatarButton.swift; sourceTree = "<group>"; }; - DB0E91E926A9675100BD2ACC /* MetaLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetaLabel.swift; sourceTree = "<group>"; }; DB0EF72A26FDB1D200347686 /* SidebarListCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarListCollectionViewCell.swift; sourceTree = "<group>"; }; DB0EF72D26FDB24F00347686 /* SidebarListContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarListContentView.swift; sourceTree = "<group>"; }; - DB0F814D264CFFD300F2A12B /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = "<group>"; }; DB0F814E264CFFD300F2A12B /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/InfoPlist.strings; sourceTree = "<group>"; }; DB0F814F264D1E2500F2A12B /* PickServerLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerLoaderTableViewCell.swift; sourceTree = "<group>"; }; + DB0FCB67279507EF006C02E2 /* DataSourceFacade+Meta.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Meta.swift"; sourceTree = "<group>"; }; + DB0FCB6B27950E29006C02E2 /* MastodonMentionContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonMentionContainer.swift; sourceTree = "<group>"; }; + DB0FCB6D27950E6B006C02E2 /* MastodonMention.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonMention.swift; sourceTree = "<group>"; }; + DB0FCB6F27951368006C02E2 /* TimelineMiddleLoaderTableViewCell+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TimelineMiddleLoaderTableViewCell+ViewModel.swift"; sourceTree = "<group>"; }; + DB0FCB7127952986006C02E2 /* NamingState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NamingState.swift; sourceTree = "<group>"; }; + DB0FCB7327956939006C02E2 /* DataSourceFacade+Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Status.swift"; sourceTree = "<group>"; }; + DB0FCB75279571C5006C02E2 /* ThreadViewController+DataSourceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ThreadViewController+DataSourceProvider.swift"; sourceTree = "<group>"; }; + DB0FCB7727957678006C02E2 /* DataSourceProvider+UITableViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceProvider+UITableViewDelegate.swift"; sourceTree = "<group>"; }; + DB0FCB79279576A2006C02E2 /* DataSourceFacade+Thread.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Thread.swift"; sourceTree = "<group>"; }; + DB0FCB7B2795821F006C02E2 /* StatusThreadRootTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusThreadRootTableViewCell.swift; sourceTree = "<group>"; }; + DB0FCB7D27958957006C02E2 /* StatusThreadRootTableViewCell+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusThreadRootTableViewCell+ViewModel.swift"; sourceTree = "<group>"; }; + DB0FCB7F27968F70006C02E2 /* MastodonStatusThreadViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonStatusThreadViewModel.swift; sourceTree = "<group>"; }; + DB0FCB812796AC78006C02E2 /* UserTimelineViewController+DataSourceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserTimelineViewController+DataSourceProvider.swift"; sourceTree = "<group>"; }; + DB0FCB832796B2A2006C02E2 /* FavoriteViewController+DataSourceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FavoriteViewController+DataSourceProvider.swift"; sourceTree = "<group>"; }; + DB0FCB852796BDA1006C02E2 /* SearchSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchSection.swift; sourceTree = "<group>"; }; + DB0FCB872796BDA9006C02E2 /* SearchItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchItem.swift; sourceTree = "<group>"; }; + DB0FCB8B2796BF8D006C02E2 /* SearchViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewModel+Diffable.swift"; sourceTree = "<group>"; }; + DB0FCB8D2796C0B7006C02E2 /* TrendCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendCollectionViewCell.swift; sourceTree = "<group>"; }; + DB0FCB8F2796C5EB006C02E2 /* APIService+Trend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Trend.swift"; sourceTree = "<group>"; }; + DB0FCB912796DE19006C02E2 /* TrendSectionHeaderCollectionReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendSectionHeaderCollectionReusableView.swift; sourceTree = "<group>"; }; + DB0FCB932797E2B0006C02E2 /* SearchResultViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchResultViewModel+Diffable.swift"; sourceTree = "<group>"; }; + DB0FCB952797E6C2006C02E2 /* SearchResultViewController+DataSourceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchResultViewController+DataSourceProvider.swift"; sourceTree = "<group>"; }; + DB0FCB972797F6BF006C02E2 /* UserTableViewCell+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserTableViewCell+ViewModel.swift"; sourceTree = "<group>"; }; + DB0FCB992797F7AD006C02E2 /* UserView+Configuration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserView+Configuration.swift"; sourceTree = "<group>"; }; + DB0FCB9B27980AB6006C02E2 /* HashtagTimelineViewController+DataSourceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewController+DataSourceProvider.swift"; sourceTree = "<group>"; }; DB118A8125E4B6E600FAB162 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; }; + DB126A4C278C063F005726EE /* eu-ES */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "eu-ES"; path = "eu-ES.lproj/Intents.strings"; sourceTree = "<group>"; }; + DB126A4F278C063F005726EE /* eu-ES */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "eu-ES"; path = "eu-ES.lproj/InfoPlist.strings"; sourceTree = "<group>"; }; + DB126A50278C063F005726EE /* eu-ES */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "eu-ES"; path = "eu-ES.lproj/Intents.stringsdict"; sourceTree = "<group>"; }; + DB126A56278C088D005726EE /* sv-FI */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "sv-FI"; path = "sv-FI.lproj/Intents.strings"; sourceTree = "<group>"; }; + DB126A59278C088D005726EE /* sv-FI */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "sv-FI"; path = "sv-FI.lproj/InfoPlist.strings"; sourceTree = "<group>"; }; + DB126A5A278C088D005726EE /* sv-FI */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "sv-FI"; path = "sv-FI.lproj/Intents.stringsdict"; sourceTree = "<group>"; }; + DB159C2A27A17BAC0068DC77 /* DataSourceFacade+Media.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Media.swift"; sourceTree = "<group>"; }; DB1D186B25EF5BA7003F1F23 /* PollTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollTableView.swift; sourceTree = "<group>"; }; DB1D61CE26F1B33600DA8662 /* WelcomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeViewModel.swift; sourceTree = "<group>"; }; - DB1D842B26551A1C000346B3 /* StatusProvider+StatusTableViewKeyCommandNavigateable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+StatusTableViewKeyCommandNavigateable.swift"; sourceTree = "<group>"; }; DB1D842D26552C4D000346B3 /* StatusTableViewControllerNavigateable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableViewControllerNavigateable.swift; sourceTree = "<group>"; }; DB1D842F26566512000346B3 /* KeyboardPreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardPreference.swift; sourceTree = "<group>"; }; DB1D843326579931000346B3 /* TableViewControllerNavigateable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewControllerNavigateable.swift; sourceTree = "<group>"; }; - DB1D843526579DB5000346B3 /* StatusProvider+TableViewControllerNavigateable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+TableViewControllerNavigateable.swift"; sourceTree = "<group>"; }; DB1D84372657B275000346B3 /* SegmentedControlNavigateable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentedControlNavigateable.swift; sourceTree = "<group>"; }; DB1E346725F518E20079D7DF /* CategoryPickerSection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CategoryPickerSection.swift; sourceTree = "<group>"; }; DB1E347725F519300079D7DF /* PickServerItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PickServerItem.swift; sourceTree = "<group>"; }; - DB1EE7B1267F9525000CC337 /* StatusProvider+StatusNodeDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+StatusNodeDelegate.swift"; sourceTree = "<group>"; }; DB1FD43525F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonPickServerViewModel+LoadIndexedServerState.swift"; sourceTree = "<group>"; }; DB1FD44325F26CCC004CFCFC /* PickServerSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerSection.swift; sourceTree = "<group>"; }; DB1FD44F25F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonPickServerViewModel+Diffable.swift"; sourceTree = "<group>"; }; @@ -1020,8 +927,21 @@ DB2B3ABD25E37E15007045F9 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = "<group>"; }; DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthenticationViewModel.swift; sourceTree = "<group>"; }; DB2FF50F260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollExpiresOptionCollectionViewCell.swift; sourceTree = "<group>"; }; + DB336F20278D6D960031E64B /* MastodonEmoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonEmoji.swift; sourceTree = "<group>"; }; + DB336F22278D6DED0031E64B /* MastodonEmojiContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonEmojiContainer.swift; sourceTree = "<group>"; }; + DB336F27278D6EC70031E64B /* MastodonFieldContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonFieldContainer.swift; sourceTree = "<group>"; }; + DB336F29278D6F2B0031E64B /* MastodonField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonField.swift; sourceTree = "<group>"; }; + DB336F2B278D6FC30031E64B /* Persistence+Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Persistence+Status.swift"; sourceTree = "<group>"; }; + DB336F2D278D71AF0031E64B /* Status+Property.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Status+Property.swift"; sourceTree = "<group>"; }; + DB336F31278D77330031E64B /* Persistence+Poll.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Persistence+Poll.swift"; sourceTree = "<group>"; }; + DB336F33278D77730031E64B /* Persistence+PollOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Persistence+PollOption.swift"; sourceTree = "<group>"; }; + DB336F35278D77A40031E64B /* PollOption+Property.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PollOption+Property.swift"; sourceTree = "<group>"; }; + DB336F37278D7AAF0031E64B /* Poll+Property.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Poll+Property.swift"; sourceTree = "<group>"; }; + DB336F3C278D80040031E64B /* FeedFetchedResultsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedFetchedResultsController.swift; sourceTree = "<group>"; }; + DB336F3E278E668C0031E64B /* StatusTableViewCell+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusTableViewCell+ViewModel.swift"; sourceTree = "<group>"; }; + DB336F40278E68480031E64B /* StatusView+Configuration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusView+Configuration.swift"; sourceTree = "<group>"; }; + DB336F42278EB1680031E64B /* MediaView+Configuration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MediaView+Configuration.swift"; sourceTree = "<group>"; }; DB35FC1E2612F1D9006193C9 /* ProfileRelationshipActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileRelationshipActionButton.swift; sourceTree = "<group>"; }; - DB35FC242612FD7A006193C9 /* ProfileFieldView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldView.swift; sourceTree = "<group>"; }; DB36679C268AB91B0027D07F /* ComposeStatusAttachmentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusAttachmentTableViewCell.swift; sourceTree = "<group>"; }; DB36679E268ABAF20027D07F /* ComposeStatusAttachmentSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusAttachmentSection.swift; sourceTree = "<group>"; }; DB3667A0268ABB2E0027D07F /* ComposeStatusAttachmentItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusAttachmentItem.swift; sourceTree = "<group>"; }; @@ -1029,7 +949,6 @@ DB3667A5268AE2620027D07F /* ComposeStatusPollSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollSection.swift; sourceTree = "<group>"; }; DB3667A7268AE2900027D07F /* ComposeStatusPollItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollItem.swift; sourceTree = "<group>"; }; DB3D0FED25BAA42200EAA174 /* MastodonSDK */ = {isa = PBXFileReference; lastKnownFileType = folder; path = MastodonSDK; sourceTree = "<group>"; }; - DB3D100E25BAA75E00EAA174 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; }; DB427DD225BAA00100D1B89D /* Mastodon.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Mastodon.app; sourceTree = BUILT_PRODUCTS_DIR; }; DB427DD525BAA00100D1B89D /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; DB427DD725BAA00100D1B89D /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; }; @@ -1043,47 +962,36 @@ DB427DF325BAA00100D1B89D /* MastodonUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MastodonUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; DB427DF725BAA00100D1B89D /* MastodonUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonUITests.swift; sourceTree = "<group>"; }; DB427DF925BAA00100D1B89D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; - DB44384E25E8C1FA008912A2 /* CALayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CALayer.swift; sourceTree = "<group>"; }; DB443CD32694627B00159B29 /* AppearanceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceView.swift; sourceTree = "<group>"; }; DB44767A260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiPickerInputView.swift; sourceTree = "<group>"; }; DB447680260B3ED600B66B82 /* CustomEmojiPickerSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiPickerSection.swift; sourceTree = "<group>"; }; DB44768A260B3F2100B66B82 /* CustomEmojiPickerItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiPickerItem.swift; sourceTree = "<group>"; }; DB447690260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiPickerItemCollectionViewCell.swift; sourceTree = "<group>"; }; DB447696260B439000B66B82 /* CustomEmojiPickerHeaderCollectionReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiPickerHeaderCollectionReusableView.swift; sourceTree = "<group>"; }; - DB4481AC25EE155900BEFB67 /* Poll.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Poll.swift; sourceTree = "<group>"; }; - DB4481B225EE16D000BEFB67 /* PollOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOption.swift; sourceTree = "<group>"; }; DB4481B825EE289600BEFB67 /* UITableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UITableView.swift; sourceTree = "<group>"; }; - DB4481C525EE2ADA00BEFB67 /* PollSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollSection.swift; sourceTree = "<group>"; }; - DB4481CB25EE2AFE00BEFB67 /* PollItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollItem.swift; sourceTree = "<group>"; }; DB45FAB525CA5485005A8AC7 /* UIAlertController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIAlertController.swift; sourceTree = "<group>"; }; DB45FAD625CA6C76005A8AC7 /* UIBarButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIBarButtonItem.swift; sourceTree = "<group>"; }; - DB45FADC25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+MastodonUser.swift"; sourceTree = "<group>"; }; DB45FAE225CA7181005A8AC7 /* MastodonUser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonUser.swift; sourceTree = "<group>"; }; - DB45FAEC25CA7A9A005A8AC7 /* MastodonAuthentication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonAuthentication.swift; sourceTree = "<group>"; }; DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+MastodonAuthentication.swift"; sourceTree = "<group>"; }; DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationService.swift; sourceTree = "<group>"; }; DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+HomeTimeline.swift"; sourceTree = "<group>"; }; DB47229625F9EFAD00DA7F53 /* NSManagedObjectContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSManagedObjectContext.swift; sourceTree = "<group>"; }; + DB47AB6127CF752B00CD73C7 /* MastodonUISnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonUISnapshotTests.swift; sourceTree = "<group>"; }; + DB47AB6327CF858400CD73C7 /* AppStoreSnapshotTestPlan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = AppStoreSnapshotTestPlan.xctestplan; sourceTree = "<group>"; }; DB482A3E261331E8008AE74C /* UserTimelineViewModel+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserTimelineViewModel+State.swift"; sourceTree = "<group>"; }; - DB482A44261335BA008AE74C /* UserTimelineViewController+Provider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserTimelineViewController+Provider.swift"; sourceTree = "<group>"; }; DB482A4A261340A7008AE74C /* APIService+UserTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+UserTimeline.swift"; sourceTree = "<group>"; }; DB4924E126312AB200E9DB22 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; }; DB4932B026F1FB5300EF46D4 /* WizardCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WizardCardView.swift; sourceTree = "<group>"; }; - DB4932B226F2054200EF46D4 /* CircleAvatarButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircleAvatarButton.swift; sourceTree = "<group>"; }; DB4932B826F31AD300EF46D4 /* BadgeButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeButton.swift; sourceTree = "<group>"; }; DB49A61325FF2C5600B98345 /* EmojiService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiService.swift; sourceTree = "<group>"; }; DB49A61E25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EmojiService+CustomEmojiViewModel.swift"; sourceTree = "<group>"; }; DB49A62425FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EmojiService+CustomEmojiViewModel+LoadState.swift"; sourceTree = "<group>"; }; DB49A62A25FF36C700B98345 /* APIService+CustomEmoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+CustomEmoji.swift"; sourceTree = "<group>"; }; - DB49A63C25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PlayerContainerView+MediaTypeIndicotorView.swift"; sourceTree = "<group>"; }; + DB4AA6B227BA34B6009EC082 /* CellFrameCacheContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CellFrameCacheContainer.swift; sourceTree = "<group>"; }; DB4B777F26CA4EFA00B087B3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Intents.strings; sourceTree = "<group>"; }; - DB4B778026CA4EFA00B087B3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ru; path = ru.lproj/Localizable.stringsdict; sourceTree = "<group>"; }; - DB4B778126CA4EFA00B087B3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = "<group>"; }; DB4B778226CA4EFA00B087B3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/InfoPlist.strings; sourceTree = "<group>"; }; DB4B778326CA4EFA00B087B3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ru; path = ru.lproj/Intents.stringsdict; sourceTree = "<group>"; }; DB4B778426CA500E00B087B3 /* gd-GB */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "gd-GB"; path = "gd-GB.lproj/Intents.strings"; sourceTree = "<group>"; }; - DB4B778526CA500E00B087B3 /* gd-GB */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "gd-GB"; path = "gd-GB.lproj/Localizable.stringsdict"; sourceTree = "<group>"; }; - DB4B778626CA500E00B087B3 /* gd-GB */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "gd-GB"; path = "gd-GB.lproj/Localizable.strings"; sourceTree = "<group>"; }; DB4B778726CA500E00B087B3 /* gd-GB */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "gd-GB"; path = "gd-GB.lproj/InfoPlist.strings"; sourceTree = "<group>"; }; DB4B778826CA500E00B087B3 /* gd-GB */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "gd-GB"; path = "gd-GB.lproj/Intents.stringsdict"; sourceTree = "<group>"; }; DB4B778926CA504100B087B3 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = es; path = es.lproj/Intents.stringsdict; sourceTree = "<group>"; }; @@ -1096,15 +1004,12 @@ DB4B779026CA504900B087B3 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = fr; path = fr.lproj/Intents.stringsdict; sourceTree = "<group>"; }; DB4B779126CA504A00B087B3 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ja; path = ja.lproj/Intents.stringsdict; sourceTree = "<group>"; }; DB4B779226CA50BA00B087B3 /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/Intents.strings; sourceTree = "<group>"; }; - DB4B779326CA50BA00B087B3 /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = th; path = th.lproj/Localizable.stringsdict; sourceTree = "<group>"; }; - DB4B779426CA50BA00B087B3 /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/Localizable.strings; sourceTree = "<group>"; }; DB4B779526CA50BA00B087B3 /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/InfoPlist.strings; sourceTree = "<group>"; }; DB4B779626CA50BA00B087B3 /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = th; path = th.lproj/Intents.stringsdict; sourceTree = "<group>"; }; DB4F0962269ED06300D62E92 /* SearchResultViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultViewController.swift; sourceTree = "<group>"; }; DB4F0965269ED52200D62E92 /* SearchResultViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultViewModel.swift; sourceTree = "<group>"; }; DB4F0967269ED8AD00D62E92 /* SearchHistoryTableHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistoryTableHeaderView.swift; sourceTree = "<group>"; }; DB4F0969269EDAD200D62E92 /* SearchResultViewModel+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchResultViewModel+State.swift"; sourceTree = "<group>"; }; - DB4F096B269EFA2000D62E92 /* SearchResultViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchResultViewController+StatusProvider.swift"; sourceTree = "<group>"; }; DB4F097426A037F500D62E92 /* SearchHistoryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistoryViewModel.swift; sourceTree = "<group>"; }; DB4F097A26A039FF00D62E92 /* SearchHistorySection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistorySection.swift; sourceTree = "<group>"; }; DB4F097C26A03A5B00D62E92 /* SearchHistoryItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistoryItem.swift; sourceTree = "<group>"; }; @@ -1113,18 +1018,14 @@ DB4FFC2A269EC39600D62E92 /* SearchTransitionController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchTransitionController.swift; sourceTree = "<group>"; }; DB51D170262832380062B7A1 /* BlurHashDecode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BlurHashDecode.swift; sourceTree = "<group>"; }; DB51D171262832380062B7A1 /* BlurHashEncode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BlurHashEncode.swift; sourceTree = "<group>"; }; - DB564BCF269F2F83001E39A7 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ar; path = ar.lproj/Localizable.stringsdict; sourceTree = "<group>"; }; - DB564BD1269F2F8A001E39A7 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = en; path = en.lproj/Localizable.stringsdict; sourceTree = "<group>"; }; DB564BD2269F3B35001E39A7 /* StatusFilterService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusFilterService.swift; sourceTree = "<group>"; }; - DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+UITableViewDelegate.swift"; sourceTree = "<group>"; }; - DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewCellHeightCacheableContainer.swift; sourceTree = "<group>"; }; DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Poll.swift"; sourceTree = "<group>"; }; - DB59F11725EFA35B001F1DAB /* StripProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StripProgressView.swift; sourceTree = "<group>"; }; DB5B7294273112B100081888 /* FollowingListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowingListViewController.swift; sourceTree = "<group>"; }; DB5B7297273112C800081888 /* FollowingListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowingListViewModel.swift; sourceTree = "<group>"; }; - DB5B72992731137900081888 /* FollowingListViewController+Provider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FollowingListViewController+Provider.swift"; sourceTree = "<group>"; }; DB5B729B273113C200081888 /* FollowingListViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FollowingListViewModel+Diffable.swift"; sourceTree = "<group>"; }; DB5B729D273113F300081888 /* FollowingListViewModel+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FollowingListViewModel+State.swift"; sourceTree = "<group>"; }; + DB603110279EB38500A935FE /* DataSourceFacade+Mute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Mute.swift"; sourceTree = "<group>"; }; + DB603112279EBEBA00A935FE /* DataSourceFacade+Block.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Block.swift"; sourceTree = "<group>"; }; DB6180DC263918E30018D199 /* MediaPreviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewViewController.swift; sourceTree = "<group>"; }; DB6180DF2639194B0018D199 /* MediaPreviewPagingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewPagingViewController.swift; sourceTree = "<group>"; }; DB6180E226391A4C0018D199 /* ViewControllerAnimatedTransitioning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewControllerAnimatedTransitioning.swift; sourceTree = "<group>"; }; @@ -1138,11 +1039,38 @@ DB6180F526391D580018D199 /* MediaPreviewableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewableViewController.swift; sourceTree = "<group>"; }; DB6180F726391D660018D199 /* MediaPreviewingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewingViewController.swift; sourceTree = "<group>"; }; DB6180F926391F2E0018D199 /* MediaPreviewViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewViewModel.swift; sourceTree = "<group>"; }; - DB63BE7E268DD1070011D3F9 /* NotificationViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationViewController+StatusProvider.swift"; sourceTree = "<group>"; }; + DB63F7442799056400455B82 /* HashtagTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTableViewCell.swift; sourceTree = "<group>"; }; + DB63F74627990B0600455B82 /* DataSourceFacade+Hashtag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Hashtag.swift"; sourceTree = "<group>"; }; + DB63F7482799126300455B82 /* FollowerListViewController+DataSourceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FollowerListViewController+DataSourceProvider.swift"; sourceTree = "<group>"; }; + DB63F74A279914A000455B82 /* FollowingListViewController+DataSourceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FollowingListViewController+DataSourceProvider.swift"; sourceTree = "<group>"; }; + DB63F74C27993F5B00455B82 /* SearchHistoryUserCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistoryUserCollectionViewCell.swift; sourceTree = "<group>"; }; + DB63F74E2799405600455B82 /* SearchHistoryViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchHistoryViewModel+Diffable.swift"; sourceTree = "<group>"; }; + DB63F751279944AA00455B82 /* SearchHistorySectionHeaderCollectionReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistorySectionHeaderCollectionReusableView.swift; sourceTree = "<group>"; }; + DB63F7532799491600455B82 /* DataSourceFacade+SearchHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+SearchHistory.swift"; sourceTree = "<group>"; }; + DB63F755279949BD00455B82 /* Persistence+SearchHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Persistence+SearchHistory.swift"; sourceTree = "<group>"; }; + DB63F759279953F200455B82 /* SearchHistoryUserCollectionViewCell+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchHistoryUserCollectionViewCell+ViewModel.swift"; sourceTree = "<group>"; }; + DB63F75B279956D000455B82 /* Persistence+Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Persistence+Tag.swift"; sourceTree = "<group>"; }; + DB63F75D27995B3B00455B82 /* Tag+Property.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Tag+Property.swift"; sourceTree = "<group>"; }; + DB63F76127996B6600455B82 /* SearchHistoryViewController+DataSourceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchHistoryViewController+DataSourceProvider.swift"; sourceTree = "<group>"; }; + DB63F763279A5E3C00455B82 /* NotificationTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationTimelineViewController.swift; sourceTree = "<group>"; }; + DB63F766279A5EB300455B82 /* NotificationTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationTimelineViewModel.swift; sourceTree = "<group>"; }; + DB63F768279A5EBB00455B82 /* NotificationTimelineViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationTimelineViewModel+Diffable.swift"; sourceTree = "<group>"; }; + DB63F76A279A5ED300455B82 /* NotificationTimelineViewModel+LoadOldestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationTimelineViewModel+LoadOldestState.swift"; sourceTree = "<group>"; }; + DB63F76E279A7D1100455B82 /* NotificationTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationTableViewCell.swift; sourceTree = "<group>"; }; + DB63F770279A858500455B82 /* Persistence+Notification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Persistence+Notification.swift"; sourceTree = "<group>"; }; + DB63F772279A87DC00455B82 /* Notification+Property.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Notification+Property.swift"; sourceTree = "<group>"; }; + DB63F774279A997D00455B82 /* NotificationTableViewCell+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationTableViewCell+ViewModel.swift"; sourceTree = "<group>"; }; + DB63F776279A9A2A00455B82 /* NotificationView+Configuration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationView+Configuration.swift"; sourceTree = "<group>"; }; + DB63F778279ABF9C00455B82 /* DataSourceFacade+Reblog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Reblog.swift"; sourceTree = "<group>"; }; + DB63F77A279ACAE500455B82 /* DataSourceFacade+Favorite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Favorite.swift"; sourceTree = "<group>"; }; DB647C5826F1EA2700F7F82C /* WizardPreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WizardPreference.swift; sourceTree = "<group>"; }; + DB65C63627A2AF6C008BAC2E /* ReportItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportItem.swift; sourceTree = "<group>"; }; DB66728B25F9F8DC00D60309 /* ComposeViewModel+DataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ComposeViewModel+DataSource.swift"; sourceTree = "<group>"; }; DB66729525F9F91600D60309 /* ComposeStatusSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusSection.swift; sourceTree = "<group>"; }; DB66729B25F9F91F00D60309 /* ComposeStatusItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusItem.swift; sourceTree = "<group>"; }; + DB6746EA278ED8B0008A6B94 /* PollOptionView+Configuration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PollOptionView+Configuration.swift"; sourceTree = "<group>"; }; + DB6746EC278F45F0008A6B94 /* AutoGenerateProtocolRelayDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoGenerateProtocolRelayDelegate.swift; sourceTree = "<group>"; }; + DB6746EF278F463B008A6B94 /* AutoGenerateProtocolDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoGenerateProtocolDelegate.swift; sourceTree = "<group>"; }; DB67D08327312970006A36CF /* APIService+Following.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Following.swift"; sourceTree = "<group>"; }; DB67D08527312E67006A36CF /* WizardViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WizardViewController.swift; sourceTree = "<group>"; }; DB67D088273256D7006A36CF /* StoreReviewPreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreReviewPreference.swift; sourceTree = "<group>"; }; @@ -1150,7 +1078,6 @@ 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 = "<group>"; }; DB6804822637CD4C00430867 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; - DB6804912637CD8700430867 /* AppName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppName.swift; sourceTree = "<group>"; }; DB6804D02637CE4700430867 /* UserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaults.swift; sourceTree = "<group>"; }; DB6804FC2637CFEC00430867 /* AppSecret.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSecret.swift; sourceTree = "<group>"; }; DB68053E2638011000430867 /* NotificationService.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NotificationService.entitlements; sourceTree = "<group>"; }; @@ -1158,18 +1085,24 @@ DB68A04925E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AdaptiveStatusBarStyleNavigationController.swift; sourceTree = "<group>"; }; DB68A05C25E9055900CFDF14 /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = "<group>"; }; DB68A06225E905E000CFDF14 /* UIApplication.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIApplication.swift; sourceTree = "<group>"; }; + DB697DD0278F4871004EF2F7 /* AutoGenerateTableViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoGenerateTableViewDelegate.swift; sourceTree = "<group>"; }; + DB697DD3278F4927004EF2F7 /* StatusTableViewCellDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableViewCellDelegate.swift; sourceTree = "<group>"; }; + DB697DD5278F4C29004EF2F7 /* DataSourceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataSourceProvider.swift; sourceTree = "<group>"; }; + DB697DD8278F4CED004EF2F7 /* HomeTimelineViewController+DataSourceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeTimelineViewController+DataSourceProvider.swift"; sourceTree = "<group>"; }; + DB697DDA278F4DE3004EF2F7 /* DataSourceProvider+StatusTableViewCellDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceProvider+StatusTableViewCellDelegate.swift"; sourceTree = "<group>"; }; + DB697DDC278F521D004EF2F7 /* DataSourceFacade.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataSourceFacade.swift; sourceTree = "<group>"; }; + DB697DDE278F524F004EF2F7 /* DataSourceFacade+Profile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Profile.swift"; sourceTree = "<group>"; }; + DB697DE0278F5296004EF2F7 /* DataSourceFacade+Model.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Model.swift"; sourceTree = "<group>"; }; DB6B35172601FA3400DC1E11 /* MastodonAttachmentService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonAttachmentService.swift; sourceTree = "<group>"; }; DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusAttachmentCollectionViewCell.swift; sourceTree = "<group>"; }; DB6B74EE272FB55000C70B6E /* FollowerListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowerListViewController.swift; sourceTree = "<group>"; }; DB6B74F1272FB67600C70B6E /* FollowerListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowerListViewModel.swift; sourceTree = "<group>"; }; DB6B74F3272FBAE700C70B6E /* FollowerListViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FollowerListViewModel+Diffable.swift"; sourceTree = "<group>"; }; DB6B74F5272FBCDB00C70B6E /* FollowerListViewModel+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FollowerListViewModel+State.swift"; sourceTree = "<group>"; }; - DB6B74F7272FBFB100C70B6E /* FollowerListViewController+Provider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FollowerListViewController+Provider.swift"; sourceTree = "<group>"; }; DB6B74F9272FC2B500C70B6E /* APIService+Follower.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Follower.swift"; sourceTree = "<group>"; }; DB6B74FB272FF55800C70B6E /* UserSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSection.swift; sourceTree = "<group>"; }; DB6B74FD272FF59000C70B6E /* UserItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserItem.swift; sourceTree = "<group>"; }; DB6B74FF272FF73800C70B6E /* UserTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserTableViewCell.swift; sourceTree = "<group>"; }; - DB6B75012730060700C70B6E /* UserProviderFacade+UITableViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserProviderFacade+UITableViewDelegate.swift"; sourceTree = "<group>"; }; DB6B750327300B4000C70B6E /* TimelineFooterTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineFooterTableViewCell.swift; sourceTree = "<group>"; }; DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error.swift"; sourceTree = "<group>"; }; DB6D1B3C2636857500ACB481 /* AppearancePreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearancePreference.swift; sourceTree = "<group>"; }; @@ -1188,18 +1121,12 @@ DB6F5E37264E994A009108F4 /* AutoCompleteTopChevronView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCompleteTopChevronView.swift; sourceTree = "<group>"; }; DB71C7CA271D5A0300BE3819 /* LineChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LineChartView.swift; sourceTree = "<group>"; }; DB71C7CC271D7F4300BE3819 /* CurveAlgorithm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurveAlgorithm.swift; sourceTree = "<group>"; }; - DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarStackContainerButton.swift; sourceTree = "<group>"; }; - DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+PersistMemo.swift"; sourceTree = "<group>"; }; - DB71FD3B25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+PersistCache.swift"; sourceTree = "<group>"; }; - DB71FD4525F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+UITableViewDataSourcePrefetching.swift"; sourceTree = "<group>"; }; - DB71FD4B25F8C80E00512AE1 /* StatusPrefetchingService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusPrefetchingService.swift; sourceTree = "<group>"; }; DB71FD5125F8CCAA00512AE1 /* APIService+Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Status.swift"; sourceTree = "<group>"; }; DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewController.swift; sourceTree = "<group>"; }; DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewModel.swift; sourceTree = "<group>"; }; DB7274F3273BB9B200577D95 /* ListBatchFetchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListBatchFetchViewModel.swift; sourceTree = "<group>"; }; DB73B48F261F030A002E9E9F /* SafariActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariActivity.swift; sourceTree = "<group>"; }; DB73BF3A2711885500781945 /* UserDefaults+Notification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserDefaults+Notification.swift"; sourceTree = "<group>"; }; - DB73BF4027118B6D00781945 /* Instance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Instance.swift; sourceTree = "<group>"; }; DB73BF42271192BB00781945 /* InstanceService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceService.swift; sourceTree = "<group>"; }; DB73BF44271195AC00781945 /* APIService+CoreData+Instance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Instance.swift"; sourceTree = "<group>"; }; DB73BF46271199CA00781945 /* Instance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Instance.swift; sourceTree = "<group>"; }; @@ -1210,29 +1137,15 @@ DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewModel.swift; sourceTree = "<group>"; }; DB7F48442620241000796008 /* ProfileHeaderViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderViewModel.swift; sourceTree = "<group>"; }; DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentContainerView.swift; sourceTree = "<group>"; }; + DB8481142788121200BBEABA /* MastodonRegisterTextFieldTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterTextFieldTableViewCell.swift; sourceTree = "<group>"; }; + DB84811627883C2600BBEABA /* MastodonRegisterPasswordHintTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterPasswordHintTableViewCell.swift; sourceTree = "<group>"; }; DB852D1826FAEB6B00FC9D81 /* SidebarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarViewController.swift; sourceTree = "<group>"; }; DB852D1B26FB021500FC9D81 /* RootSplitViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootSplitViewController.swift; sourceTree = "<group>"; }; DB852D1E26FB037800FC9D81 /* SidebarViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarViewModel.swift; sourceTree = "<group>"; }; DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollOptionCollectionViewCell.swift; sourceTree = "<group>"; }; - DB87D44A2609C11900D12C0D /* PollOptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionView.swift; sourceTree = "<group>"; }; DB87D4502609CF1E00D12C0D /* ComposeStatusPollOptionAppendEntryCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollOptionAppendEntryCollectionViewCell.swift; sourceTree = "<group>"; }; - DB87D4562609DD5300D12C0D /* DeleteBackwardResponseTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteBackwardResponseTextField.swift; sourceTree = "<group>"; }; - DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = CoreDataStack.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - DB89B9F025C10FD0008580ED /* CoreDataStack.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CoreDataStack.h; sourceTree = "<group>"; }; - DB89B9F125C10FD0008580ED /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; - DB89B9F625C10FD0008580ED /* CoreDataStackTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CoreDataStackTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - DB89B9FD25C10FD0008580ED /* CoreDataStackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataStackTests.swift; sourceTree = "<group>"; }; - DB89B9FF25C10FD0008580ED /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; + DB894CC327A5490600684B74 /* BlurhashImageCacheService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurhashImageCacheService.swift; sourceTree = "<group>"; }; DB89BA1025C10FF5008580ED /* Mastodon.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Mastodon.entitlements; sourceTree = "<group>"; }; - DB89BA1125C1105C008580ED /* CoreDataStack.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CoreDataStack.swift; sourceTree = "<group>"; }; - DB89BA1825C1107F008580ED /* Collection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Collection.swift; sourceTree = "<group>"; }; - DB89BA1925C1107F008580ED /* NSManagedObjectContext.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSManagedObjectContext.swift; sourceTree = "<group>"; }; - DB89BA1A25C1107F008580ED /* URL.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URL.swift; sourceTree = "<group>"; }; - DB89BA2625C110B4008580ED /* Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Status.swift; sourceTree = "<group>"; }; - DB89BA3625C1145C008580ED /* CoreData.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = CoreData.xcdatamodel; sourceTree = "<group>"; }; - DB89BA4125C1165F008580ED /* NetworkUpdatable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkUpdatable.swift; sourceTree = "<group>"; }; - DB89BA4225C1165F008580ED /* Managed.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Managed.swift; sourceTree = "<group>"; }; - DB8AF52425C131D1002E6C99 /* MastodonUser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonUser.swift; sourceTree = "<group>"; }; DB8AF52B25C13561002E6C99 /* ViewStateStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ViewStateStore.swift; sourceTree = "<group>"; }; DB8AF52C25C13561002E6C99 /* DocumentStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DocumentStore.swift; sourceTree = "<group>"; }; DB8AF52D25C13561002E6C99 /* AppContext.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppContext.swift; sourceTree = "<group>"; }; @@ -1240,7 +1153,7 @@ DB8AF54325C13647002E6C99 /* NeedsDependency.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NeedsDependency.swift; sourceTree = "<group>"; }; DB8AF54F25C13703002E6C99 /* MainTabBarController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MainTabBarController.swift; sourceTree = "<group>"; }; DB8AF55C25C138B7002E6C99 /* UIViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIViewController.swift; sourceTree = "<group>"; }; - DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineIndex.swift; sourceTree = "<group>"; }; + DB8F7075279E954700E1225B /* DataSourceFacade+Follow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Follow.swift"; sourceTree = "<group>"; }; DB8FAB9E26AEC3A2008E5AF4 /* Intents.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Intents.framework; path = System/Library/Frameworks/Intents.framework; sourceTree = SDKROOT; }; DB8FABA926AEC3A2008E5AF4 /* IntentsUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = IntentsUI.framework; path = System/Library/Frameworks/IntentsUI.framework; sourceTree = SDKROOT; }; DB8FABC626AEC7B2008E5AF4 /* MastodonIntent.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = MastodonIntent.appex; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1248,7 +1161,6 @@ DB8FABCB26AEC7B2008E5AF4 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; DB8FABD626AEC864008E5AF4 /* MastodonIntent.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = MastodonIntent.entitlements; sourceTree = "<group>"; }; DB9282B125F3222800823B15 /* PickServerEmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerEmptyStateView.swift; sourceTree = "<group>"; }; - DB92CF7125E7BB98002C1017 /* PollOptionTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionTableViewCell.swift; sourceTree = "<group>"; }; DB938EE52623F50700E5B6C1 /* ThreadViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadViewController.swift; sourceTree = "<group>"; }; DB938EEC2623F79B00E5B6C1 /* ThreadViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadViewModel.swift; sourceTree = "<group>"; }; DB938F0226240EA300E5B6C1 /* CachedThreadViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CachedThreadViewModel.swift; sourceTree = "<group>"; }; @@ -1256,15 +1168,25 @@ DB938F0E2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ThreadViewModel+LoadThreadState.swift"; sourceTree = "<group>"; }; DB938F1426241FDF00E5B6C1 /* APIService+Thread.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Thread.swift"; sourceTree = "<group>"; }; DB938F1E2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ThreadViewModel+Diffable.swift"; sourceTree = "<group>"; }; - DB938F24262438D600E5B6C1 /* ThreadViewController+Provider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ThreadViewController+Provider.swift"; sourceTree = "<group>"; }; DB938F3226243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineTopLoaderTableViewCell.swift; sourceTree = "<group>"; }; - DB97131E2666078B00BD1E90 /* Date.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Date.swift; sourceTree = "<group>"; }; DB98336A25C9420100AD9700 /* APIService+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+App.swift"; sourceTree = "<group>"; }; DB98337025C9443200AD9700 /* APIService+Authentication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Authentication.swift"; sourceTree = "<group>"; }; DB98337E25C9452D00AD9700 /* APIService+APIError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+APIError.swift"; sourceTree = "<group>"; }; - DB98338525C945ED00AD9700 /* Strings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Strings.swift; sourceTree = "<group>"; }; - DB98338625C945ED00AD9700 /* Assets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Assets.swift; sourceTree = "<group>"; }; DB98339B25C96DE600AD9700 /* APIService+Account.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Account.swift"; sourceTree = "<group>"; }; + DB98EB4627B0DFAA0082E365 /* ReportViewModel+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ReportViewModel+State.swift"; sourceTree = "<group>"; }; + DB98EB4827B0F0CD0082E365 /* ReportStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportStatusTableViewCell.swift; sourceTree = "<group>"; }; + DB98EB4B27B0F2BC0082E365 /* ReportStatusTableViewCell+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ReportStatusTableViewCell+ViewModel.swift"; sourceTree = "<group>"; }; + DB98EB5227B0F9890082E365 /* ReportHeadlineTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportHeadlineTableViewCell.swift; sourceTree = "<group>"; }; + DB98EB5527B0FF1B0082E365 /* ReportViewControllerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportViewControllerAppearance.swift; sourceTree = "<group>"; }; + DB98EB5827B109890082E365 /* ReportSupplementaryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportSupplementaryViewController.swift; sourceTree = "<group>"; }; + DB98EB5B27B10A730082E365 /* ReportSupplementaryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportSupplementaryViewModel.swift; sourceTree = "<group>"; }; + DB98EB5D27B10A7A0082E365 /* ReportSupplementaryViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ReportSupplementaryViewModel+Diffable.swift"; sourceTree = "<group>"; }; + DB98EB5F27B10E150082E365 /* ReportCommentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportCommentTableViewCell.swift; sourceTree = "<group>"; }; + DB98EB6127B215EB0082E365 /* ReportResultViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportResultViewController.swift; sourceTree = "<group>"; }; + DB98EB6427B216500082E365 /* ReportResultViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportResultViewModel.swift; sourceTree = "<group>"; }; + DB98EB6627B216560082E365 /* ReportResultViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ReportResultViewModel+Diffable.swift"; sourceTree = "<group>"; }; + DB98EB6827B21A7C0082E365 /* ReportResultActionTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportResultActionTableViewCell.swift; sourceTree = "<group>"; }; + DB98EB6A27B243470082E365 /* SettingsAppearanceTableViewCell+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SettingsAppearanceTableViewCell+ViewModel.swift"; sourceTree = "<group>"; }; DB9A486B26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AttachmentContainerView+EmptyStateView.swift"; sourceTree = "<group>"; }; DB9A488926034D40008B817C /* ComposeViewModel+PublishState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ComposeViewModel+PublishState.swift"; sourceTree = "<group>"; }; DB9A488F26035963008B817C /* APIService+Media.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Media.swift"; sourceTree = "<group>"; }; @@ -1272,10 +1194,6 @@ DB9D6BE825E4F5340051B173 /* SearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewController.swift; sourceTree = "<group>"; }; DB9D6BF725E4F5690051B173 /* NotificationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationViewController.swift; sourceTree = "<group>"; }; DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewController.swift; sourceTree = "<group>"; }; - DB9D6C0D25E4F9780051B173 /* MosaicImageViewContainer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MosaicImageViewContainer.swift; sourceTree = "<group>"; }; - DB9D6C2225E502C60051B173 /* MosaicImageViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MosaicImageViewModel.swift; sourceTree = "<group>"; }; - DB9D6C2D25E504AC0051B173 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = "<group>"; }; - DB9D6C3725E508BE0051B173 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = "<group>"; }; DB9D7C20269824B80054B3DF /* APIService+Filter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Filter.swift"; sourceTree = "<group>"; }; DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIInterpolatingMotionEffect.swift; sourceTree = "<group>"; }; DB9F58EB26EF435000E7BBE9 /* AccountViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountViewController.swift; sourceTree = "<group>"; }; @@ -1283,36 +1201,21 @@ DB9F58F026EF512300E7BBE9 /* AccountListTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountListTableViewCell.swift; sourceTree = "<group>"; }; DBA088DE26958164003EB4B2 /* UserFetchedResultsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserFetchedResultsController.swift; sourceTree = "<group>"; }; DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeToolbarView.swift; sourceTree = "<group>"; }; - DBA1DB7F268F84F80052DB59 /* NotificationType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationType.swift; sourceTree = "<group>"; }; DBA465922696B495002B41DB /* APIService+WebFinger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+WebFinger.swift"; sourceTree = "<group>"; }; DBA465942696E387002B41DB /* AppPreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPreference.swift; sourceTree = "<group>"; }; DBA4B0D326BD10AC0077136E /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Intents.strings"; sourceTree = "<group>"; }; - DBA4B0D426BD10AD0077136E /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.stringsdict"; sourceTree = "<group>"; }; - DBA4B0D526BD10AD0077136E /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = "<group>"; }; DBA4B0D626BD10AD0077136E /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/InfoPlist.strings"; sourceTree = "<group>"; }; DBA4B0D726BD10F40077136E /* ca */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ca; path = ca.lproj/Intents.strings; sourceTree = "<group>"; }; - DBA4B0D826BD10F40077136E /* ca */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ca; path = ca.lproj/Localizable.stringsdict; sourceTree = "<group>"; }; - DBA4B0D926BD10F40077136E /* ca */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ca; path = ca.lproj/Localizable.strings; sourceTree = "<group>"; }; DBA4B0DA26BD10F40077136E /* ca */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ca; path = ca.lproj/InfoPlist.strings; sourceTree = "<group>"; }; DBA4B0DB26BD11130077136E /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Intents.strings; sourceTree = "<group>"; }; - DBA4B0DC26BD11130077136E /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = fr; path = fr.lproj/Localizable.stringsdict; sourceTree = "<group>"; }; - DBA4B0DD26BD11130077136E /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = "<group>"; }; DBA4B0DE26BD11130077136E /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/InfoPlist.strings; sourceTree = "<group>"; }; DBA4B0DF26BD11C70077136E /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Intents.strings; sourceTree = "<group>"; }; - DBA4B0E026BD11C70077136E /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = es; path = es.lproj/Localizable.stringsdict; sourceTree = "<group>"; }; - DBA4B0E126BD11C80077136E /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = "<group>"; }; DBA4B0E226BD11C80077136E /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/InfoPlist.strings; sourceTree = "<group>"; }; DBA4B0E326BD11D10077136E /* es-419 */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "es-419"; path = "es-419.lproj/Intents.strings"; sourceTree = "<group>"; }; - DBA4B0E426BD11D10077136E /* es-419 */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "es-419"; path = "es-419.lproj/Localizable.stringsdict"; sourceTree = "<group>"; }; - DBA4B0E526BD11D10077136E /* es-419 */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "es-419"; path = "es-419.lproj/Localizable.strings"; sourceTree = "<group>"; }; DBA4B0E626BD11D10077136E /* es-419 */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "es-419"; path = "es-419.lproj/InfoPlist.strings"; sourceTree = "<group>"; }; DBA4B0E826C153820077136E /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Intents.strings; sourceTree = "<group>"; }; - DBA4B0E926C153820077136E /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = de; path = de.lproj/Localizable.stringsdict; sourceTree = "<group>"; }; - DBA4B0EA26C153820077136E /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; }; DBA4B0EB26C153820077136E /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/InfoPlist.strings; sourceTree = "<group>"; }; DBA4B0EC26C153B10077136E /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Intents.strings; sourceTree = "<group>"; }; - DBA4B0ED26C153B10077136E /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = nl; path = nl.lproj/Localizable.stringsdict; sourceTree = "<group>"; }; - DBA4B0EE26C153B20077136E /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = "<group>"; }; DBA4B0EF26C153B20077136E /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/InfoPlist.strings; sourceTree = "<group>"; }; DBA4B0F526C2621D0077136E /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Intents.strings; sourceTree = "<group>"; }; DBA4B0F826C269880077136E /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = en; path = en.lproj/Intents.stringsdict; sourceTree = "<group>"; }; @@ -1324,27 +1227,19 @@ DBA5E7AA263BD3F5004598BB /* TimelineTableViewCellContextMenuConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineTableViewCellContextMenuConfiguration.swift; sourceTree = "<group>"; }; DBA94433265CBB5300C537E1 /* ProfileFieldSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldSection.swift; sourceTree = "<group>"; }; DBA94435265CBB7400C537E1 /* ProfileFieldItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldItem.swift; sourceTree = "<group>"; }; - DBA94437265CBD4D00C537E1 /* ProfileHeaderViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileHeaderViewModel+Diffable.swift"; sourceTree = "<group>"; }; - DBA94439265CC0FC00C537E1 /* Fields.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Fields.swift; sourceTree = "<group>"; }; DBA9443D265CFA6400C537E1 /* ProfileFieldCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldCollectionViewCell.swift; sourceTree = "<group>"; }; DBA9443F265D137600C537E1 /* Mastodon+Entity+Field.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Field.swift"; sourceTree = "<group>"; }; DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeIllustrationView.swift; sourceTree = "<group>"; }; - DBAC6484267D0F9E007FE9FD /* StatusNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusNode.swift; sourceTree = "<group>"; }; - DBAC6487267D388B007FE9FD /* ASTableNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ASTableNode.swift; sourceTree = "<group>"; }; - DBAC648E267DC84D007FE9FD /* TableNodeDiffableDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableNodeDiffableDataSource.swift; sourceTree = "<group>"; }; - DBAC6496267DECCB007FE9FD /* TimelineMiddleLoaderNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMiddleLoaderNode.swift; sourceTree = "<group>"; }; - DBAC6498267DF2C4007FE9FD /* TimelineBottomLoaderNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineBottomLoaderNode.swift; sourceTree = "<group>"; }; - DBAC649A267DF8C8007FE9FD /* ActivityIndicatorNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityIndicatorNode.swift; sourceTree = "<group>"; }; - DBAE3F672615DD60004B8251 /* UserProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProvider.swift; sourceTree = "<group>"; }; - DBAE3F812615DDA3004B8251 /* ProfileViewController+UserProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileViewController+UserProvider.swift"; sourceTree = "<group>"; }; - DBAE3F872615DDF4004B8251 /* UserProviderFacade.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProviderFacade.swift; sourceTree = "<group>"; }; DBAE3F8D2616E0B1004B8251 /* APIService+Block.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Block.swift"; sourceTree = "<group>"; }; DBAE3F932616E28B004B8251 /* APIService+Follow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Follow.swift"; sourceTree = "<group>"; }; DBAE3F9D2616E308004B8251 /* APIService+Mute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Mute.swift"; sourceTree = "<group>"; }; DBAE3FAE26172FC0004B8251 /* RemoteProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteProfileViewModel.swift; sourceTree = "<group>"; }; - DBAEDE5B267A058D00D25FF5 /* BlurhashImageCacheService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurhashImageCacheService.swift; sourceTree = "<group>"; }; - DBAFB7342645463500371D5F /* Emojis.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Emojis.swift; sourceTree = "<group>"; }; DBB3BA2926A81C020004F2D4 /* FLAnimatedImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FLAnimatedImageView.swift; sourceTree = "<group>"; }; + DBB45B5527B39FC9002DC5A7 /* MediaPreviewVideoViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewVideoViewController.swift; sourceTree = "<group>"; }; + DBB45B5827B39FE4002DC5A7 /* MediaPreviewVideoViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewVideoViewModel.swift; sourceTree = "<group>"; }; + DBB45B5A27B3A109002DC5A7 /* MediaPreviewTransitionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewTransitionViewController.swift; sourceTree = "<group>"; }; + DBB45B5F27B50A4F002DC5A7 /* RecommendAccountItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecommendAccountItem.swift; sourceTree = "<group>"; }; + DBB45B6127B51112002DC5A7 /* SuggestionAccountViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SuggestionAccountViewModel+Diffable.swift"; sourceTree = "<group>"; }; DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSegmentedViewController.swift; sourceTree = "<group>"; }; DBB525202611EBD6002F1F29 /* ProfilePagingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePagingViewController.swift; sourceTree = "<group>"; }; DBB5252F2611EBF3002F1F29 /* ProfilePagingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePagingViewModel.swift; sourceTree = "<group>"; }; @@ -1361,8 +1256,6 @@ DBB9759B262462E1004620BD /* ThreadMetaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadMetaView.swift; sourceTree = "<group>"; }; DBBC24A726A52F9000398BB9 /* ComposeToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeToolbarView.swift; sourceTree = "<group>"; }; DBBC24AB26A53D9300398BB9 /* ComposeStatusContentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusContentTableViewCell.swift; sourceTree = "<group>"; }; - DBBC24AD26A53DC100398BB9 /* ReplicaStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplicaStatusView.swift; sourceTree = "<group>"; }; - DBBC24B426A540AE00398BB9 /* AvatarConfigurableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarConfigurableView.swift; sourceTree = "<group>"; }; DBBC24BB26A542F500398BB9 /* ThemeService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeService.swift; sourceTree = "<group>"; }; DBBC24BE26A5443100398BB9 /* MastodonTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonTheme.swift; sourceTree = "<group>"; }; DBBC24BF26A5443100398BB9 /* SystemTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemTheme.swift; sourceTree = "<group>"; }; @@ -1370,6 +1263,7 @@ DBBC24CE26A547AE00398BB9 /* ThemeService+Appearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ThemeService+Appearance.swift"; sourceTree = "<group>"; }; DBBC24D626A54BCB00398BB9 /* MastodonRegex.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MastodonRegex.swift; sourceTree = "<group>"; }; DBBC24D826A54BCB00398BB9 /* MastodonMetricFormatter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MastodonMetricFormatter.swift; sourceTree = "<group>"; }; + DBBC50C0278ED49200AF0CC6 /* MastodonAuthenticationBox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonAuthenticationBox.swift; sourceTree = "<group>"; }; DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPickServerAppearance.swift; sourceTree = "<group>"; }; DBBF1DBE2652401B00E5B703 /* AutoCompleteViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCompleteViewModel.swift; sourceTree = "<group>"; }; DBBF1DC126524D2900E5B703 /* AutoCompleteTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCompleteTableViewCell.swift; sourceTree = "<group>"; }; @@ -1385,14 +1279,6 @@ DBC7A671260C897100E57475 /* StatusContentWarningEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentWarningEditorView.swift; sourceTree = "<group>"; }; DBC7A67B260DFADE00E57475 /* StatusPublishService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusPublishService.swift; sourceTree = "<group>"; }; DBCBCBF3267CB070000F5B51 /* Decode85.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Decode85.swift; sourceTree = "<group>"; }; - DBCBCBFB2680ADB7000F5B51 /* AsyncHomeTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncHomeTimelineViewController.swift; sourceTree = "<group>"; }; - DBCBCBFE2680AE98000F5B51 /* AsyncHomeTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncHomeTimelineViewModel.swift; sourceTree = "<group>"; }; - DBCBCC002680AF2A000F5B51 /* AsyncHomeTimelineViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AsyncHomeTimelineViewModel+Diffable.swift"; sourceTree = "<group>"; }; - DBCBCC022680AF6E000F5B51 /* AsyncHomeTimelineViewController+DebugAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AsyncHomeTimelineViewController+DebugAction.swift"; sourceTree = "<group>"; }; - DBCBCC042680AFB9000F5B51 /* AsyncHomeTimelineViewController+Provider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AsyncHomeTimelineViewController+Provider.swift"; sourceTree = "<group>"; }; - DBCBCC062680AFEC000F5B51 /* AsyncHomeTimelineViewModel+LoadLatestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AsyncHomeTimelineViewModel+LoadLatestState.swift"; sourceTree = "<group>"; }; - DBCBCC082680B01B000F5B51 /* AsyncHomeTimelineViewModel+LoadMiddleState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AsyncHomeTimelineViewModel+LoadMiddleState.swift"; sourceTree = "<group>"; }; - DBCBCC0A2680B03F000F5B51 /* AsyncHomeTimelineViewModel+LoadOldestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AsyncHomeTimelineViewModel+LoadOldestState.swift"; sourceTree = "<group>"; }; DBCBCC0C2680B908000F5B51 /* HomeTimelinePreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelinePreference.swift; sourceTree = "<group>"; }; DBCBED1626132DB500B49291 /* UserTimelineViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserTimelineViewModel+Diffable.swift"; sourceTree = "<group>"; }; DBCBED1C26132E1A00B49291 /* StatusFetchedResultsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusFetchedResultsController.swift; sourceTree = "<group>"; }; @@ -1400,16 +1286,13 @@ DBCC3B35261440BA0045B23D /* UINavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UINavigationController.swift; sourceTree = "<group>"; }; DBCC3B8E26148F7B0045B23D /* CachedProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CachedProfileViewModel.swift; sourceTree = "<group>"; }; DBCC3B9426157E6E0045B23D /* APIService+Relationship.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Relationship.swift"; sourceTree = "<group>"; }; - DBCC3B9A2615849F0045B23D /* PrivateNote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateNote.swift; sourceTree = "<group>"; }; DBCCC71D25F73297007E1AB6 /* APIService+Reblog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Reblog.swift"; sourceTree = "<group>"; }; DBD376AB2692ECDB007FEC24 /* ThemePreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemePreference.swift; sourceTree = "<group>"; }; DBD376B1269302A4007FEC24 /* UITableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITableViewCell.swift; sourceTree = "<group>"; }; + DBD5B1F527BCD3D200BD6B38 /* SuggestionAccountTableViewCell+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SuggestionAccountTableViewCell+ViewModel.swift"; sourceTree = "<group>"; }; + DBD5B1F727BCFD9D00BD6B38 /* DataSourceProvider+TableViewControllerNavigateable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceProvider+TableViewControllerNavigateable.swift"; sourceTree = "<group>"; }; + DBD5B1F927BD013700BD6B38 /* DataSourceProvider+StatusTableViewControllerNavigateable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceProvider+StatusTableViewControllerNavigateable.swift"; sourceTree = "<group>"; }; DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Onboarding.swift"; sourceTree = "<group>"; }; - DBDC1CF9272C0FD600055C3D /* ku-TR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "ku-TR"; path = "ku-TR.lproj/Intents.strings"; sourceTree = "<group>"; }; - DBDC1CFA272C0FD600055C3D /* ku-TR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "ku-TR"; path = "ku-TR.lproj/Localizable.stringsdict"; sourceTree = "<group>"; }; - DBDC1CFB272C0FD600055C3D /* ku-TR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "ku-TR"; path = "ku-TR.lproj/Localizable.strings"; sourceTree = "<group>"; }; - DBDC1CFC272C0FD600055C3D /* ku-TR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "ku-TR"; path = "ku-TR.lproj/InfoPlist.strings"; sourceTree = "<group>"; }; - DBDC1CFD272C0FD600055C3D /* ku-TR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "ku-TR"; path = "ku-TR.lproj/Intents.stringsdict"; sourceTree = "<group>"; }; DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewController.swift; sourceTree = "<group>"; }; DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewModel.swift; sourceTree = "<group>"; }; DBE3CDBA261C427900430CC6 /* TimelineHeaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineHeaderTableViewCell.swift; sourceTree = "<group>"; }; @@ -1418,10 +1301,10 @@ DBE3CDFA261C6CA500430CC6 /* FavoriteViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteViewModel.swift; sourceTree = "<group>"; }; DBE3CE00261D623D00430CC6 /* FavoriteViewModel+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FavoriteViewModel+State.swift"; sourceTree = "<group>"; }; DBE3CE06261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FavoriteViewModel+Diffable.swift"; sourceTree = "<group>"; }; - DBE3CE0C261D767100430CC6 /* FavoriteViewController+Provider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FavoriteViewController+Provider.swift"; sourceTree = "<group>"; }; - DBE3CE12261D7D4200430CC6 /* StatusTableViewControllerAspect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableViewControllerAspect.swift; sourceTree = "<group>"; }; DBE54AC52636C89F004E7C0B /* NotificationPreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPreference.swift; sourceTree = "<group>"; }; - DBF156DD27006F5D00EC00B7 /* CoreData 2.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "CoreData 2.xcdatamodel"; sourceTree = "<group>"; }; + DBEB19E927E4F37B00B0E80E /* ku */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ku; path = ku.lproj/Intents.strings; sourceTree = "<group>"; }; + DBEB19EA27E4F37B00B0E80E /* ku */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ku; path = ku.lproj/InfoPlist.strings; sourceTree = "<group>"; }; + DBEB19EB27E4F37B00B0E80E /* ku */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ku; path = ku.lproj/Intents.stringsdict; sourceTree = "<group>"; }; DBF156DE2701B17600EC00B7 /* SidebarAddAccountCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarAddAccountCollectionViewCell.swift; sourceTree = "<group>"; }; DBF156E02702DA6800EC00B7 /* Mastodon-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Mastodon-Bridging-Header.h"; sourceTree = "<group>"; }; DBF156E12702DA6900EC00B7 /* UIStatusBarManager+HandleTapAction.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "UIStatusBarManager+HandleTapAction.m"; sourceTree = "<group>"; }; @@ -1440,6 +1323,10 @@ DBF96325262EC0A6001D8D25 /* AuthenticationServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AuthenticationServices.framework; path = System/Library/Frameworks/AuthenticationServices.framework; sourceTree = SDKROOT; }; DBF98149265E24F500E4BA07 /* ProfileFieldCollectionViewHeaderFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldCollectionViewHeaderFooterView.swift; sourceTree = "<group>"; }; DBF9814B265E339500E4BA07 /* ProfileFieldAddEntryCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldAddEntryCollectionViewCell.swift; sourceTree = "<group>"; }; + DBFEEC95279BDC67004F81DD /* ProfileAboutViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileAboutViewController.swift; sourceTree = "<group>"; }; + DBFEEC98279BDCDE004F81DD /* ProfileAboutViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileAboutViewModel.swift; sourceTree = "<group>"; }; + DBFEEC9A279BDDD9004F81DD /* ProfileAboutViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileAboutViewModel+Diffable.swift"; sourceTree = "<group>"; }; + DBFEEC9C279C12C1004F81DD /* ProfileFieldEditCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldEditCollectionViewCell.swift; sourceTree = "<group>"; }; DBFEF05526A576EE006D7ED1 /* StatusEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusEditorView.swift; sourceTree = "<group>"; }; DBFEF05626A576EE006D7ED1 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = "<group>"; }; DBFEF05726A576EE006D7ED1 /* ComposeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewModel.swift; sourceTree = "<group>"; }; @@ -1450,18 +1337,16 @@ DBFEF06726A58D07006D7ED1 /* ShareActionExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareActionExtension.entitlements; sourceTree = "<group>"; }; DBFEF06C26A67FB7006D7ED1 /* StatusAttachmentViewModel+UploadState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusAttachmentViewModel+UploadState.swift"; sourceTree = "<group>"; }; DBFEF07226A6913D006D7ED1 /* APIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIService.swift; sourceTree = "<group>"; }; - DBFEF07626A691FB006D7ED1 /* MastodonAuthenticationBox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonAuthenticationBox.swift; sourceTree = "<group>"; }; DBFEF07A26A6BCE8006D7ED1 /* APIService+Status+Publish.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Status+Publish.swift"; sourceTree = "<group>"; }; DDB1B139FA8EA26F510D58B6 /* Pods-AppShared.asdk - release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AppShared.asdk - release.xcconfig"; path = "Target Support Files/Pods-AppShared/Pods-AppShared.asdk - release.xcconfig"; sourceTree = "<group>"; }; E5C7236E58D14A0322FE00F2 /* Pods-Mastodon-MastodonUITests.asdk - debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.asdk - debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.asdk - debug.xcconfig"; sourceTree = "<group>"; }; E9AABD3D26B64B8C00E237DA /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Intents.strings; sourceTree = "<group>"; }; - E9AABD3E26B64B8D00E237DA /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ja; path = ja.lproj/Localizable.stringsdict; sourceTree = "<group>"; }; - E9AABD3F26B64B8D00E237DA /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = "<group>"; }; E9AABD4026B64B8D00E237DA /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/InfoPlist.strings; sourceTree = "<group>"; }; EC6E707B68A67DB08EC288FA /* Pods-MastodonTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonTests.debug.xcconfig"; path = "Target Support Files/Pods-MastodonTests/Pods-MastodonTests.debug.xcconfig"; sourceTree = "<group>"; }; ECA373ABA86BE3C2D7ED878E /* Pods-AppShared.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AppShared.release.xcconfig"; path = "Target Support Files/Pods-AppShared/Pods-AppShared.release.xcconfig"; sourceTree = "<group>"; }; EE13214BC0246BE5210CCC10 /* Pods-AppShared.asdk.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AppShared.asdk.xcconfig"; path = "Target Support Files/Pods-AppShared/Pods-AppShared.asdk.xcconfig"; sourceTree = "<group>"; }; F31E7502A7E3945B98C6CBAF /* Pods-NotificationService.asdk.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NotificationService.asdk.xcconfig"; path = "Target Support Files/Pods-NotificationService/Pods-NotificationService.asdk.xcconfig"; sourceTree = "<group>"; }; + F43DF6E8AB8C87914A64FC48 /* Pods-ShareActionExtension.release snapshot.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareActionExtension.release snapshot.xcconfig"; path = "Target Support Files/Pods-ShareActionExtension/Pods-ShareActionExtension.release snapshot.xcconfig"; sourceTree = "<group>"; }; F4A2A2D7000E477CA459ADA9 /* Pods_AppShared.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_AppShared.framework; sourceTree = BUILT_PRODUCTS_DIR; }; F4C94BD75C96D0EFF5F6D961 /* Pods_MastodonIntent.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MastodonIntent.framework; sourceTree = BUILT_PRODUCTS_DIR; }; F920AD4EC23B0D00F5CCA58E /* Pods-MastodonIntent.asdk - release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonIntent.asdk - release.xcconfig"; path = "Target Support Files/Pods-MastodonIntent/Pods-MastodonIntent.asdk - release.xcconfig"; sourceTree = "<group>"; }; @@ -1473,8 +1358,6 @@ buildActionMask = 2147483647; files = ( DB0140BD25C40D7500F9F3CF /* CommonOSLog in Frameworks */, - DB89BA0325C10FD0008580ED /* CoreDataStack.framework in Frameworks */, - DBC6462B26A1738900B0E31B /* MastodonUI in Frameworks */, DB9A487E2603456B008B817C /* UITextView+Placeholder in Frameworks */, 2D939AC825EE14620076FA61 /* CropViewController in Frameworks */, DBB525082611EAC0002F1F29 /* Tabman in Frameworks */, @@ -1522,31 +1405,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - DB89B9EB25C10FD0008580ED /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - DB6805262637D7DD00430867 /* AppShared.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - DB89B9F325C10FD0008580ED /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - DB89B9F725C10FD0008580ED /* CoreDataStack.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; DB8FABC326AEC7B2008E5AF4 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( DB8FABC726AEC7B2008E5AF4 /* Intents.framework in Frameworks */, - DB8FABDC26AEC87B008E5AF4 /* CoreDataStack.framework in Frameworks */, DBB8AB4826AED09C00F6D281 /* MastodonSDK in Frameworks */, - DB8FABD726AEC873008E5AF4 /* AppShared.framework in Frameworks */, BBAC710E327AF1EE1DB36A4E /* Pods_MastodonIntent.framework in Frameworks */, + DBE3CA6E27A39CB300AFE27B /* AppShared.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1555,12 +1421,10 @@ buildActionMask = 2147483647; files = ( DBBC24B826A5421800398BB9 /* CommonOSLog in Frameworks */, - DBC6462526A1720B00B0E31B /* MastodonUI in Frameworks */, - DBC6463726A195DB00B0E31B /* CoreDataStack.framework in Frameworks */, DBBC24D126A5484F00398BB9 /* UITextView+Placeholder in Frameworks */, DBBC24AA26A5301B00398BB9 /* MastodonSDK in Frameworks */, + DBE3CA6B27A39CAF00AFE27B /* AppShared.framework in Frameworks */, DB0C946526A6FD4D0088FB11 /* AlamofireImage in Frameworks */, - DBC6463326A195DB00B0E31B /* AppShared.framework in Frameworks */, 4278334D6033AEEE0A1C5155 /* Pods_ShareActionExtension.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1569,10 +1433,10 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - DBBC24CD26A5471E00398BB9 /* MastodonExtension in Frameworks */, DB00CA972632DDB600A54956 /* CommonOSLog in Frameworks */, + DB179267278D5A4A00B71DEB /* MastodonSDK in Frameworks */, DB6D9F42263527CE008423CD /* AlamofireImage in Frameworks */, - DB6804A52637CDCC00430867 /* AppShared.framework in Frameworks */, + DBE3CA6827A39CAB00AFE27B /* AppShared.framework in Frameworks */, B914FC6B0B8AF18573C0B291 /* Pods_NotificationService.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1584,12 +1448,10 @@ isa = PBXGroup; children = ( 0F2021FA2613262F000C64BF /* HashtagTimelineViewController.swift */, - 0F202226261411BA000C64BF /* HashtagTimelineViewController+Provider.swift */, + DB0FCB9B27980AB6006C02E2 /* HashtagTimelineViewController+DataSourceProvider.swift */, 0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */, 0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */, - 0F20220C26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift */, 0F20222C261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift */, - 0F20223226145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift */, ); path = HashtagTimeline; sourceTree = "<group>"; @@ -1622,9 +1484,6 @@ 0FB3D2FC25E4CB4B00AAD544 /* TableViewCell */ = { isa = PBXGroup; children = ( - 0FB3D2FD25E4CB6400AAD544 /* PickServerTitleCell.swift */, - 0FB3D30725E524C600AAD544 /* PickServerCategoriesCell.swift */, - 0FB3D33125E5F50E00AAD544 /* PickServerSearchCell.swift */, 0FB3D33725E6401400AAD544 /* PickServerCell.swift */, DB0F814F264D1E2500F2A12B /* PickServerLoaderTableViewCell.swift */, ); @@ -1636,6 +1495,7 @@ children = ( 0FB3D30E25E525CD00AAD544 /* PickServerCategoryView.swift */, DB9282B125F3222800823B15 /* PickServerEmptyStateView.swift */, + DB0617F0278413D00030EE79 /* PickServerServerSectionTableHeaderView.swift */, ); path = View; sourceTree = "<group>"; @@ -1688,6 +1548,13 @@ C3789232A52F43529CA67E95 /* Pods-MastodonIntent.asdk - debug.xcconfig */, F920AD4EC23B0D00F5CCA58E /* Pods-MastodonIntent.asdk - release.xcconfig */, 159AC43EFE0A1F95FCB358A4 /* Pods-MastodonIntent.release.xcconfig */, + 3E08A432F40BA7B9CAA9DB68 /* Pods-AppShared.release snapshot.xcconfig */, + 0655B257371274BEB7EB1C19 /* Pods-Mastodon.release snapshot.xcconfig */, + 0827D1674B2523503E8605F6 /* Pods-Mastodon-MastodonUITests.release snapshot.xcconfig */, + 2C12EB4B3699D5D597027962 /* Pods-MastodonIntent.release snapshot.xcconfig */, + 8E79CCBE51FBC3F7FE8CF49F /* Pods-MastodonTests.release snapshot.xcconfig */, + 8ADD558BE5B8255E5764A54F /* Pods-NotificationService.release snapshot.xcconfig */, + F43DF6E8AB8C87914A64FC48 /* Pods-ShareActionExtension.release snapshot.xcconfig */, ); path = Pods; sourceTree = "<group>"; @@ -1695,10 +1562,13 @@ 2D152A8A25C295B8009AA50C /* Content */ = { isa = PBXGroup; children = ( - 2D152A8B25C295CC009AA50C /* StatusView.swift */, + DB336F40278E68480031E64B /* StatusView+Configuration.swift */, + DB336F42278EB1680031E64B /* MediaView+Configuration.swift */, + DB6746EA278ED8B0008A6B94 /* PollOptionView+Configuration.swift */, + DB0FCB992797F7AD006C02E2 /* UserView+Configuration.swift */, + DB63F776279A9A2A00455B82 /* NotificationView+Configuration.swift */, 2D694A7325F9EB4E0038ADDC /* ContentWarningOverlayView.swift */, 2D571B2E26004EC000540450 /* NavigationBarProgressView.swift */, - DB87D44A2609C11900D12C0D /* PollOptionView.swift */, DBE3CDCE261C42ED00430CC6 /* TimelineHeaderView.swift */, 0F1E2D0A2615C39400C38565 /* DoubleTitleLabelNavigationBarTitleView.swift */, DBB9759B262462E1004620BD /* ThreadMetaView.swift */, @@ -1706,21 +1576,23 @@ path = Content; sourceTree = "<group>"; }; - 2D34D9E026149C550081BFC0 /* CollectionViewCell */ = { + 2D34D9E026149C550081BFC0 /* Cell */ = { isa = PBXGroup; children = ( - 2D34D9E126149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift */, - 2DE0FAC72615F5F000CDF649 /* SearchRecommendAccountsCollectionViewCell.swift */, + DB0FCB8D2796C0B7006C02E2 /* TrendCollectionViewCell.swift */, + DB0FCB912796DE19006C02E2 /* TrendSectionHeaderCollectionReusableView.swift */, ); - path = CollectionViewCell; + path = Cell; sourceTree = "<group>"; }; - 2D35237F26256F470031AF25 /* TableViewCell */ = { + 2D35237F26256F470031AF25 /* Cell */ = { isa = PBXGroup; children = ( - 2D24E11C2626D8B100A59D4F /* NotificationStatusTableViewCell.swift */, + DB023D2727A0FABD005AC798 /* NotificationTableViewCellDelegate.swift */, + DB63F76E279A7D1100455B82 /* NotificationTableViewCell.swift */, + DB63F774279A997D00455B82 /* NotificationTableViewCell+ViewModel.swift */, ); - path = TableViewCell; + path = Cell; sourceTree = "<group>"; }; 2D364F7025E66D5B00204FDC /* ResendEmail */ = { @@ -1737,49 +1609,20 @@ isa = PBXGroup; children = ( DB1F239626117C360057430E /* View */, - DBCBCBFD2680ADBA000F5B51 /* AsyncHomeTimeline */, 2D38F1D425CD465300561493 /* HomeTimelineViewController.swift */, - 2D38F1DE25CD46A400561493 /* HomeTimelineViewController+Provider.swift */, + DB697DD8278F4CED004EF2F7 /* HomeTimelineViewController+DataSourceProvider.swift */, 2D5A3D6125CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift */, 2D38F1E425CD46C100561493 /* HomeTimelineViewModel.swift */, 2D5A3D2725CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift */, 2D38F1EA25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift */, - 2D38F1F025CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift */, 2D38F1F625CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift */, ); path = HomeTimeline; sourceTree = "<group>"; }; - 2D38F1FC25CD47D900561493 /* StatusProvider */ = { - isa = PBXGroup; - children = ( - 2D38F1FD25CD481700561493 /* StatusProvider.swift */, - 2DF75B9A25D0E27500694EC8 /* StatusProviderFacade.swift */, - 2DF75BA025D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift */, - DB1EE7B1267F9525000CC337 /* StatusProvider+StatusNodeDelegate.swift */, - DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift */, - DB71FD4525F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift */, - DB1D843526579DB5000346B3 /* StatusProvider+TableViewControllerNavigateable.swift */, - DB1D842B26551A1C000346B3 /* StatusProvider+StatusTableViewKeyCommandNavigateable.swift */, - ); - path = StatusProvider; - sourceTree = "<group>"; - }; - 2D42FF7C25C82207004A627A /* ToolBar */ = { - isa = PBXGroup; - children = ( - 2D42FF7D25C82218004A627A /* ActionToolBarContainer.swift */, - ); - path = ToolBar; - sourceTree = "<group>"; - }; 2D42FF8325C82245004A627A /* Button */ = { isa = PBXGroup; children = ( - DB0C947126A7D2D70088FB11 /* AvatarButton.swift */, - DB4932B226F2054200EF46D4 /* CircleAvatarButton.swift */, - DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */, - 2D42FF8425C8224F004A627A /* HitTestExpandedButton.swift */, 0FAA101125E105390017CCDE /* PrimaryActionButton.swift */, ); path = Button; @@ -1811,7 +1654,6 @@ DB51D171262832380062B7A1 /* BlurHashEncode.swift */, DB6180EC26391C6C0018D199 /* TransitioningMath.swift */, DB75BF1D263C1C1B00EDBF1F /* CustomScheduler.swift */, - DBAC649A267DF8C8007FE9FD /* ActivityIndicatorNode.swift */, DBF156E32702DB3F00EC00B7 /* HandleTapAction.swift */, DBF156E12702DA6900EC00B7 /* UIStatusBarManager+HandleTapAction.m */, DBF156E02702DA6800EC00B7 /* Mastodon-Bridging-Header.h */, @@ -1827,46 +1669,28 @@ DB9A489B26036E19008B817C /* MastodonAttachmentService */, DBBC24BD26A5441A00398BB9 /* ThemeService */, DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */, - 2D206B8B25F6015000143C56 /* AudioPlaybackService.swift */, 2DA6054625F716A2006356F9 /* PlaybackState.swift */, - 5DF1054025F886D400D6C0D4 /* VideoPlaybackService.swift */, - DB71FD4B25F8C80E00512AE1 /* StatusPrefetchingService.swift */, DBC7A67B260DFADE00E57475 /* StatusPublishService.swift */, 2D9DB966263A76FB007C1D71 /* BlockDomainService.swift */, DB4924E126312AB200E9DB22 /* NotificationService.swift */, DB6D9F6226357848008423CD /* SettingService.swift */, DBA5E7A2263AD0A3004598BB /* PhotoLibraryService.swift */, DB297B1A2679FAE200704C90 /* PlaceholderImageCacheService.swift */, - DBAEDE5B267A058D00D25FF5 /* BlurhashImageCacheService.swift */, DB564BD2269F3B35001E39A7 /* StatusFilterService.swift */, DB73BF42271192BB00781945 /* InstanceService.swift */, + DB894CC327A5490600684B74 /* BlurhashImageCacheService.swift */, ); path = Service; sourceTree = "<group>"; }; - 2D61335625C1887F00CAE157 /* Persist */ = { - isa = PBXGroup; - children = ( - 2D61335725C188A000CAE157 /* APIService+Persist+Status.swift */, - DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */, - DB71FD3B25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift */, - ); - path = Persist; - sourceTree = "<group>"; - }; 2D69CFF225CA9E2200C3A1B2 /* Protocol */ = { isa = PBXGroup; children = ( - 2D38F1FC25CD47D900561493 /* StatusProvider */, - DBAE3F742615DD63004B8251 /* UserProvider */, - DBBC24B426A540AE00398BB9 /* AvatarConfigurableView.swift */, - 2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */, + DB697DD7278F4C34004EF2F7 /* Provider */, + DB0FCB7127952986006C02E2 /* NamingState.swift */, 2D5A3D3725CF8D9F002347D6 /* ScrollViewContainer.swift */, - DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */, - 2D38F1C525CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift */, + DB4AA6B227BA34B6009EC082 /* CellFrameCacheContainer.swift */, 2D38F20725CD491300561493 /* DisposeBagCollectable.swift */, - 5DF1058425F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift */, - DBE3CE12261D7D4200430CC6 /* StatusTableViewControllerAspect.swift */, DB1D84372657B275000346B3 /* SegmentedControlNavigateable.swift */, DB1D843326579931000346B3 /* TableViewControllerNavigateable.swift */, DB1D842D26552C4D000346B3 /* StatusTableViewControllerNavigateable.swift */, @@ -1874,45 +1698,25 @@ path = Protocol; sourceTree = "<group>"; }; - 2D76316325C14BAC00929FB9 /* PublicTimeline */ = { - isa = PBXGroup; - children = ( - 2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */, - 2D76317C25C14DF400929FB9 /* PublicTimelineViewController+Provider.swift */, - 2D76316A25C14D4C00929FB9 /* PublicTimelineViewModel.swift */, - 2D32EAD925CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift */, - 2D76318225C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift */, - 2D45E5BE25C9549700A6D639 /* PublicTimelineViewModel+State.swift */, - ); - path = PublicTimeline; - sourceTree = "<group>"; - }; 2D76319C25C151DE00929FB9 /* Diffiable */ = { isa = PBXGroup; children = ( - 2D76319D25C151F600929FB9 /* Section */, - 2D7631B125C159E700929FB9 /* Item */, + DB4F097826A039B400D62E92 /* Onboarding */, + DB0617FB27855B740030EE79 /* Account */, + DB0617F827855B170030EE79 /* User */, + DB0617F927855B460030EE79 /* Profile */, + DB0FCB892796BE1E006C02E2 /* RecommandAccount */, + DB4F097926A039C400D62E92 /* Status */, + DB65C63527A2AF52008BAC2E /* Report */, + DB4F097626A0398000D62E92 /* Compose */, + DB0617F727855B010030EE79 /* Notification */, + DB4F097726A039A200D62E92 /* Search */, + DB0617FA27855B660030EE79 /* Settings */, DBCBED2226132E1D00B49291 /* FetchedResultsController */, - DBAC6490267DC84F007FE9FD /* DataSource */, ); path = Diffiable; sourceTree = "<group>"; }; - 2D76319D25C151F600929FB9 /* Section */ = { - isa = PBXGroup; - children = ( - DB4F097926A039C400D62E92 /* Status */, - DB4F097826A039B400D62E92 /* Onboarding */, - DB4F097726A039A200D62E92 /* Search */, - DB4F097626A0398000D62E92 /* Compose */, - 2D4AD8A126316CD200613EFC /* SelectedAccountSection.swift */, - DB6D9F7C26358ED4008423CD /* SettingsSection.swift */, - DBA94433265CBB5300C537E1 /* ProfileFieldSection.swift */, - DB6B74FB272FF55800C70B6E /* UserSection.swift */, - ); - path = Section; - sourceTree = "<group>"; - }; 2D7631A425C1532200929FB9 /* Share */ = { isa = PBXGroup; children = ( @@ -1930,15 +1734,10 @@ children = ( 2DA504672601ADBA008F4E6C /* Decoration */, 2D42FF8325C82245004A627A /* Button */, - 2D42FF7C25C82207004A627A /* ToolBar */, - DB9D6C1325E4F97A0051B173 /* Container */, DBA9B90325F1D4420012E7B6 /* Control */, 2D152A8A25C295B8009AA50C /* Content */, - DB0C947026A7D2AB0088FB11 /* ImageView */, - DB87D45C2609DE6600D12C0D /* TextField */, DB1D187125EF5BBD003F1F23 /* TableView */, 2D7631A625C1533800929FB9 /* TableviewCell */, - DBAC6486267D0FAC007FE9FD /* Node */, ); path = View; sourceTree = "<group>"; @@ -1946,43 +1745,25 @@ 2D7631A625C1533800929FB9 /* TableviewCell */ = { isa = PBXGroup; children = ( + DB697DD3278F4927004EF2F7 /* StatusTableViewCellDelegate.swift */, 2D7631A725C1535600929FB9 /* StatusTableViewCell.swift */, + DB336F3E278E668C0031E64B /* StatusTableViewCell+ViewModel.swift */, + DB0FCB7B2795821F006C02E2 /* StatusThreadRootTableViewCell.swift */, + DB0FCB7D27958957006C02E2 /* StatusThreadRootTableViewCell+ViewModel.swift */, + DB6B74FF272FF73800C70B6E /* UserTableViewCell.swift */, + DB0FCB972797F6BF006C02E2 /* UserTableViewCell+ViewModel.swift */, 2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */, DB938F3226243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift */, 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */, 2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */, + DB0FCB6F27951368006C02E2 /* TimelineMiddleLoaderTableViewCell+ViewModel.swift */, DBE3CDBA261C427900430CC6 /* TimelineHeaderTableViewCell.swift */, DB6B750327300B4000C70B6E /* TimelineFooterTableViewCell.swift */, DB02CDAA26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift */, - DB92CF7125E7BB98002C1017 /* PollOptionTableViewCell.swift */, - DB6B74FF272FF73800C70B6E /* UserTableViewCell.swift */, ); path = TableviewCell; sourceTree = "<group>"; }; - 2D7631B125C159E700929FB9 /* Item */ = { - isa = PBXGroup; - children = ( - 2D7631B225C159F700929FB9 /* Item.swift */, - DB6B74FD272FF59000C70B6E /* UserItem.swift */, - 2D198642261BF09500F0B013 /* SearchResultItem.swift */, - DB4F097C26A03A5B00D62E92 /* SearchHistoryItem.swift */, - 2D4AD8A726316D3500613EFC /* SelectedAccountItem.swift */, - 2D7867182625B77500211898 /* NotificationItem.swift */, - DB4481CB25EE2AFE00BEFB67 /* PollItem.swift */, - DB1E347725F519300079D7DF /* PickServerItem.swift */, - DB1FD45925F27898004CFCFC /* CategoryPickerItem.swift */, - DB66729B25F9F91F00D60309 /* ComposeStatusItem.swift */, - DB3667A0268ABB2E0027D07F /* ComposeStatusAttachmentItem.swift */, - DB3667A7268AE2900027D07F /* ComposeStatusPollItem.swift */, - DB44768A260B3F2100B66B82 /* CustomEmojiPickerItem.swift */, - DB6D9F8326358EEC008423CD /* SettingsItem.swift */, - DBBF1DCA2652539E00E5B703 /* AutoCompleteItem.swift */, - DBA94435265CBB7400C537E1 /* ProfileFieldItem.swift */, - ); - path = Item; - sourceTree = "<group>"; - }; 2DA504672601ADBA008F4E6C /* Decoration */ = { isa = PBXGroup; children = ( @@ -1996,6 +1777,7 @@ children = ( 2DAC9E37262FC2320062E1A6 /* SuggestionAccountViewController.swift */, 2DAC9E3D262FC2400062E1A6 /* SuggestionAccountViewModel.swift */, + DBB45B6127B51112002DC5A7 /* SuggestionAccountViewModel+Diffable.swift */, 2D4AD89A2631659400613EFC /* CollectionViewCell */, 2DAC9E43262FC9DE0062E1A6 /* TableViewCell */, ); @@ -2006,6 +1788,7 @@ isa = PBXGroup; children = ( 2DAC9E45262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift */, + DBD5B1F527BCD3D200BD6B38 /* SuggestionAccountTableViewCell+ViewModel.swift */, ); path = TableViewCell; sourceTree = "<group>"; @@ -2019,21 +1802,12 @@ path = View; sourceTree = "<group>"; }; - 2DF75BB725D1473400694EC8 /* Stack */ = { + 2DFAD5212616F8E300F9EE7C /* Cell */ = { isa = PBXGroup; children = ( - 2DF75BB825D1474100694EC8 /* ManagedObjectObserver.swift */, - 2DF75BC625D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift */, + DB63F7442799056400455B82 /* HashtagTableViewCell.swift */, ); - path = Stack; - sourceTree = "<group>"; - }; - 2DFAD5212616F8E300F9EE7C /* TableViewCell */ = { - isa = PBXGroup; - children = ( - 2DFAD5362617010500F9EE7C /* SearchResultTableViewCell.swift */, - ); - path = TableViewCell; + path = Cell; sourceTree = "<group>"; }; 3FE14AD363ED19AE7FF210A6 /* Frameworks */ = { @@ -2056,13 +1830,10 @@ 5B24BBD6262DB14800A9381B /* Report */ = { isa = PBXGroup; children = ( - 5B24BBD7262DB14800A9381B /* ReportViewModel.swift */, - 5B24BBD8262DB14800A9381B /* ReportViewModel+Diffable.swift */, - 5BB04FEE262F0DCB0043BFF6 /* ReportViewModel+Data.swift */, - 5BB04FD4262E7AFF0043BFF6 /* ReportViewController.swift */, - 5BB04FDA262EA3070043BFF6 /* ReportHeaderView.swift */, - 5B8E055726319E47006E3C53 /* ReportFooterView.swift */, - 5BB04FE8262EFC300043BFF6 /* ReportedStatusTableviewCell.swift */, + DB98EB5727B0FF1F0082E365 /* Share */, + DB98EB4F27B0F9300082E365 /* ReportStatus */, + DB98EB5A27B109900082E365 /* ReportSupplementary */, + DB98EB6327B216490082E365 /* ReportResult */, ); path = Report; sourceTree = "<group>"; @@ -2070,6 +1841,7 @@ 5B90C455262599800002E742 /* Settings */ = { isa = PBXGroup; children = ( + 5B90C458262599800002E742 /* Cell */, 5B90C457262599800002E742 /* View */, DB6D9F9626367249008423CD /* SettingsViewController.swift */, 5B90C456262599800002E742 /* SettingsViewModel.swift */, @@ -2080,7 +1852,6 @@ 5B90C457262599800002E742 /* View */ = { isa = PBXGroup; children = ( - 5B90C458262599800002E742 /* Cell */, 5B90C45C262599800002E742 /* SettingsSectionHeader.swift */, DB443CD32694627B00159B29 /* AppearanceView.swift */, ); @@ -2090,8 +1861,9 @@ 5B90C458262599800002E742 /* Cell */ = { isa = PBXGroup; children = ( - 5B90C459262599800002E742 /* SettingsToggleTableViewCell.swift */, 5B90C45A262599800002E742 /* SettingsAppearanceTableViewCell.swift */, + DB98EB6A27B243470082E365 /* SettingsAppearanceTableViewCell+ViewModel.swift */, + 5B90C459262599800002E742 /* SettingsToggleTableViewCell.swift */, 5B90C45B262599800002E742 /* SettingsLinkTableViewCell.swift */, ); path = Cell; @@ -2112,60 +1884,134 @@ DB68A03825E900CC00CFDF14 /* Share */, 0FAA0FDD25E0B5700017CCDE /* Welcome */, 0FAA102525E1125D0017CCDE /* PickServer */, - DBE0821A25CD382900FD6BBD /* Register */, DB72602125E36A2500235243 /* ServerRules */, + DBE0821A25CD382900FD6BBD /* Register */, 2D364F7025E66D5B00204FDC /* ResendEmail */, 2D59819925E4A55C000FB903 /* ConfirmEmail */, ); path = Onboarding; sourceTree = "<group>"; }; - DB023296267F0ABE00031745 /* Status */ = { + DB025B91278D64F0002F581E /* Persistence */ = { isa = PBXGroup; children = ( - DBAC6484267D0F9E007FE9FD /* StatusNode.swift */, - DBAC6496267DECCB007FE9FD /* TimelineMiddleLoaderNode.swift */, - DBAC6498267DF2C4007FE9FD /* TimelineBottomLoaderNode.swift */, + DB025B98278D66D8002F581E /* Extension */, + DB336F24278D6DF40031E64B /* Protocol */, + DB025B92278D6501002F581E /* Persistence.swift */, + DB025B94278D6530002F581E /* Persistence+MastodonUser.swift */, + DB336F2B278D6FC30031E64B /* Persistence+Status.swift */, + DB336F31278D77330031E64B /* Persistence+Poll.swift */, + DB336F33278D77730031E64B /* Persistence+PollOption.swift */, + DB63F75B279956D000455B82 /* Persistence+Tag.swift */, + DB63F755279949BD00455B82 /* Persistence+SearchHistory.swift */, + DB63F770279A858500455B82 /* Persistence+Notification.swift */, ); - path = Status; + path = Persistence; + sourceTree = "<group>"; + }; + DB025B98278D66D8002F581E /* Extension */ = { + isa = PBXGroup; + children = ( + DB025B96278D66D5002F581E /* MastodonUser+Property.swift */, + DB336F2D278D71AF0031E64B /* Status+Property.swift */, + DB336F37278D7AAF0031E64B /* Poll+Property.swift */, + DB336F35278D77A40031E64B /* PollOption+Property.swift */, + DB63F75D27995B3B00455B82 /* Tag+Property.swift */, + DB63F772279A87DC00455B82 /* Notification+Property.swift */, + DB336F20278D6D960031E64B /* MastodonEmoji.swift */, + DB336F29278D6F2B0031E64B /* MastodonField.swift */, + DB0FCB6D27950E6B006C02E2 /* MastodonMention.swift */, + ); + path = Extension; sourceTree = "<group>"; }; DB03F7F1268990A2007B274C /* TableViewCell */ = { isa = PBXGroup; children = ( DB03F7F22689AEA3007B274C /* ComposeRepliedToStatusContentTableViewCell.swift */, + DBBC24AB26A53D9300398BB9 /* ComposeStatusContentTableViewCell.swift */, DB36679C268AB91B0027D07F /* ComposeStatusAttachmentTableViewCell.swift */, DB3667A3268AE2370027D07F /* ComposeStatusPollTableViewCell.swift */, ); path = TableViewCell; sourceTree = "<group>"; }; + DB0617F727855B010030EE79 /* Notification */ = { + isa = PBXGroup; + children = ( + 2D35237926256D920031AF25 /* NotificationSection.swift */, + 2D7867182625B77500211898 /* NotificationItem.swift */, + ); + path = Notification; + sourceTree = "<group>"; + }; + DB0617F827855B170030EE79 /* User */ = { + isa = PBXGroup; + children = ( + DB6B74FB272FF55800C70B6E /* UserSection.swift */, + DB6B74FD272FF59000C70B6E /* UserItem.swift */, + ); + path = User; + sourceTree = "<group>"; + }; + DB0617F927855B460030EE79 /* Profile */ = { + isa = PBXGroup; + children = ( + DBA94433265CBB5300C537E1 /* ProfileFieldSection.swift */, + DBA94435265CBB7400C537E1 /* ProfileFieldItem.swift */, + ); + path = Profile; + sourceTree = "<group>"; + }; + DB0617FA27855B660030EE79 /* Settings */ = { + isa = PBXGroup; + children = ( + DB6D9F7C26358ED4008423CD /* SettingsSection.swift */, + DB6D9F8326358EEC008423CD /* SettingsItem.swift */, + ); + path = Settings; + sourceTree = "<group>"; + }; + DB0617FB27855B740030EE79 /* Account */ = { + isa = PBXGroup; + children = ( + 2D4AD8A126316CD200613EFC /* SelectedAccountSection.swift */, + 2D4AD8A726316D3500613EFC /* SelectedAccountItem.swift */, + ); + path = Account; + sourceTree = "<group>"; + }; + DB0618082785B2790030EE79 /* Cell */ = { + isa = PBXGroup; + children = ( + DB0618002785732C0030EE79 /* ServerRulesTableViewCell.swift */, + ); + path = Cell; + sourceTree = "<group>"; + }; + DB06180B2785B2AF0030EE79 /* Cell */ = { + isa = PBXGroup; + children = ( + DB0618092785B2AB0030EE79 /* MastodonRegisterAvatarTableViewCell.swift */, + DB8481142788121200BBEABA /* MastodonRegisterTextFieldTableViewCell.swift */, + DB84811627883C2600BBEABA /* MastodonRegisterPasswordHintTableViewCell.swift */, + ); + path = Cell; + sourceTree = "<group>"; + }; DB084B5125CBC56300F898ED /* CoreDataStack */ = { isa = PBXGroup; children = ( DB084B5625CBC56C00F898ED /* Status.swift */, DB45FAE225CA7181005A8AC7 /* MastodonUser.swift */, - DB0C946A26A700AB0088FB11 /* MastodonUser+Property.swift */, - DB9D6C3725E508BE0051B173 /* Attachment.swift */, DB6D9F6E2635807F008423CD /* Setting.swift */, DB6D9F4826353FD6008423CD /* Subscription.swift */, DB6D9F4F2635761F008423CD /* SubscriptionAlerts.swift */, - DBAFB7342645463500371D5F /* Emojis.swift */, - DBA94439265CC0FC00C537E1 /* Fields.swift */, - DBA1DB7F268F84F80052DB59 /* NotificationType.swift */, DB73BF46271199CA00781945 /* Instance.swift */, ); path = CoreDataStack; sourceTree = "<group>"; }; - DB0C947026A7D2AB0088FB11 /* ImageView */ = { - isa = PBXGroup; - children = ( - DB0C946E26A7D2A80088FB11 /* AvatarImageView.swift */, - ); - path = ImageView; - sourceTree = "<group>"; - }; DB0C947826A7FE950088FB11 /* Button */ = { isa = PBXGroup; children = ( @@ -2185,6 +2031,15 @@ path = View; sourceTree = "<group>"; }; + DB0FCB892796BE1E006C02E2 /* RecommandAccount */ = { + isa = PBXGroup; + children = ( + 2DE0FACD2615F7AD00CDF649 /* RecommendAccountSection.swift */, + DBB45B5F27B50A4F002DC5A7 /* RecommendAccountItem.swift */, + ); + path = RecommandAccount; + sourceTree = "<group>"; + }; DB1D187125EF5BBD003F1F23 /* TableView */ = { isa = PBXGroup; children = ( @@ -2202,6 +2057,16 @@ path = View; sourceTree = "<group>"; }; + DB336F24278D6DF40031E64B /* Protocol */ = { + isa = PBXGroup; + children = ( + DB336F22278D6DED0031E64B /* MastodonEmojiContainer.swift */, + DB336F27278D6EC70031E64B /* MastodonFieldContainer.swift */, + DB0FCB6B27950E29006C02E2 /* MastodonMentionContainer.swift */, + ); + path = Protocol; + sourceTree = "<group>"; + }; DB3D0FF725BAA68500EAA174 /* Supporting Files */ = { isa = PBXGroup; children = ( @@ -2221,8 +2086,6 @@ DBF3B73E2733EAED00E21627 /* local-codes.json */, DB427DDE25BAA00100D1B89D /* Assets.xcassets */, DB118A8125E4B6E600FAB162 /* Preview Assets.xcassets */, - DB564BCE269F2F83001E39A7 /* Localizable.stringsdict */, - DB3D100F25BAA75E00EAA174 /* Localizable.strings */, DB2B3ABE25E37E15007045F9 /* InfoPlist.strings */, ); path = Resources; @@ -2233,13 +2096,12 @@ children = ( DBF53F5F25C14E88008AAC7B /* Mastodon.xctestplan */, DBF53F6025C14E9D008AAC7B /* MastodonSDK.xctestplan */, + DB47AB6327CF858400CD73C7 /* AppStoreSnapshotTestPlan.xctestplan */, DB3D0FED25BAA42200EAA174 /* MastodonSDK */, DB427DD425BAA00100D1B89D /* Mastodon */, DB427DEB25BAA00100D1B89D /* MastodonTests */, DB427DF625BAA00100D1B89D /* MastodonUITests */, DB6804802637CD4C00430867 /* AppShared */, - DB89B9EF25C10FD0008580ED /* CoreDataStack */, - DB89B9FC25C10FD0008580ED /* CoreDataStackTests */, DBF8AE14263293E400C9C23C /* NotificationService */, DBC6461326A170AB00B0E31B /* ShareActionExtension */, DB8FABC826AEC7B2008E5AF4 /* MastodonIntent */, @@ -2256,8 +2118,6 @@ DB427DD225BAA00100D1B89D /* Mastodon.app */, DB427DE825BAA00100D1B89D /* MastodonTests.xctest */, DB427DF325BAA00100D1B89D /* MastodonUITests.xctest */, - DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */, - DB89B9F625C10FD0008580ED /* CoreDataStackTests.xctest */, DBF8AE13263293E400C9C23C /* NotificationService.appex */, DB68047F2637CD4C00430867 /* AppShared.framework */, DBC6461226A170AB00B0E31B /* ShareActionExtension.appex */, @@ -2280,9 +2140,10 @@ 2D5A3D0125CF8640002347D6 /* Vender */, DB73B495261F030D002E9E9F /* Activity */, DBBC24D526A54BCB00398BB9 /* Helper */, + DB025B91278D64F0002F581E /* Persistence */, DB5086CB25CC0DB400C2C187 /* Preference */, 2D69CFF225CA9E2200C3A1B2 /* Protocol */, - DB98338425C945ED00AD9700 /* Generated */, + DB6746EE278F45F3008A6B94 /* Template */, DB3D0FF825BAA6B200EAA174 /* Resources */, DB3D0FF725BAA68500EAA174 /* Supporting Files */, ); @@ -2302,6 +2163,7 @@ isa = PBXGroup; children = ( DB427DF725BAA00100D1B89D /* MastodonUITests.swift */, + DB47AB6127CF752B00CD73C7 /* MastodonUISnapshotTests.swift */, DB427DF925BAA00100D1B89D /* Info.plist */, ); path = MastodonUITests; @@ -2311,7 +2173,6 @@ isa = PBXGroup; children = ( DB45FB0925CA87BC005A8AC7 /* CoreData */, - 2D61335625C1887F00CAE157 /* Persist */, 2D61335D25C1894B00CAE157 /* APIService.swift */, DB98337E25C9452D00AD9700 /* APIService+APIError.swift */, DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */, @@ -2321,7 +2182,6 @@ DB98337025C9443200AD9700 /* APIService+Authentication.swift */, DB98339B25C96DE600AD9700 /* APIService+Account.swift */, 2D9DB96A263A91D1007C1D71 /* APIService+DomainBlock.swift */, - 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */, DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */, DB482A4A261340A7008AE74C /* APIService+UserTimeline.swift */, DBA465922696B495002B41DB /* APIService+WebFinger.swift */, @@ -2334,6 +2194,7 @@ 2D61254C262547C200299647 /* APIService+Notification.swift */, DB9A488F26035963008B817C /* APIService+Media.swift */, 2D34D9D026148D9E0081BFC0 /* APIService+Recommend.swift */, + DB0FCB8F2796C5EB006C02E2 /* APIService+Trend.swift */, 2D34D9DA261494120081BFC0 /* APIService+Search.swift */, 0F202212261351F5000C64BF /* APIService+HashtagTimeline.swift */, DB6B74F9272FC2B500C70B6E /* APIService+Follower.swift */, @@ -2353,10 +2214,7 @@ DB45FB0925CA87BC005A8AC7 /* CoreData */ = { isa = PBXGroup; children = ( - 2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Status.swift */, - DB45FADC25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift */, DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */, - 2D79E700261EA5550011E398 /* APIService+CoreData+Tag.swift */, DB6D9F56263577D2008423CD /* APIService+CoreData+Setting.swift */, 5B90C48A26259C120002E742 /* APIService+CoreData+Subscriptions.swift */, DB73BF44271195AC00781945 /* APIService+CoreData+Instance.swift */, @@ -2378,9 +2236,11 @@ DB4F0964269ED06700D62E92 /* SearchResult */ = { isa = PBXGroup; children = ( + 2DFAD5212616F8E300F9EE7C /* Cell */, DB4F0962269ED06300D62E92 /* SearchResultViewController.swift */, - DB4F096B269EFA2000D62E92 /* SearchResultViewController+StatusProvider.swift */, + DB0FCB952797E6C2006C02E2 /* SearchResultViewController+DataSourceProvider.swift */, DB4F0965269ED52200D62E92 /* SearchResultViewModel.swift */, + DB0FCB932797E2B0006C02E2 /* SearchResultViewModel+Diffable.swift */, DB4F0969269EDAD200D62E92 /* SearchResultViewModel+State.swift */, ); path = SearchResult; @@ -2390,10 +2250,15 @@ isa = PBXGroup; children = ( DB66729525F9F91600D60309 /* ComposeStatusSection.swift */, + DB66729B25F9F91F00D60309 /* ComposeStatusItem.swift */, DB36679E268ABAF20027D07F /* ComposeStatusAttachmentSection.swift */, + DB3667A0268ABB2E0027D07F /* ComposeStatusAttachmentItem.swift */, DB3667A5268AE2620027D07F /* ComposeStatusPollSection.swift */, + DB3667A7268AE2900027D07F /* ComposeStatusPollItem.swift */, DB447680260B3ED600B66B82 /* CustomEmojiPickerSection.swift */, + DB44768A260B3F2100B66B82 /* CustomEmojiPickerItem.swift */, DBBF1DC82652538500E5B703 /* AutoCompleteSection.swift */, + DBBF1DCA2652539E00E5B703 /* AutoCompleteItem.swift */, ); path = Compose; sourceTree = "<group>"; @@ -2401,10 +2266,12 @@ DB4F097726A039A200D62E92 /* Search */ = { isa = PBXGroup; children = ( - 2DE0FAC02615F04D00CDF649 /* RecommendHashTagSection.swift */, - 2DE0FACD2615F7AD00CDF649 /* RecommendAccountSection.swift */, + DB0FCB852796BDA1006C02E2 /* SearchSection.swift */, + DB0FCB872796BDA9006C02E2 /* SearchItem.swift */, 2D198648261C0B8500F0B013 /* SearchResultSection.swift */, + 2D198642261BF09500F0B013 /* SearchResultItem.swift */, DB4F097A26A039FF00D62E92 /* SearchHistorySection.swift */, + DB4F097C26A03A5B00D62E92 /* SearchHistoryItem.swift */, ); path = Search; sourceTree = "<group>"; @@ -2413,7 +2280,13 @@ isa = PBXGroup; children = ( DB1FD44325F26CCC004CFCFC /* PickServerSection.swift */, + DB1E347725F519300079D7DF /* PickServerItem.swift */, DB1E346725F518E20079D7DF /* CategoryPickerSection.swift */, + DB1FD45925F27898004CFCFC /* CategoryPickerItem.swift */, + DB0617F427855AB90030EE79 /* ServerRuleSection.swift */, + DB0617FC27855BFE0030EE79 /* ServerRuleItem.swift */, + DB0618022785A7100030EE79 /* RegisterSection.swift */, + DB0618042785A73D0030EE79 /* RegisterItem.swift */, ); path = Onboarding; sourceTree = "<group>"; @@ -2422,9 +2295,7 @@ isa = PBXGroup; children = ( 2D76319E25C1521200929FB9 /* StatusSection.swift */, - DB4481C525EE2ADA00BEFB67 /* PollSection.swift */, - 2D35237926256D920031AF25 /* NotificationSection.swift */, - 5BB04FF4262F0E6D0043BFF6 /* ReportSection.swift */, + DB025B77278D606A002F581E /* StatusItem.swift */, ); path = Status; sourceTree = "<group>"; @@ -2464,7 +2335,6 @@ DB55D32225FB4D320002F825 /* View */ = { isa = PBXGroup; children = ( - DBBC24AD26A53DC100398BB9 /* ReplicaStatusView.swift */, DB03F7F42689B782007B274C /* ComposeTableView.swift */, DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */, DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */, @@ -2480,7 +2350,7 @@ isa = PBXGroup; children = ( DB5B7294273112B100081888 /* FollowingListViewController.swift */, - DB5B72992731137900081888 /* FollowingListViewController+Provider.swift */, + DB63F74A279914A000455B82 /* FollowingListViewController+DataSourceProvider.swift */, DB5B7297273112C800081888 /* FollowingListViewModel.swift */, DB5B729B273113C200081888 /* FollowingListViewModel+Diffable.swift */, DB5B729D273113F300081888 /* FollowingListViewModel+State.swift */, @@ -2491,6 +2361,8 @@ DB6180DE263919350018D199 /* MediaPreview */ = { isa = PBXGroup; children = ( + DBB45B5727B39FCC002DC5A7 /* Video */, + DB6180F026391CAB0018D199 /* Image */, DB6180E1263919780018D199 /* Paging */, DB6180DC263918E30018D199 /* MediaPreviewViewController.swift */, DB6180F926391F2E0018D199 /* MediaPreviewViewModel.swift */, @@ -2501,7 +2373,6 @@ DB6180E1263919780018D199 /* Paging */ = { isa = PBXGroup; children = ( - DB6180F026391CAB0018D199 /* Image */, DB6180DF2639194B0018D199 /* MediaPreviewPagingViewController.swift */, ); path = Paging; @@ -2525,6 +2396,7 @@ DB6180EA26391C140018D199 /* MediaPreviewTransitionItem.swift */, DB6180F526391D580018D199 /* MediaPreviewableViewController.swift */, DB6180F726391D660018D199 /* MediaPreviewingViewController.swift */, + DBB45B5A27B3A109002DC5A7 /* MediaPreviewTransitionViewController.swift */, ); path = MediaPreview; sourceTree = "<group>"; @@ -2539,6 +2411,47 @@ path = Image; sourceTree = "<group>"; }; + DB63F7502799449300455B82 /* Cell */ = { + isa = PBXGroup; + children = ( + DB63F74C27993F5B00455B82 /* SearchHistoryUserCollectionViewCell.swift */, + DB63F759279953F200455B82 /* SearchHistoryUserCollectionViewCell+ViewModel.swift */, + DB63F751279944AA00455B82 /* SearchHistorySectionHeaderCollectionReusableView.swift */, + ); + path = Cell; + sourceTree = "<group>"; + }; + DB63F765279A5E5600455B82 /* NotificationTimeline */ = { + isa = PBXGroup; + children = ( + DB63F763279A5E3C00455B82 /* NotificationTimelineViewController.swift */, + DB023D2B27A10464005AC798 /* NotificationTimelineViewController+DataSourceProvider.swift */, + DB63F766279A5EB300455B82 /* NotificationTimelineViewModel.swift */, + DB63F768279A5EBB00455B82 /* NotificationTimelineViewModel+Diffable.swift */, + DB63F76A279A5ED300455B82 /* NotificationTimelineViewModel+LoadOldestState.swift */, + ); + path = NotificationTimeline; + sourceTree = "<group>"; + }; + DB65C63527A2AF52008BAC2E /* Report */ = { + isa = PBXGroup; + children = ( + 5BB04FF4262F0E6D0043BFF6 /* ReportSection.swift */, + DB65C63627A2AF6C008BAC2E /* ReportItem.swift */, + ); + path = Report; + sourceTree = "<group>"; + }; + DB6746EE278F45F3008A6B94 /* Template */ = { + isa = PBXGroup; + children = ( + DB6746EC278F45F0008A6B94 /* AutoGenerateProtocolRelayDelegate.swift */, + DB6746EF278F463B008A6B94 /* AutoGenerateProtocolDelegate.swift */, + DB697DD0278F4871004EF2F7 /* AutoGenerateTableViewDelegate.swift */, + ); + path = Template; + sourceTree = "<group>"; + }; DB67D08727312E6A006A36CF /* Wizard */ = { isa = PBXGroup; children = ( @@ -2552,7 +2465,6 @@ children = ( DB6804812637CD4C00430867 /* AppShared.h */, DB6804822637CD4C00430867 /* Info.plist */, - DB6804912637CD8700430867 /* AppName.swift */, DB6804FC2637CFEC00430867 /* AppSecret.swift */, DB6804D02637CE4700430867 /* UserDefaults.swift */, DB73BF3A2711885500781945 /* UserDefaults+Notification.swift */, @@ -2563,9 +2475,12 @@ DB68A03825E900CC00CFDF14 /* Share */ = { isa = PBXGroup; children = ( - 2D82B9FE25E7863200E36F0F /* OnboardingViewControllerAppearance.swift */, DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */, DB029E94266A20430062874E /* MastodonAuthenticationController.swift */, + 2D82B9FE25E7863200E36F0F /* OnboardingViewControllerAppearance.swift */, + DB0617EC277F02C50030EE79 /* OnboardingNavigationController.swift */, + 0FB3D2FD25E4CB6400AAD544 /* OnboardingHeadlineTableViewCell.swift */, + DB0617EE277F12720030EE79 /* NavigationActionView.swift */, ); path = Share; sourceTree = "<group>"; @@ -2578,11 +2493,38 @@ path = NavigationController; sourceTree = "<group>"; }; + DB697DD7278F4C34004EF2F7 /* Provider */ = { + isa = PBXGroup; + children = ( + DB697DDC278F521D004EF2F7 /* DataSourceFacade.swift */, + DB697DE0278F5296004EF2F7 /* DataSourceFacade+Model.swift */, + DB697DDE278F524F004EF2F7 /* DataSourceFacade+Profile.swift */, + DB8F7075279E954700E1225B /* DataSourceFacade+Follow.swift */, + DB603110279EB38500A935FE /* DataSourceFacade+Mute.swift */, + DB603112279EBEBA00A935FE /* DataSourceFacade+Block.swift */, + DB0FCB7327956939006C02E2 /* DataSourceFacade+Status.swift */, + DB63F778279ABF9C00455B82 /* DataSourceFacade+Reblog.swift */, + DB63F77A279ACAE500455B82 /* DataSourceFacade+Favorite.swift */, + DB0FCB67279507EF006C02E2 /* DataSourceFacade+Meta.swift */, + DB0FCB79279576A2006C02E2 /* DataSourceFacade+Thread.swift */, + DB63F74627990B0600455B82 /* DataSourceFacade+Hashtag.swift */, + DB63F7532799491600455B82 /* DataSourceFacade+SearchHistory.swift */, + DB159C2A27A17BAC0068DC77 /* DataSourceFacade+Media.swift */, + DB697DD5278F4C29004EF2F7 /* DataSourceProvider.swift */, + DB697DDA278F4DE3004EF2F7 /* DataSourceProvider+StatusTableViewCellDelegate.swift */, + DB023D2927A0FE5C005AC798 /* DataSourceProvider+NotificationTableViewCellDelegate.swift */, + DB0FCB7727957678006C02E2 /* DataSourceProvider+UITableViewDelegate.swift */, + DBD5B1F727BCFD9D00BD6B38 /* DataSourceProvider+TableViewControllerNavigateable.swift */, + DBD5B1F927BD013700BD6B38 /* DataSourceProvider+StatusTableViewControllerNavigateable.swift */, + ); + path = Provider; + sourceTree = "<group>"; + }; DB6B74F0272FB55400C70B6E /* Follower */ = { isa = PBXGroup; children = ( DB6B74EE272FB55000C70B6E /* FollowerListViewController.swift */, - DB6B74F7272FBFB100C70B6E /* FollowerListViewController+Provider.swift */, + DB63F7482799126300455B82 /* FollowerListViewController+DataSourceProvider.swift */, DB6B74F1272FB67600C70B6E /* FollowerListViewModel.swift */, DB6B74F3272FBAE700C70B6E /* FollowerListViewModel+Diffable.swift */, DB6B74F5272FBCDB00C70B6E /* FollowerListViewModel+State.swift */, @@ -2621,8 +2563,10 @@ DB72602125E36A2500235243 /* ServerRules */ = { isa = PBXGroup; children = ( + DB0618082785B2790030EE79 /* Cell */, DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */, DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */, + DB0617FE27855D6C0030EE79 /* MastodonServerRulesViewModel+Diffable.swift */, ); path = ServerRules; sourceTree = "<group>"; @@ -2631,6 +2575,7 @@ isa = PBXGroup; children = ( DB73B48F261F030A002E9E9F /* SafariActivity.swift */, + DB023D25279FFB0A005AC798 /* ShareActivityProvider.swift */, ); path = Activity; sourceTree = "<group>"; @@ -2653,7 +2598,6 @@ DB789A2125F9F76D0071ACA0 /* CollectionViewCell */ = { isa = PBXGroup; children = ( - DBBC24AB26A53D9300398BB9 /* ComposeStatusContentTableViewCell.swift */, DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift */, DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */, DB87D4502609CF1E00D12C0D /* ComposeStatusPollOptionAppendEntryCollectionViewCell.swift */, @@ -2686,85 +2630,6 @@ path = Root; sourceTree = "<group>"; }; - DB87D45C2609DE6600D12C0D /* TextField */ = { - isa = PBXGroup; - children = ( - DB87D4562609DD5300D12C0D /* DeleteBackwardResponseTextField.swift */, - ); - path = TextField; - sourceTree = "<group>"; - }; - DB89B9EF25C10FD0008580ED /* CoreDataStack */ = { - isa = PBXGroup; - children = ( - DB89B9F125C10FD0008580ED /* Info.plist */, - DB89B9F025C10FD0008580ED /* CoreDataStack.h */, - DB89BA1125C1105C008580ED /* CoreDataStack.swift */, - DB89BA3525C1145C008580ED /* CoreData.xcdatamodeld */, - 2DF75BB725D1473400694EC8 /* Stack */, - DB89BA4025C1165F008580ED /* Protocol */, - DB89BA1725C1107F008580ED /* Extension */, - DB89BA2C25C110B7008580ED /* Entity */, - ); - path = CoreDataStack; - sourceTree = "<group>"; - }; - DB89B9FC25C10FD0008580ED /* CoreDataStackTests */ = { - isa = PBXGroup; - children = ( - DB89B9FD25C10FD0008580ED /* CoreDataStackTests.swift */, - DB89B9FF25C10FD0008580ED /* Info.plist */, - ); - path = CoreDataStackTests; - sourceTree = "<group>"; - }; - DB89BA1725C1107F008580ED /* Extension */ = { - isa = PBXGroup; - children = ( - DB89BA1825C1107F008580ED /* Collection.swift */, - DB89BA1925C1107F008580ED /* NSManagedObjectContext.swift */, - DB89BA1A25C1107F008580ED /* URL.swift */, - 2D152A9125C2980C009AA50C /* UIFont.swift */, - ); - path = Extension; - sourceTree = "<group>"; - }; - DB89BA2C25C110B7008580ED /* Entity */ = { - isa = PBXGroup; - children = ( - DB89BA2625C110B4008580ED /* Status.swift */, - 2D9DB968263A833E007C1D71 /* DomainBlock.swift */, - 2D6125462625436B00299647 /* Notification.swift */, - 2D0B7A1C261D839600B44727 /* SearchHistory.swift */, - DB8AF52425C131D1002E6C99 /* MastodonUser.swift */, - DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */, - 2D927F0125C7E4F2004F19B8 /* Mention.swift */, - 2D927F0725C7E9A8004F19B8 /* Tag.swift */, - 2D927F0D25C7E9C9004F19B8 /* History.swift */, - 2D927F1325C7EDD9004F19B8 /* Emoji.swift */, - DB45FAEC25CA7A9A005A8AC7 /* MastodonAuthentication.swift */, - 2DA7D05625CA693F00804E11 /* Application.swift */, - DB9D6C2D25E504AC0051B173 /* Attachment.swift */, - DB4481AC25EE155900BEFB67 /* Poll.swift */, - DB4481B225EE16D000BEFB67 /* PollOption.swift */, - DBCC3B9A2615849F0045B23D /* PrivateNote.swift */, - 5B90C46D26259B2C0002E742 /* Setting.swift */, - 5B90C46C26259B2C0002E742 /* Subscription.swift */, - 5B90C47E26259BA90002E742 /* SubscriptionAlerts.swift */, - DB73BF4027118B6D00781945 /* Instance.swift */, - ); - path = Entity; - sourceTree = "<group>"; - }; - DB89BA4025C1165F008580ED /* Protocol */ = { - isa = PBXGroup; - children = ( - DB89BA4125C1165F008580ED /* NetworkUpdatable.swift */, - DB89BA4225C1165F008580ED /* Managed.swift */, - ); - path = Protocol; - sourceTree = "<group>"; - }; DB8AF52A25C13561002E6C99 /* State */ = { isa = PBXGroup; children = ( @@ -2802,17 +2667,16 @@ DB67D08727312E6A006A36CF /* Wizard */, DB9F58ED26EF435800E7BBE9 /* Account */, 2D38F1D325CD463600561493 /* HomeTimeline */, - 2D76316325C14BAC00929FB9 /* PublicTimeline */, - 5B24BBD6262DB14800A9381B /* Report */, 0F2021F5261325ED000C64BF /* HashtagTimeline */, + DB9D6BFD25E4F57B0051B173 /* Notification */, + DB938EEB2623F52600E5B6C1 /* Thread */, + 5B24BBD6262DB14800A9381B /* Report */, + DB789A1025F9F29B0071ACA0 /* Compose */, + DB6180DE263919350018D199 /* MediaPreview */, 2DAC9E36262FC20B0062E1A6 /* SuggestionAccount */, + DB9D6C0825E4F5A60051B173 /* Profile */, DB9D6BEE25E4F5370051B173 /* Search */, 5B90C455262599800002E742 /* Settings */, - DB9D6BFD25E4F57B0051B173 /* Notification */, - DB9D6C0825E4F5A60051B173 /* Profile */, - DB789A1025F9F29B0071ACA0 /* Compose */, - DB938EEB2623F52600E5B6C1 /* Thread */, - DB6180DE263919350018D199 /* MediaPreview */, ); path = Scene; sourceTree = "<group>"; @@ -2825,18 +2689,14 @@ 2DF123A625C3B0210020F248 /* ActiveLabel.swift */, 5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */, 0F20223826146553000C64BF /* Array.swift */, - DB44384E25E8C1FA008912A2 /* CALayer.swift */, 2D206B8525F5FB0900143C56 /* Double.swift */, - DB97131E2666078B00BD1E90 /* Date.swift */, DBB3BA2926A81C020004F2D4 /* FLAnimatedImageView.swift */, - DB0E91E926A9675100BD2ACC /* MetaLabel.swift */, DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */, DB47229625F9EFAD00DA7F53 /* NSManagedObjectContext.swift */, DB0140CE25C42AEE00F9F3CF /* OSLog.swift */, 2D939AB425EDD8A90076FA61 /* String.swift */, DB68A06225E905E000CFDF14 /* UIApplication.swift */, DB45FAB525CA5485005A8AC7 /* UIAlertController.swift */, - 2D42FF8E25C8228A004A627A /* UIButton.swift */, DB45FAD625CA6C76005A8AC7 /* UIBarButtonItem.swift */, DB4481B825EE289600BEFB67 /* UITableView.swift */, DBD376B1269302A4007FEC24 /* UITableViewCell.swift */, @@ -2875,12 +2735,13 @@ isa = PBXGroup; children = ( DB938EE52623F50700E5B6C1 /* ThreadViewController.swift */, - DB938F24262438D600E5B6C1 /* ThreadViewController+Provider.swift */, + DB0FCB75279571C5006C02E2 /* ThreadViewController+DataSourceProvider.swift */, DB938EEC2623F79B00E5B6C1 /* ThreadViewModel.swift */, DB938F1E2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift */, DB938F0E2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift */, DB938F0226240EA300E5B6C1 /* CachedThreadViewModel.swift */, DB938F0826240F3C00E5B6C1 /* RemoteThreadViewModel.swift */, + DB0FCB7F27968F70006C02E2 /* MastodonStatusThreadViewModel.swift */, ); path = Thread; sourceTree = "<group>"; @@ -2895,13 +2756,56 @@ name = "Recovered References"; sourceTree = "<group>"; }; - DB98338425C945ED00AD9700 /* Generated */ = { + DB98EB4A27B0F0F50082E365 /* Cell */ = { isa = PBXGroup; children = ( - DB98338525C945ED00AD9700 /* Strings.swift */, - DB98338625C945ED00AD9700 /* Assets.swift */, + DB98EB5227B0F9890082E365 /* ReportHeadlineTableViewCell.swift */, + DB98EB4827B0F0CD0082E365 /* ReportStatusTableViewCell.swift */, + DB98EB4B27B0F2BC0082E365 /* ReportStatusTableViewCell+ViewModel.swift */, + DB98EB5F27B10E150082E365 /* ReportCommentTableViewCell.swift */, + DB98EB6827B21A7C0082E365 /* ReportResultActionTableViewCell.swift */, ); - path = Generated; + path = Cell; + sourceTree = "<group>"; + }; + DB98EB4F27B0F9300082E365 /* ReportStatus */ = { + isa = PBXGroup; + children = ( + 5BB04FD4262E7AFF0043BFF6 /* ReportViewController.swift */, + 5B24BBD7262DB14800A9381B /* ReportViewModel.swift */, + 5B24BBD8262DB14800A9381B /* ReportViewModel+Diffable.swift */, + DB98EB4627B0DFAA0082E365 /* ReportViewModel+State.swift */, + ); + path = ReportStatus; + sourceTree = "<group>"; + }; + DB98EB5727B0FF1F0082E365 /* Share */ = { + isa = PBXGroup; + children = ( + DB98EB4A27B0F0F50082E365 /* Cell */, + DB98EB5527B0FF1B0082E365 /* ReportViewControllerAppearance.swift */, + ); + path = Share; + sourceTree = "<group>"; + }; + DB98EB5A27B109900082E365 /* ReportSupplementary */ = { + isa = PBXGroup; + children = ( + DB98EB5827B109890082E365 /* ReportSupplementaryViewController.swift */, + DB98EB5B27B10A730082E365 /* ReportSupplementaryViewModel.swift */, + DB98EB5D27B10A7A0082E365 /* ReportSupplementaryViewModel+Diffable.swift */, + ); + path = ReportSupplementary; + sourceTree = "<group>"; + }; + DB98EB6327B216490082E365 /* ReportResult */ = { + isa = PBXGroup; + children = ( + DB98EB6127B215EB0082E365 /* ReportResultViewController.swift */, + DB98EB6427B216500082E365 /* ReportResultViewModel.swift */, + DB98EB6627B216560082E365 /* ReportResultViewModel+Diffable.swift */, + ); + path = ReportResult; sourceTree = "<group>"; }; DB9A489B26036E19008B817C /* MastodonAttachmentService */ = { @@ -2925,14 +2829,11 @@ DB9D6BFD25E4F57B0051B173 /* Notification */ = { isa = PBXGroup; children = ( + DB63F765279A5E5600455B82 /* NotificationTimeline */, DB0C947826A7FE950088FB11 /* Button */, - 2D35237F26256F470031AF25 /* TableViewCell */, + 2D35237F26256F470031AF25 /* Cell */, DB9D6BF725E4F5690051B173 /* NotificationViewController.swift */, - DB63BE7E268DD1070011D3F9 /* NotificationViewController+StatusProvider.swift */, 2D607AD726242FC500B70763 /* NotificationViewModel.swift */, - 2D084B8C26258EA3003AA3AF /* NotificationViewModel+Diffable.swift */, - 2D084B9226259545003AA3AF /* NotificationViewModel+LoadLatestState.swift */, - 2D24E12C2626FD2E00A59D4F /* NotificationViewModel+LoadOldestState.swift */, ); path = Notification; sourceTree = "<group>"; @@ -2946,8 +2847,8 @@ DBE3CDF1261C6B3100430CC6 /* Favorite */, DB6B74F0272FB55400C70B6E /* Follower */, DB5B7296273112B400081888 /* Following */, + DBFEEC97279BDC6A004F81DD /* About */, DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */, - DBAE3F812615DDA3004B8251 /* ProfileViewController+UserProvider.swift */, DBB5255D2611F07A002F1F29 /* ProfileViewModel.swift */, DBCC3B8E26148F7B0045B23D /* CachedProfileViewModel.swift */, DBAE3FAE26172FC0004B8251 /* RemoteProfileViewModel.swift */, @@ -2956,24 +2857,9 @@ path = Profile; sourceTree = "<group>"; }; - DB9D6C1325E4F97A0051B173 /* Container */ = { - isa = PBXGroup; - children = ( - DB9D6C0D25E4F9780051B173 /* MosaicImageViewContainer.swift */, - 2D206B7125F5D27F00143C56 /* AudioContainerView.swift */, - 5DF1057825F88A1D00D6C0D4 /* PlayerContainerView.swift */, - DB49A63C25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift */, - 5DF1057E25F88A4100D6C0D4 /* TouchBlockingView.swift */, - ); - path = Container; - sourceTree = "<group>"; - }; DB9D6C2025E502C60051B173 /* ViewModel */ = { isa = PBXGroup; children = ( - DB9D6C2225E502C60051B173 /* MosaicImageViewModel.swift */, - 2DA6055025F74407006356F9 /* AudioContainerViewModel.swift */, - 5DF1054625F8870E00D6C0D4 /* VideoPlayerViewModel.swift */, DB7274F3273BB9B200577D95 /* ListBatchFetchViewModel.swift */, ); path = ViewModel; @@ -3029,7 +2915,6 @@ DBA9B90325F1D4420012E7B6 /* Control */ = { isa = PBXGroup; children = ( - DB59F11725EFA35B001F1DAB /* StripProgressView.swift */, DB02CDBE2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift */, ); path = Control; @@ -3040,36 +2925,18 @@ children = ( DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */, DB4932B026F1FB5300EF46D4 /* WizardCardView.swift */, + DB0617EA277EF3820030EE79 /* GradientBorderView.swift */, ); path = View; sourceTree = "<group>"; }; - DBAC6486267D0FAC007FE9FD /* Node */ = { + DBB45B5727B39FCC002DC5A7 /* Video */ = { isa = PBXGroup; children = ( - DB023296267F0ABE00031745 /* Status */, - DB023294267F0AB800031745 /* ASMetaEditableTextNode.swift */, + DBB45B5527B39FC9002DC5A7 /* MediaPreviewVideoViewController.swift */, + DBB45B5827B39FE4002DC5A7 /* MediaPreviewVideoViewModel.swift */, ); - path = Node; - sourceTree = "<group>"; - }; - DBAC6490267DC84F007FE9FD /* DataSource */ = { - isa = PBXGroup; - children = ( - DBAC6487267D388B007FE9FD /* ASTableNode.swift */, - DBAC648E267DC84D007FE9FD /* TableNodeDiffableDataSource.swift */, - ); - path = DataSource; - sourceTree = "<group>"; - }; - DBAE3F742615DD63004B8251 /* UserProvider */ = { - isa = PBXGroup; - children = ( - DBAE3F672615DD60004B8251 /* UserProvider.swift */, - DBAE3F872615DDF4004B8251 /* UserProviderFacade.swift */, - DB6B75012730060700C70B6E /* UserProviderFacade+UITableViewDelegate.swift */, - ); - path = UserProvider; + path = Video; sourceTree = "<group>"; }; DBB525132611EBB1002F1F29 /* Segmented */ = { @@ -3094,7 +2961,7 @@ isa = PBXGroup; children = ( DBB525352611ECEB002F1F29 /* UserTimelineViewController.swift */, - DB482A44261335BA008AE74C /* UserTimelineViewController+Provider.swift */, + DB0FCB812796AC78006C02E2 /* UserTimelineViewController+DataSourceProvider.swift */, DBB525552611EDCA002F1F29 /* UserTimelineViewModel.swift */, DBCBED1626132DB500B49291 /* UserTimelineViewModel+Diffable.swift */, DB482A3E261331E8008AE74C /* UserTimelineViewModel+State.swift */, @@ -3108,7 +2975,6 @@ DBB525732612D5A5002F1F29 /* View */, DBB525402611ED54002F1F29 /* ProfileHeaderViewController.swift */, DB7F48442620241000796008 /* ProfileHeaderViewModel.swift */, - DBA94437265CBD4D00C537E1 /* ProfileHeaderViewModel+Diffable.swift */, ); path = Header; sourceTree = "<group>"; @@ -3120,9 +2986,6 @@ DBB5256D2612D5A1002F1F29 /* ProfileStatusDashboardView.swift */, DBB525842612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift */, DB35FC1E2612F1D9006193C9 /* ProfileRelationshipActionButton.swift */, - DB35FC242612FD7A006193C9 /* ProfileFieldView.swift */, - DBA9443D265CFA6400C537E1 /* ProfileFieldCollectionViewCell.swift */, - DBF9814B265E339500E4BA07 /* ProfileFieldAddEntryCollectionViewCell.swift */, DBF98149265E24F500E4BA07 /* ProfileFieldCollectionViewHeaderFooterView.swift */, ); path = View; @@ -3152,8 +3015,8 @@ isa = PBXGroup; children = ( DBBC24D626A54BCB00398BB9 /* MastodonRegex.swift */, + DBBC50C0278ED49200AF0CC6 /* MastodonAuthenticationBox.swift */, DBBC24D826A54BCB00398BB9 /* MastodonMetricFormatter.swift */, - DBFEF07626A691FB006D7ED1 /* MastodonAuthenticationBox.swift */, DBF3B7402733EB9400E21627 /* MastodonLocalCode.swift */, ); path = Helper; @@ -3187,24 +3050,10 @@ path = ShareActionExtension; sourceTree = "<group>"; }; - DBCBCBFD2680ADBA000F5B51 /* AsyncHomeTimeline */ = { - isa = PBXGroup; - children = ( - DBCBCBFB2680ADB7000F5B51 /* AsyncHomeTimelineViewController.swift */, - DBCBCC022680AF6E000F5B51 /* AsyncHomeTimelineViewController+DebugAction.swift */, - DBCBCC042680AFB9000F5B51 /* AsyncHomeTimelineViewController+Provider.swift */, - DBCBCBFE2680AE98000F5B51 /* AsyncHomeTimelineViewModel.swift */, - DBCBCC002680AF2A000F5B51 /* AsyncHomeTimelineViewModel+Diffable.swift */, - DBCBCC062680AFEC000F5B51 /* AsyncHomeTimelineViewModel+LoadLatestState.swift */, - DBCBCC082680B01B000F5B51 /* AsyncHomeTimelineViewModel+LoadMiddleState.swift */, - DBCBCC0A2680B03F000F5B51 /* AsyncHomeTimelineViewModel+LoadOldestState.swift */, - ); - path = AsyncHomeTimeline; - sourceTree = "<group>"; - }; DBCBED2226132E1D00B49291 /* FetchedResultsController */ = { isa = PBXGroup; children = ( + DB336F3C278D80040031E64B /* FeedFetchedResultsController.swift */, DBCBED1C26132E1A00B49291 /* StatusFetchedResultsController.swift */, DBA088DE26958164003EB4B2 /* UserFetchedResultsController.swift */, DB6D9F75263587C7008423CD /* SettingFetchedResultController.swift */, @@ -3216,9 +3065,11 @@ DBE0821A25CD382900FD6BBD /* Register */ = { isa = PBXGroup; children = ( + DB06180B2785B2AF0030EE79 /* Cell */, DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */, 2D939AE725EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift */, DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */, + DB0618062785A8880030EE79 /* MastodonRegisterViewModel+Diffable.swift */, ); path = Register; sourceTree = "<group>"; @@ -3227,7 +3078,7 @@ isa = PBXGroup; children = ( DBE3CDEB261C6B2900430CC6 /* FavoriteViewController.swift */, - DBE3CE0C261D767100430CC6 /* FavoriteViewController+Provider.swift */, + DB0FCB832796B2A2006C02E2 /* FavoriteViewController+DataSourceProvider.swift */, DBE3CDFA261C6CA500430CC6 /* FavoriteViewModel.swift */, DBE3CE06261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift */, DBE3CE00261D623D00430CC6 /* FavoriteViewModel+State.swift */, @@ -3238,7 +3089,6 @@ DBF1D24F269DAF6100C1C08A /* SearchDetail */ = { isa = PBXGroup; children = ( - 2DFAD5212616F8E300F9EE7C /* TableViewCell */, DB4F0964269ED06700D62E92 /* SearchResult */, DBF1D252269DB01700C1C08A /* SearchHistory */, DBF1D24D269DAF5D00C1C08A /* SearchDetailViewController.swift */, @@ -3250,9 +3100,12 @@ DBF1D252269DB01700C1C08A /* SearchHistory */ = { isa = PBXGroup; children = ( + DB63F7502799449300455B82 /* Cell */, DB4F098026A0475500D62E92 /* View */, DBF1D250269DB01200C1C08A /* SearchHistoryViewController.swift */, + DB63F76127996B6600455B82 /* SearchHistoryViewController+DataSourceProvider.swift */, DB4F097426A037F500D62E92 /* SearchHistoryViewModel.swift */, + DB63F74E2799405600455B82 /* SearchHistoryViewModel+Diffable.swift */, ); path = SearchHistory; sourceTree = "<group>"; @@ -3260,12 +3113,11 @@ DBF1D253269DB02C00C1C08A /* Search */ = { isa = PBXGroup; children = ( - 2D34D9E026149C550081BFC0 /* CollectionViewCell */, + 2D34D9E026149C550081BFC0 /* Cell */, 2DE0FAC62615F5D200CDF649 /* View */, DB9D6BE825E4F5340051B173 /* SearchViewController.swift */, - 2D34D9CA261489930081BFC0 /* SearchViewController+Recommend.swift */, - 5DFC35DE262068D20045711D /* SearchViewController+Follow.swift */, 2D6DE3FF26141DF600A63F6A /* SearchViewModel.swift */, + DB0FCB8B2796BF8D006C02E2 /* SearchViewModel+Diffable.swift */, ); path = Search; sourceTree = "<group>"; @@ -3283,6 +3135,27 @@ path = NotificationService; sourceTree = "<group>"; }; + DBFEEC97279BDC6A004F81DD /* About */ = { + isa = PBXGroup; + children = ( + DBFEEC9E279C12CD004F81DD /* Cell */, + DBFEEC95279BDC67004F81DD /* ProfileAboutViewController.swift */, + DBFEEC98279BDCDE004F81DD /* ProfileAboutViewModel.swift */, + DBFEEC9A279BDDD9004F81DD /* ProfileAboutViewModel+Diffable.swift */, + ); + path = About; + sourceTree = "<group>"; + }; + DBFEEC9E279C12CD004F81DD /* Cell */ = { + isa = PBXGroup; + children = ( + DBA9443D265CFA6400C537E1 /* ProfileFieldCollectionViewCell.swift */, + DBFEEC9C279C12C1004F81DD /* ProfileFieldEditCollectionViewCell.swift */, + DBF9814B265E339500E4BA07 /* ProfileFieldAddEntryCollectionViewCell.swift */, + ); + path = Cell; + sourceTree = "<group>"; + }; DBFEF05426A576EE006D7ED1 /* View */ = { isa = PBXGroup; children = ( @@ -3328,14 +3201,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - DB89B9E925C10FD0008580ED /* Headers */ = { - isa = PBXHeadersBuildPhase; - buildActionMask = 2147483647; - files = ( - DB89BA0025C10FD0008580ED /* CoreDataStack.h in Headers */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXHeadersBuildPhase section */ /* Begin PBXNativeTarget section */ @@ -3351,11 +3216,12 @@ 5532CB85BBE168B25B20720B /* [CP] Embed Pods Frameworks */, DB89BA0825C10FD0008580ED /* Embed Frameworks */, DBF8AE1B263293E400C9C23C /* Embed App Extensions */, + DB025B8E278D6448002F581E /* ShellScript */, + DB697DD2278F48D5004EF2F7 /* ShellScript */, ); buildRules = ( ); dependencies = ( - DB89BA0225C10FD0008580ED /* PBXTargetDependency */, DBF8AE19263293E400C9C23C /* PBXTargetDependency */, DB6804852637CD4C00430867 /* PBXTargetDependency */, DB6804CA2637CE3000430867 /* PBXTargetDependency */, @@ -3376,7 +3242,6 @@ DBAC649D267DFE43007FE9FD /* DiffableDataSources */, DBAC64A0267E6D02007FE9FD /* Fuzi */, DBF7A0FB26830C33004176A2 /* FPSIndicator */, - DBC6462A26A1738900B0E31B /* MastodonUI */, DB01E23226A98F0900C3965B /* MastodonMeta */, DB01E23426A98F0900C3965B /* MetaTextKit */, DB552D4E26BBD10C00E481F6 /* OrderedCollections */, @@ -3447,44 +3312,6 @@ productReference = DB68047F2637CD4C00430867 /* AppShared.framework */; productType = "com.apple.product-type.framework"; }; - DB89B9ED25C10FD0008580ED /* CoreDataStack */ = { - isa = PBXNativeTarget; - buildConfigurationList = DB89BA0525C10FD0008580ED /* Build configuration list for PBXNativeTarget "CoreDataStack" */; - buildPhases = ( - DB89B9E925C10FD0008580ED /* Headers */, - DB89B9EA25C10FD0008580ED /* Sources */, - DB89B9EB25C10FD0008580ED /* Frameworks */, - DB89B9EC25C10FD0008580ED /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - DB6805292637D7DD00430867 /* PBXTargetDependency */, - ); - name = CoreDataStack; - productName = CoreDataStack; - productReference = DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */; - productType = "com.apple.product-type.framework"; - }; - DB89B9F525C10FD0008580ED /* CoreDataStackTests */ = { - isa = PBXNativeTarget; - buildConfigurationList = DB89BA0925C10FD0008580ED /* Build configuration list for PBXNativeTarget "CoreDataStackTests" */; - buildPhases = ( - DB89B9F225C10FD0008580ED /* Sources */, - DB89B9F325C10FD0008580ED /* Frameworks */, - DB89B9F425C10FD0008580ED /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - DB89B9F925C10FD0008580ED /* PBXTargetDependency */, - DB89B9FB25C10FD0008580ED /* PBXTargetDependency */, - ); - name = CoreDataStackTests; - productName = CoreDataStackTests; - productReference = DB89B9F625C10FD0008580ED /* CoreDataStackTests.xctest */; - productType = "com.apple.product-type.bundle.unit-test"; - }; DB8FABC526AEC7B2008E5AF4 /* MastodonIntent */ = { isa = PBXNativeTarget; buildConfigurationList = DB8FABCF26AEC7B2008E5AF4 /* Build configuration list for PBXNativeTarget "MastodonIntent" */; @@ -3498,7 +3325,6 @@ ); dependencies = ( DB8FABDA26AEC873008E5AF4 /* PBXTargetDependency */, - DB8FABDF26AEC87B008E5AF4 /* PBXTargetDependency */, ); name = MastodonIntent; packageProductDependencies = ( @@ -3521,11 +3347,9 @@ ); dependencies = ( DBC6463626A195DB00B0E31B /* PBXTargetDependency */, - DBC6463A26A195DB00B0E31B /* PBXTargetDependency */, ); name = ShareActionExtension; packageProductDependencies = ( - DBC6462426A1720B00B0E31B /* MastodonUI */, DBBC24A926A5301B00398BB9 /* MastodonSDK */, DBBC24B726A5421800398BB9 /* CommonOSLog */, DBBC24D026A5484F00398BB9 /* UITextView+Placeholder */, @@ -3553,7 +3377,7 @@ packageProductDependencies = ( DB00CA962632DDB600A54956 /* CommonOSLog */, DB6D9F41263527CE008423CD /* AlamofireImage */, - DBBC24CC26A5471E00398BB9 /* MastodonExtension */, + DB179266278D5A4A00B71DEB /* MastodonSDK */, ); productName = NotificationService; productReference = DBF8AE13263293E400C9C23C /* NotificationService.appex */; @@ -3584,14 +3408,6 @@ CreatedOnToolsVersion = 12.4; LastSwiftMigration = 1240; }; - DB89B9ED25C10FD0008580ED = { - CreatedOnToolsVersion = 12.4; - LastSwiftMigration = 1240; - }; - DB89B9F525C10FD0008580ED = { - CreatedOnToolsVersion = 12.4; - TestTargetID = DB427DD125BAA00100D1B89D; - }; DB8FABC526AEC7B2008E5AF4 = { CreatedOnToolsVersion = 12.5.1; }; @@ -3622,7 +3438,9 @@ ru, "gd-GB", th, - "ku-TR", + "eu-ES", + "sv-FI", + ku, ); mainGroup = DB427DC925BAA00100D1B89D; packageReferences = ( @@ -3651,8 +3469,6 @@ DB427DE725BAA00100D1B89D /* MastodonTests */, DB427DF225BAA00100D1B89D /* MastodonUITests */, DB68047E2637CD4C00430867 /* AppShared */, - DB89B9ED25C10FD0008580ED /* CoreDataStack */, - DB89B9F525C10FD0008580ED /* CoreDataStackTests */, DBF8AE12263293E400C9C23C /* NotificationService */, DBC6461126A170AB00B0E31B /* ShareActionExtension */, DB8FABC526AEC7B2008E5AF4 /* MastodonIntent */, @@ -3667,8 +3483,6 @@ files = ( 164F0EBC267D4FE400249499 /* BoopSound.caf in Resources */, DB427DE225BAA00100D1B89D /* LaunchScreen.storyboard in Resources */, - DB564BD0269F2F83001E39A7 /* Localizable.stringsdict in Resources */, - DB3D100D25BAA75E00EAA174 /* Localizable.strings in Resources */, DB427DDF25BAA00100D1B89D /* Assets.xcassets in Resources */, DB427DDD25BAA00100D1B89D /* Main.storyboard in Resources */, DBA4B0F626C269880077136E /* Intents.stringsdict in Resources */, @@ -3700,28 +3514,12 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - DB89B9EC25C10FD0008580ED /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - DB89B9F425C10FD0008580ED /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; DB8FABC426AEC7B2008E5AF4 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( DBA4B0F726C269880077136E /* Intents.stringsdict in Resources */, DBB8AB4F26AED13F00F6D281 /* Assets.xcassets in Resources */, - DBB8AB5126AED14600F6D281 /* Localizable.strings in Resources */, - DBB8AB5026AED14400F6D281 /* Localizable.stringsdict in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -3730,9 +3528,7 @@ buildActionMask = 2147483647; files = ( DBC6461826A170AB00B0E31B /* MainInterface.storyboard in Resources */, - DBC6462726A1736000B0E31B /* Localizable.strings in Resources */, DBC6462826A1736300B0E31B /* Assets.xcassets in Resources */, - DBC6462626A1736000B0E31B /* Localizable.stringsdict in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -3895,6 +3691,23 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; + DB025B8E278D6448002F581E /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if [[ -f \"${PODS_ROOT}/Sourcery/bin/sourcery\" ]]; then\n \"${PODS_ROOT}/Sourcery/bin/sourcery\" --config ./MastodonSDK/Sources/CoreDataStack\nelse\n echo \"warning: Sourcery is not installed. Run 'pod install --repo-update' to install it.\"\nfi\n"; + }; DB3D100425BAA71500EAA174 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -3912,6 +3725,23 @@ shellPath = /bin/sh; shellScript = "if [[ -f \"${PODS_ROOT}/SwiftGen/bin/swiftgen\" ]]; then\n \"${PODS_ROOT}/SwiftGen/bin/swiftgen\" \nelse\n echo \"warning: SwiftGen is not installed. Run 'pod install --repo-update' to install it.\"\nfi\n"; }; + DB697DD2278F48D5004EF2F7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if [[ -f \"${PODS_ROOT}/Sourcery/bin/sourcery\" ]]; then\n \"${PODS_ROOT}/Sourcery/bin/sourcery\" --config ./Mastodon\nelse\n echo \"warning: Sourcery is not installed. Run 'pod install --repo-update' to install it.\"\nfi\n"; + }; E139F888AA77A10B890BFED6 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -3960,45 +3790,46 @@ files = ( DBB525212611EBD6002F1F29 /* ProfilePagingViewController.swift in Sources */, DB6180EB26391C140018D199 /* MediaPreviewTransitionItem.swift in Sources */, + DB63F74727990B0600455B82 /* DataSourceFacade+Hashtag.swift in Sources */, DB98337125C9443200AD9700 /* APIService+Authentication.swift in Sources */, DBE3CDCF261C42ED00430CC6 /* TimelineHeaderView.swift in Sources */, + DB6746E7278ED633008A6B94 /* MastodonAuthenticationBox.swift in Sources */, 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 */, + DB65C63727A2AF6C008BAC2E /* ReportItem.swift in Sources */, 2D59819B25E4A581000FB903 /* MastodonConfirmEmailViewController.swift in Sources */, - DBCBCBFC2680ADB7000F5B51 /* AsyncHomeTimelineViewController.swift in Sources */, DB45FB1D25CA9D23005A8AC7 /* APIService+HomeTimeline.swift in Sources */, DBA94440265D137600C537E1 /* Mastodon+Entity+Field.swift in Sources */, DB49A61425FF2C5600B98345 /* EmojiService.swift in Sources */, DBBF1DC7265251D400E5B703 /* AutoCompleteViewModel+State.swift in Sources */, DB03A793272A7E5700EE37C5 /* SidebarListHeaderView.swift in Sources */, + DB336F2E278D71AF0031E64B /* Status+Property.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 */, 0FB3D2F725E4C24D00AAD544 /* MastodonPickServerViewModel.swift in Sources */, 2D61335E25C1894B00CAE157 /* APIService.swift in Sources */, 2D9DB967263A76FB007C1D71 /* BlockDomainService.swift in Sources */, + DB336F43278EB1690031E64B /* MediaView+Configuration.swift in Sources */, DB66729625F9F91600D60309 /* ComposeStatusSection.swift in Sources */, DB482A3F261331E8008AE74C /* UserTimelineViewModel+State.swift in Sources */, 2D38F1F725CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift in Sources */, - 2DFAD5372617010500F9EE7C /* SearchResultTableViewCell.swift in Sources */, DB447681260B3ED600B66B82 /* CustomEmojiPickerSection.swift in Sources */, - 5BB04FEF262F0DCB0043BFF6 /* ReportViewModel+Data.swift in Sources */, - 5B8E055826319E47006E3C53 /* ReportFooterView.swift in Sources */, + DB0FCB7427956939006C02E2 /* DataSourceFacade+Status.swift in Sources */, DBB525502611ED6D002F1F29 /* ProfileHeaderView.swift in Sources */, - 0FB3D33225E5F50E00AAD544 /* PickServerSearchCell.swift in Sources */, + DB63F75A279953F200455B82 /* SearchHistoryUserCollectionViewCell+ViewModel.swift in Sources */, + DB023D26279FFB0A005AC798 /* ShareActivityProvider.swift in Sources */, DB71FD5225F8CCAA00512AE1 /* APIService+Status.swift in Sources */, + DB8481152788121200BBEABA /* MastodonRegisterTextFieldTableViewCell.swift in Sources */, 5D0393962612D266007FE196 /* WebViewModel.swift in Sources */, 5B24BBDA262DB14800A9381B /* ReportViewModel.swift in Sources */, 2D5A3D3825CF8D9F002347D6 /* ScrollViewContainer.swift in Sources */, DB6180EF26391CA50018D199 /* MediaPreviewImageViewController.swift in Sources */, DB1E347825F519300079D7DF /* PickServerItem.swift in Sources */, + DB336F3F278E668C0031E64B /* StatusTableViewCell+ViewModel.swift in Sources */, + DB63F764279A5E3C00455B82 /* NotificationTimelineViewController.swift in Sources */, DBA5A53126F08EF000CACBAA /* DragIndicatorView.swift in Sources */, DB1FD45A25F27898004CFCFC /* CategoryPickerItem.swift in Sources */, DB6180F626391D580018D199 /* MediaPreviewableViewController.swift in Sources */, @@ -4007,110 +3838,113 @@ DB443CD42694627B00159B29 /* AppearanceView.swift in Sources */, DBF1D24E269DAF5D00C1C08A /* SearchDetailViewController.swift in Sources */, DB8AF53025C13561002E6C99 /* AppContext.swift in Sources */, - DB92CF7225E7BB98002C1017 /* PollOptionTableViewCell.swift in Sources */, DB72602725E36A6F00235243 /* MastodonServerRulesViewModel.swift in Sources */, + DB336F36278D77A40031E64B /* PollOption+Property.swift in Sources */, 2D364F7225E66D7500204FDC /* MastodonResendEmailViewController.swift in Sources */, - DB0C946F26A7D2A80088FB11 /* AvatarImageView.swift in Sources */, - 2D38F1F125CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift in Sources */, DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */, DB02CDAB26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift in Sources */, - DB63BE7F268DD1070011D3F9 /* NotificationViewController+StatusProvider.swift in Sources */, + DB0617FF27855D6C0030EE79 /* MastodonServerRulesViewModel+Diffable.swift in Sources */, DBB5255E2611F07A002F1F29 /* ProfileViewModel.swift in Sources */, - DBAC648F267DC84D007FE9FD /* TableNodeDiffableDataSource.swift in Sources */, + DB0FCB982797F6BF006C02E2 /* UserTableViewCell+ViewModel.swift in Sources */, 2D8434FB25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift in Sources */, + DB697DD6278F4C29004EF2F7 /* DataSourceProvider.swift in Sources */, + DB0FCB8E2796C0B7006C02E2 /* TrendCollectionViewCell.swift in Sources */, 0F1E2D0B2615C39400C38565 /* DoubleTitleLabelNavigationBarTitleView.swift in Sources */, - DBA1DB80268F84F80052DB59 /* NotificationType.swift in Sources */, + DB697DD9278F4CED004EF2F7 /* HomeTimelineViewController+DataSourceProvider.swift in Sources */, DB9A488A26034D40008B817C /* ComposeViewModel+PublishState.swift in Sources */, - DBAC649B267DF8C8007FE9FD /* ActivityIndicatorNode.swift in Sources */, DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */, 2DA504692601ADE7008F4E6C /* SawToothView.swift in Sources */, DBA465952696E387002B41DB /* AppPreference.swift in Sources */, - DB87D4572609DD5300D12C0D /* DeleteBackwardResponseTextField.swift in Sources */, 2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift in Sources */, DB938F0F2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift in Sources */, DB6180F226391CF40018D199 /* MediaPreviewImageViewModel.swift in Sources */, DBA5E7A3263AD0A3004598BB /* PhotoLibraryService.swift in Sources */, + DBD5B1F627BCD3D200BD6B38 /* SuggestionAccountTableViewCell+ViewModel.swift in Sources */, 5DDDF1932617442700311060 /* Mastodon+Entity+Account.swift in Sources */, - DBAE3F882615DDF4004B8251 /* UserProviderFacade.swift in Sources */, + DB63F767279A5EB300455B82 /* NotificationTimelineViewModel.swift in Sources */, 2D607AD826242FC500B70763 /* NotificationViewModel.swift in Sources */, DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */, DB564BD3269F3B35001E39A7 /* StatusFilterService.swift in Sources */, - DB71FD4625F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift in Sources */, + DB0FCB9C27980AB6006C02E2 /* HashtagTimelineViewController+DataSourceProvider.swift in Sources */, + DB63F76F279A7D1100455B82 /* NotificationTableViewCell.swift in Sources */, DB297B1B2679FAE200704C90 /* PlaceholderImageCacheService.swift in Sources */, + DB0FCB8C2796BF8D006C02E2 /* SearchViewModel+Diffable.swift in Sources */, 2D8FCA082637EABB00137F46 /* APIService+FollowRequest.swift in Sources */, DB03A795272A981400EE37C5 /* ContentSplitViewController.swift in Sources */, - 2D152A8C25C295CC009AA50C /* StatusView.swift in Sources */, DBBC24DE26A54BCB00398BB9 /* MastodonMetricFormatter.swift in Sources */, + DB06180A2785B2AB0030EE79 /* MastodonRegisterAvatarTableViewCell.swift in Sources */, + DBB45B6227B51112002DC5A7 /* SuggestionAccountViewModel+Diffable.swift in Sources */, DBB3BA2A26A81C020004F2D4 /* FLAnimatedImageView.swift in Sources */, - 2DE0FAC82615F5F000CDF649 /* SearchRecommendAccountsCollectionViewCell.swift in Sources */, + DB6746ED278F45F0008A6B94 /* AutoGenerateProtocolRelayDelegate.swift in Sources */, + DB0618032785A7100030EE79 /* RegisterSection.swift in Sources */, + DB63F76B279A5ED300455B82 /* NotificationTimelineViewModel+LoadOldestState.swift in Sources */, DBF1D251269DB01200C1C08A /* SearchHistoryViewController.swift in Sources */, - 2D42FF8525C8224F004A627A /* HitTestExpandedButton.swift in Sources */, + DB98EB5E27B10A7A0082E365 /* ReportSupplementaryViewModel+Diffable.swift in Sources */, + DB0FCB7C2795821F006C02E2 /* StatusThreadRootTableViewCell.swift in Sources */, DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */, DBB5250E2611EBAF002F1F29 /* ProfileSegmentedViewController.swift in Sources */, - 2D42FF8F25C8228A004A627A /* UIButton.swift in Sources */, DBF156DF2701B17600EC00B7 /* SidebarAddAccountCollectionViewCell.swift in Sources */, + DB0617F1278413D00030EE79 /* PickServerServerSectionTableHeaderView.swift in Sources */, + DB0FCB7E27958957006C02E2 /* StatusThreadRootTableViewCell+ViewModel.swift in Sources */, DB789A0B25F9F2950071ACA0 /* ComposeViewController.swift in Sources */, DB938F0926240F3C00E5B6C1 /* RemoteThreadViewModel.swift in Sources */, - DBBC24AE26A53DC100398BB9 /* ReplicaStatusView.swift in Sources */, + DB0617ED277F02C50030EE79 /* OnboardingNavigationController.swift in Sources */, + DB0617F527855AB90030EE79 /* ServerRuleSection.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 */, 2D34D9DB261494120081BFC0 /* APIService+Search.swift in Sources */, 5B90C48B26259C120002E742 /* APIService+CoreData+Subscriptions.swift in Sources */, DBB5256E2612D5A1002F1F29 /* ProfileStatusDashboardView.swift in Sources */, DBA9443E265CFA6400C537E1 /* ProfileFieldCollectionViewCell.swift in Sources */, + DB025B93278D6501002F581E /* Persistence.swift in Sources */, 2D24E1232626ED9D00A59D4F /* UIView+Gesture.swift in Sources */, + DBFEEC9D279C12C1004F81DD /* ProfileFieldEditCollectionViewCell.swift in Sources */, DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */, DBA088DF26958164003EB4B2 /* UserFetchedResultsController.swift in Sources */, DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */, 0F202213261351F5000C64BF /* APIService+HashtagTimeline.swift in Sources */, DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */, DBBC24DC26A54BCB00398BB9 /* MastodonRegex.swift in Sources */, - 2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Status.swift in Sources */, - DB4481C625EE2ADA00BEFB67 /* PollSection.swift in Sources */, DBCBED1726132DB500B49291 /* UserTimelineViewModel+Diffable.swift in Sources */, - DB71FD4C25F8C80E00512AE1 /* StatusPrefetchingService.swift in Sources */, 2DE0FACE2615F7AD00CDF649 /* RecommendAccountSection.swift in Sources */, 2DAC9E3E262FC2400062E1A6 /* SuggestionAccountViewModel.swift in Sources */, DB3667A8268AE2900027D07F /* ComposeStatusPollItem.swift in Sources */, DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */, + DB603113279EBEBA00A935FE /* DataSourceFacade+Block.swift in Sources */, + DB336F32278D77330031E64B /* Persistence+Poll.swift in Sources */, + DB63F777279A9A2A00455B82 /* NotificationView+Configuration.swift in Sources */, DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */, - 2D79E701261EA5550011E398 /* APIService+CoreData+Tag.swift in Sources */, DB029E95266A20430062874E /* MastodonAuthenticationController.swift in Sources */, DB0C947726A7FE840088FB11 /* NotificationAvatarButton.swift in Sources */, + DB336F34278D77730031E64B /* Persistence+PollOption.swift in Sources */, 5B90C461262599800002E742 /* SettingsLinkTableViewCell.swift in Sources */, DB6180DD263918E30018D199 /* MediaPreviewViewController.swift in Sources */, DBE3CDEC261C6B2900430CC6 /* FavoriteViewController.swift in Sources */, DB938EE62623F50700E5B6C1 /* ThreadViewController.swift in Sources */, DB6180F426391D110018D199 /* MediaPreviewImageView.swift in Sources */, + DB336F41278E68480031E64B /* StatusView+Configuration.swift in Sources */, DBF9814A265E24F500E4BA07 /* ProfileFieldCollectionViewHeaderFooterView.swift in Sources */, 2D939AB525EDD8A90076FA61 /* String.swift in Sources */, DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */, DBE3CDBB261C427900430CC6 /* TimelineHeaderTableViewCell.swift in Sources */, - DBCBCBFF2680AE98000F5B51 /* AsyncHomeTimelineViewModel.swift in Sources */, + DB159C2B27A17BAC0068DC77 /* DataSourceFacade+Media.swift in Sources */, 0FAA101C25E10E760017CCDE /* UIFont.swift in Sources */, 2D38F1D525CD465300561493 /* HomeTimelineViewController.swift in Sources */, - DBAC6497267DECCB007FE9FD /* TimelineMiddleLoaderNode.swift in Sources */, - DB97131F2666078B00BD1E90 /* Date.swift in Sources */, - DBAC6485267D0F9E007FE9FD /* StatusNode.swift in Sources */, - DB98338825C945ED00AD9700 /* Assets.swift in Sources */, DB6180E926391BDF0018D199 /* MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift in Sources */, DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift in Sources */, + DB8F7076279E954700E1225B /* DataSourceFacade+Follow.swift in Sources */, DB36679F268ABAF20027D07F /* ComposeStatusAttachmentSection.swift in Sources */, 2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */, - DB87D44B2609C11900D12C0D /* PollOptionView.swift in Sources */, + DB63F7542799491600455B82 /* DataSourceFacade+SearchHistory.swift in Sources */, DBF1572F27046F1A00EC00B7 /* SecondaryPlaceholderViewController.swift in Sources */, DB03F7F32689AEA3007B274C /* ComposeRepliedToStatusContentTableViewCell.swift in Sources */, 2D4AD8A826316D3500613EFC /* SelectedAccountItem.swift in Sources */, DBE3CDFB261C6CA500430CC6 /* FavoriteViewModel.swift in Sources */, DB8AF52F25C13561002E6C99 /* DocumentStore.swift in Sources */, - 2D206B7225F5D27F00143C56 /* AudioContainerView.swift in Sources */, DBBC24C126A5443100398BB9 /* SystemTheme.swift in Sources */, - DB9D6C2425E502C60051B173 /* MosaicImageViewModel.swift in Sources */, DBE3CE01261D623D00430CC6 /* FavoriteViewModel+State.swift in Sources */, 2D82BA0525E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift in Sources */, 2D38F1EB25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift in Sources */, @@ -4119,25 +3953,26 @@ 0F202201261326E6000C64BF /* HashtagTimelineViewModel.swift in Sources */, DB6D9F9726367249008423CD /* SettingsViewController.swift in Sources */, DB4F097F26A03DA600D62E92 /* SearchHistoryFetchedResultController.swift in Sources */, + DB63F7452799056400455B82 /* HashtagTableViewCell.swift in Sources */, DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */, DBAE3FAF26172FC0004B8251 /* RemoteProfileViewModel.swift in Sources */, - DBE3CE0D261D767100430CC6 /* FavoriteViewController+Provider.swift in Sources */, - 2D084B9326259545003AA3AF /* NotificationViewModel+LoadLatestState.swift in Sources */, + DB0FCB7227952986006C02E2 /* NamingState.swift in Sources */, DB73BF47271199CA00781945 /* Instance.swift in Sources */, DB0F8150264D1E2500F2A12B /* PickServerLoaderTableViewCell.swift in Sources */, DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */, + DB98EB5327B0F9890082E365 /* ReportHeadlineTableViewCell.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 */, + DB63F76227996B6600455B82 /* SearchHistoryViewController+DataSourceProvider.swift in Sources */, DB73BF4927140BA300781945 /* UICollectionViewDiffableDataSource.swift in Sources */, DBA5E7AB263BD3F5004598BB /* TimelineTableViewCellContextMenuConfiguration.swift in Sources */, DB73B490261F030A002E9E9F /* SafariActivity.swift in Sources */, + DB63F7492799126300455B82 /* FollowerListViewController+DataSourceProvider.swift in Sources */, DB6D1B44263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift in Sources */, DB9A48962603685D008B817C /* MastodonAttachmentService+UploadState.swift in Sources */, 2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */, @@ -4145,37 +3980,32 @@ DB66728C25F9F8DC00D60309 /* ComposeViewModel+DataSource.swift in Sources */, DB6180E02639194B0018D199 /* MediaPreviewPagingViewController.swift in Sources */, DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */, - 2DF75B9B25D0E27500694EC8 /* StatusProviderFacade.swift in Sources */, 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 */, + DB025B78278D606A002F581E /* StatusItem.swift in Sources */, + DB697DD4278F4927004EF2F7 /* StatusTableViewCellDelegate.swift in Sources */, + DB0FCB902796C5EB006C02E2 /* APIService+Trend.swift in Sources */, DBA5E7A5263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift in Sources */, DB9D7C21269824B80054B3DF /* APIService+Filter.swift in Sources */, 2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */, - DBCBCC012680AF2A000F5B51 /* AsyncHomeTimelineViewModel+Diffable.swift in Sources */, + DB0FCB842796B2A2006C02E2 /* FavoriteViewController+DataSourceProvider.swift in Sources */, DBCC3B36261440BA0045B23D /* UINavigationController.swift in Sources */, DBB525852612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift in Sources */, + DB0FCB68279507EF006C02E2 /* DataSourceFacade+Meta.swift in Sources */, + DB63F75C279956D000455B82 /* Persistence+Tag.swift in Sources */, 2D84350525FF858100EECE90 /* UIScrollView.swift in Sources */, DB49A61F25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift in Sources */, - 2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */, 5B24BBDB262DB14800A9381B /* ReportViewModel+Diffable.swift in Sources */, - DB71FD3C25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift in Sources */, - 2DA6055125F74407006356F9 /* AudioContainerViewModel.swift in Sources */, DB4F0968269ED8AD00D62E92 /* SearchHistoryTableHeaderView.swift in Sources */, - 0FB3D2FE25E4CB6400AAD544 /* PickServerTitleCell.swift in Sources */, + 0FB3D2FE25E4CB6400AAD544 /* OnboardingHeadlineTableViewCell.swift in Sources */, 5DA732CC2629CEF500A92342 /* UIView+Remove.swift in Sources */, - 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 */, @@ -4183,13 +4013,12 @@ 5DDDF1992617447F00311060 /* Mastodon+Entity+Tag.swift in Sources */, 5B90C45F262599800002E742 /* SettingsToggleTableViewCell.swift in Sources */, 2D694A7425F9EB4E0038ADDC /* ContentWarningOverlayView.swift in Sources */, + DB0FCB7827957678006C02E2 /* DataSourceProvider+UITableViewDelegate.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 */, + DB98EB6227B215EB0082E365 /* ReportResultViewController.swift in Sources */, DB6B74FA272FC2B500C70B6E /* APIService+Follower.swift in Sources */, DB6B74F4272FBAE700C70B6E /* FollowerListViewModel+Diffable.swift in Sources */, DB6B74F2272FB67600C70B6E /* FollowerListViewModel.swift in Sources */, @@ -4198,238 +4027,254 @@ DB0009A626AEE5DC009B9D2D /* Intents.intentdefinition in Sources */, 5B90C462262599800002E742 /* SettingsSectionHeader.swift in Sources */, DB44768B260B3F2100B66B82 /* CustomEmojiPickerItem.swift in Sources */, - 2DF75BA125D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift in Sources */, 5DF1056425F887CB00D6C0D4 /* AVPlayer.swift in Sources */, DBBF1DCB2652539E00E5B703 /* AutoCompleteItem.swift in Sources */, + DB84811727883C2600BBEABA /* MastodonRegisterPasswordHintTableViewCell.swift in Sources */, 2DA6054725F716A2006356F9 /* PlaybackState.swift in Sources */, DB35FC1F2612F1D9006193C9 /* ProfileRelationshipActionButton.swift in Sources */, + DB98EB6727B216560082E365 /* ReportResultViewModel+Diffable.swift in Sources */, DBC7A672260C897100E57475 /* StatusContentWarningEditorView.swift in Sources */, + DB025B95278D6530002F581E /* Persistence+MastodonUser.swift in Sources */, DB3667A6268AE2620027D07F /* ComposeStatusPollSection.swift in Sources */, DB6B750427300B4000C70B6E /* TimelineFooterTableViewCell.swift in Sources */, + DB98EB4C27B0F2BC0082E365 /* ReportStatusTableViewCell+ViewModel.swift in Sources */, DB59F10E25EF724F001F1DAB /* APIService+Poll.swift in Sources */, DB852D1F26FB037800FC9D81 /* SidebarViewModel.swift in Sources */, + DB63F769279A5EBB00455B82 /* NotificationTimelineViewModel+Diffable.swift in Sources */, DB47229725F9EFAD00DA7F53 /* NSManagedObjectContext.swift in Sources */, + DB63F75E27995B3B00455B82 /* Tag+Property.swift in Sources */, + DBFEEC9B279BDDD9004F81DD /* ProfileAboutViewModel+Diffable.swift in Sources */, 2D34D9D126148D9E0081BFC0 /* APIService+Recommend.swift in Sources */, DBB525562611EDCA002F1F29 /* UserTimelineViewModel.swift in Sources */, - 2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */, + DB0618012785732C0030EE79 /* ServerRulesTableViewCell.swift in Sources */, DB221B16260C395900AEFE46 /* CustomEmojiPickerInputViewModel.swift in Sources */, + DB98EB5C27B10A730082E365 /* ReportSupplementaryViewModel.swift in Sources */, + DB0617EF277F12720030EE79 /* NavigationActionView.swift in Sources */, DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */, 2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */, - 2D084B8D26258EA3003AA3AF /* NotificationViewModel+Diffable.swift in Sources */, DB3667A1268ABB2E0027D07F /* ComposeStatusAttachmentItem.swift in Sources */, DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */, DBA94434265CBB5300C537E1 /* ProfileFieldSection.swift in Sources */, + DB336F28278D6EC70031E64B /* MastodonFieldContainer.swift in Sources */, DBF156E42702DB3F00EC00B7 /* HandleTapAction.swift in Sources */, - DB023295267F0AB800031745 /* ASMetaEditableTextNode.swift in Sources */, + DB98EB4727B0DFAA0082E365 /* ReportViewModel+State.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 */, + DB336F2C278D6FC30031E64B /* Persistence+Status.swift in Sources */, + DB336F2A278D6F2B0031E64B /* MastodonField.swift in Sources */, + DB0FCB7A279576A2006C02E2 /* DataSourceFacade+Thread.swift in Sources */, DB9F58EF26EF491E00E7BBE9 /* AccountListViewModel.swift in Sources */, DB6D9F7D26358ED4008423CD /* SettingsSection.swift in Sources */, - DB0E91EA26A9675100BD2ACC /* MetaLabel.swift in Sources */, + DB0FCB9A2797F7AD006C02E2 /* UserView+Configuration.swift in Sources */, + DB023D2827A0FABD005AC798 /* NotificationTableViewCellDelegate.swift in Sources */, DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */, + DB023D2C27A10464005AC798 /* NotificationTimelineViewController+DataSourceProvider.swift in Sources */, DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */, - 2D38F1C625CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift in Sources */, DBF1D257269DBAC600C1C08A /* SearchDetailViewModel.swift in Sources */, DB03F7F52689B782007B274C /* ComposeTableView.swift in Sources */, - 2D24E11D2626D8B100A59D4F /* NotificationStatusTableViewCell.swift in Sources */, + DBB45B5927B39FE4002DC5A7 /* MediaPreviewVideoViewModel.swift in Sources */, DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */, + DB0FCB76279571C5006C02E2 /* ThreadViewController+DataSourceProvider.swift in Sources */, + DB0FCB7027951368006C02E2 /* TimelineMiddleLoaderTableViewCell+ViewModel.swift in Sources */, DB1E346825F518E20079D7DF /* CategoryPickerSection.swift in Sources */, DB7274F4273BB9B200577D95 /* ListBatchFetchViewModel.swift in Sources */, + DB0618052785A73D0030EE79 /* RegisterItem.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 */, + DB4AA6B327BA34B6009EC082 /* CellFrameCacheContainer.swift in Sources */, + DB0FCB942797E2B0006C02E2 /* SearchResultViewModel+Diffable.swift in Sources */, + DB63F752279944AA00455B82 /* SearchHistorySectionHeaderCollectionReusableView.swift in Sources */, DBBC24C426A544B900398BB9 /* Theme.swift in Sources */, DB938EED2623F79B00E5B6C1 /* ThreadViewModel.swift in Sources */, DBBC24AC26A53D9300398BB9 /* ComposeStatusContentTableViewCell.swift in Sources */, DBC7A67C260DFADE00E57475 /* StatusPublishService.swift in Sources */, - DBCBCC092680B01B000F5B51 /* AsyncHomeTimelineViewModel+LoadMiddleState.swift in Sources */, 2DCB73FD2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift in Sources */, DB852D1C26FB021500FC9D81 /* RootSplitViewController.swift in Sources */, + DB697DD1278F4871004EF2F7 /* AutoGenerateTableViewDelegate.swift in Sources */, DB02CDBF2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift in Sources */, DB1FD44425F26CCC004CFCFC /* PickServerSection.swift in Sources */, + DBB45B6027B50A4F002DC5A7 /* RecommendAccountItem.swift in Sources */, 0FB3D30F25E525CD00AAD544 /* PickServerCategoryView.swift in Sources */, DB6180E626391B550018D199 /* MediaPreviewTransitionController.swift in Sources */, - 5BB04FDB262EA3070043BFF6 /* ReportHeaderView.swift in Sources */, + DB0FCB922796DE19006C02E2 /* TrendSectionHeaderCollectionReusableView.swift in Sources */, DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */, + DB63F779279ABF9C00455B82 /* DataSourceFacade+Reblog.swift in Sources */, DB4F0963269ED06300D62E92 /* SearchResultViewController.swift in Sources */, DBBF1DC5265251C300E5B703 /* AutoCompleteViewModel+Diffable.swift in Sources */, + DB603111279EB38500A935FE /* DataSourceFacade+Mute.swift in Sources */, DB68A04A25E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift in Sources */, DBBC24BC26A542F500398BB9 /* ThemeService.swift in Sources */, + DB336F38278D7AAF0031E64B /* Poll+Property.swift in Sources */, 0FB3D33825E6401400AAD544 /* PickServerCell.swift in Sources */, DB6D9F8426358EEC008423CD /* SettingsItem.swift in Sources */, 2D364F7825E66D8300204FDC /* MastodonResendEmailViewModel.swift in Sources */, - DBCBCC052680AFB9000F5B51 /* AsyncHomeTimelineViewController+Provider.swift in Sources */, DBA465932696B495002B41DB /* APIService+WebFinger.swift in Sources */, DB8AF54525C13647002E6C99 /* NeedsDependency.swift in Sources */, + DB63F77B279ACAE500455B82 /* DataSourceFacade+Favorite.swift in Sources */, DB9D6BF825E4F5690051B173 /* NotificationViewController.swift in Sources */, 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 */, DB6F5E35264E78E7009108F4 /* AutoCompleteViewController.swift in Sources */, - DBA94438265CBD4D00C537E1 /* ProfileHeaderViewModel+Diffable.swift in Sources */, - 0F20220D26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift in Sources */, + DB697DE1278F5296004EF2F7 /* DataSourceFacade+Model.swift in Sources */, DBCC3B8F26148F7B0045B23D /* CachedProfileViewModel.swift in Sources */, DB4F097526A037F500D62E92 /* SearchHistoryViewModel.swift in Sources */, - 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 */, + DB98EB5627B0FF1B0082E365 /* ReportViewControllerAppearance.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 */, - DBCBCC072680AFEC000F5B51 /* AsyncHomeTimelineViewModel+LoadLatestState.swift in Sources */, 2D4AD89C263165B500613EFC /* SuggestionAccountCollectionViewCell.swift in Sources */, + DB98EB6927B21A7C0082E365 /* ReportResultActionTableViewCell.swift in Sources */, DB447691260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift in Sources */, DB9282B225F3222800823B15 /* PickServerEmptyStateView.swift in Sources */, + DB697DDF278F524F004EF2F7 /* DataSourceFacade+Profile.swift in Sources */, DB1FD45025F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift in Sources */, DBD376AC2692ECDB007FEC24 /* ThemePreference.swift in Sources */, DB4F097D26A03A5B00D62E92 /* SearchHistoryItem.swift in Sources */, + DBD5B1FA27BD013700BD6B38 /* DataSourceProvider+StatusTableViewControllerNavigateable.swift in Sources */, DB68046C2636DC9E00430867 /* MastodonNotification.swift in Sources */, DBAE3F9E2616E308004B8251 /* APIService+Mute.swift in Sources */, DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */, DB6D9F57263577D2008423CD /* APIService+CoreData+Setting.swift in Sources */, - DB9D6C0E25E4F9780051B173 /* MosaicImageViewContainer.swift in Sources */, + DB0FCB822796AC78006C02E2 /* UserTimelineViewController+DataSourceProvider.swift in Sources */, + DB63F773279A87DC00455B82 /* Notification+Property.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 */, DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */, DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */, 2D5A3D0325CF8742002347D6 /* ControlContainableScrollViews.swift in Sources */, DB36679D268AB91B0027D07F /* ComposeStatusAttachmentTableViewCell.swift in Sources */, - DB0C947226A7D2D70088FB11 /* AvatarButton.swift in Sources */, DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */, + DB894CC427A5490600684B74 /* BlurhashImageCacheService.swift in Sources */, DBFEF07B26A6BCE8006D7ED1 /* APIService+Status+Publish.swift in Sources */, - DBCBCC032680AF6E000F5B51 /* AsyncHomeTimelineViewController+DebugAction.swift in Sources */, DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */, - 2D32EADA25CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift in Sources */, 5B90C48526259BF10002E742 /* APIService+Subscriptions.swift in Sources */, + DBFEEC96279BDC67004F81DD /* ProfileAboutViewController.swift in Sources */, + DB63F74F2799405600455B82 /* SearchHistoryViewModel+Diffable.swift in Sources */, + DB336F23278D6DED0031E64B /* MastodonEmojiContainer.swift in Sources */, 0F20223926146553000C64BF /* Array.swift in Sources */, DB0EF72B26FDB1D200347686 /* SidebarListCollectionViewCell.swift in Sources */, 5B90C460262599800002E742 /* SettingsAppearanceTableViewCell.swift in Sources */, - DB0C946B26A700AB0088FB11 /* MastodonUser+Property.swift in Sources */, + DB63F74D27993F5B00455B82 /* SearchHistoryUserCollectionViewCell.swift in Sources */, DB8AF54425C13647002E6C99 /* SceneCoordinator.swift in Sources */, - 5DF1058525F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift in Sources */, DB73BF45271195AC00781945 /* APIService+CoreData+Instance.swift in Sources */, + DB336F21278D6D960031E64B /* MastodonEmoji.swift in Sources */, DB1D84382657B275000346B3 /* SegmentedControlNavigateable.swift in Sources */, DB447697260B439000B66B82 /* CustomEmojiPickerHeaderCollectionReusableView.swift in Sources */, + DB025B97278D66D5002F581E /* MastodonUser+Property.swift in Sources */, DB45FAF925CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift in Sources */, - 2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */, - 2D34D9E226149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift in Sources */, - 2D76317D25C14DF500929FB9 /* PublicTimelineViewController+Provider.swift in Sources */, - 0F20223326145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift in Sources */, + DB0FCB6C27950E29006C02E2 /* MastodonMentionContainer.swift in Sources */, DB6D9F502635761F008423CD /* SubscriptionAlerts.swift in Sources */, 0F20220726134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift in Sources */, DBE54AC62636C89F004E7C0B /* NotificationPreference.swift in Sources */, 2D5A3D2825CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift in Sources */, DB98339C25C96DE600AD9700 /* APIService+Account.swift in Sources */, + DB0618072785A8880030EE79 /* MastodonRegisterViewModel+Diffable.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 */, + DB0FCB862796BDA1006C02E2 /* SearchSection.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 */, - 2D76318325C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift in Sources */, DBBF1DBF2652401B00E5B703 /* AutoCompleteViewModel.swift in Sources */, DB6180FA26391F2E0018D199 /* MediaPreviewViewModel.swift in Sources */, - 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 */, + DB98EB6B27B243470082E365 /* SettingsAppearanceTableViewCell+ViewModel.swift in Sources */, + DBB45B5B27B3A109002DC5A7 /* MediaPreviewTransitionViewController.swift in Sources */, DB482A4B261340A7008AE74C /* APIService+UserTimeline.swift in Sources */, DB427DD825BAA00100D1B89D /* SceneDelegate.swift in Sources */, DB4932B926F31AD300EF46D4 /* BadgeButton.swift in Sources */, 0F2021FB2613262F000C64BF /* HashtagTimelineViewController.swift in Sources */, + DB697DDD278F521D004EF2F7 /* DataSourceFacade.swift in Sources */, DBCC3B30261440A50045B23D /* UITabBarController.swift in Sources */, DB8190C62601FF0400020C08 /* AttachmentContainerView.swift in Sources */, + DB697DDB278F4DE3004EF2F7 /* DataSourceProvider+StatusTableViewCellDelegate.swift in Sources */, DB51D173262832380062B7A1 /* BlurHashEncode.swift in Sources */, 2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */, - DB71FD2C25F86A5100512AE1 /* AvatarStackContainerButton.swift in Sources */, DB87D4512609CF1E00D12C0D /* ComposeStatusPollOptionAppendEntryCollectionViewCell.swift in Sources */, + DBB45B5627B39FC9002DC5A7 /* MediaPreviewVideoViewController.swift in Sources */, DBBC24C026A5443100398BB9 /* MastodonTheme.swift in Sources */, + DB0FCB8027968F70006C02E2 /* MastodonStatusThreadViewModel.swift in Sources */, + DB0FCB6E27950E6B006C02E2 /* MastodonMention.swift in Sources */, DB67D08627312E67006A36CF /* WizardViewController.swift in Sources */, - DBBC24B526A540AE00398BB9 /* AvatarConfigurableView.swift in Sources */, + DB6746EB278ED8B0008A6B94 /* PollOptionView+Configuration.swift in Sources */, DB9A489026035963008B817C /* APIService+Media.swift in Sources */, - DBFEF07726A691FB006D7ED1 /* MastodonAuthenticationBox.swift in Sources */, + DBFEEC99279BDCDE004F81DD /* ProfileAboutViewModel.swift in Sources */, DBBC24CF26A547AE00398BB9 /* ThemeService+Appearance.swift in Sources */, 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 */, DB4924E226312AB200E9DB22 /* NotificationService.swift in Sources */, DB6D9F6F2635807F008423CD /* Setting.swift in Sources */, DB6F5E38264E994A009108F4 /* AutoCompleteTopChevronView.swift in Sources */, + DB6746F0278F463B008A6B94 /* AutoGenerateProtocolDelegate.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 */, + DB63F756279949BD00455B82 /* Persistence+SearchHistory.swift in Sources */, 2D4AD8A226316CD200613EFC /* SelectedAccountSection.swift in Sources */, + DB63F775279A997D00455B82 /* NotificationTableViewCell+ViewModel.swift in Sources */, + DB98EB5927B109890082E365 /* ReportSupplementaryViewController.swift in Sources */, + DB0617EB277EF3820030EE79 /* GradientBorderView.swift in Sources */, DB789A1225F9F2CC0071ACA0 /* ComposeViewModel.swift in Sources */, + DB63F74B279914A000455B82 /* FollowingListViewController+DataSourceProvider.swift in Sources */, DBB525362611ECEB002F1F29 /* UserTimelineViewController.swift in Sources */, DB6D1B3D2636857500ACB481 /* AppearancePreference.swift in Sources */, DB938F3326243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift in Sources */, + DB98EB4927B0F0CD0082E365 /* ReportStatusTableViewCell.swift in Sources */, DB3667A4268AE2370027D07F /* ComposeStatusPollTableViewCell.swift in Sources */, DBBF1DC226524D2900E5B703 /* AutoCompleteTableViewCell.swift in Sources */, - 2D38F1FE25CD481700561493 /* StatusProvider.swift in Sources */, - DB1EE7B2267F9525000CC337 /* StatusProvider+StatusNodeDelegate.swift in Sources */, 5B24BBE2262DB19100A9381B /* APIService+Report.swift in Sources */, DBF3B7412733EB9400E21627 /* MastodonLocalCode.swift in Sources */, + DB98EB6527B216500082E365 /* ReportResultViewModel.swift in Sources */, DB4F096A269EDAD200D62E92 /* SearchResultViewModel+State.swift in Sources */, 5BB04FF5262F0E6D0043BFF6 /* ReportSection.swift in Sources */, DBA94436265CBB7400C537E1 /* ProfileFieldItem.swift in Sources */, - DB938F25262438D600E5B6C1 /* ThreadViewController+Provider.swift in Sources */, + DB023D2A27A0FE5C005AC798 /* DataSourceProvider+NotificationTableViewCellDelegate.swift in Sources */, + DB98EB6027B10E150082E365 /* ReportCommentTableViewCell.swift in Sources */, + DB0FCB962797E6C2006C02E2 /* SearchResultViewController+DataSourceProvider.swift in Sources */, DB66729C25F9F91F00D60309 /* ComposeStatusItem.swift in Sources */, DB6180E326391A4C0018D199 /* ViewControllerAnimatedTransitioning.swift in Sources */, + DBD5B1F827BCFD9D00BD6B38 /* DataSourceProvider+TableViewControllerNavigateable.swift in Sources */, 0FB3D31E25E534C700AAD544 /* PickServerCategoryCollectionViewCell.swift in Sources */, - DB1D842C26551A1C000346B3 /* StatusProvider+StatusTableViewKeyCommandNavigateable.swift in Sources */, - 5DF1057925F88A1D00D6C0D4 /* PlayerContainerView.swift in Sources */, - DB1D843626579DB5000346B3 /* StatusProvider+TableViewControllerNavigateable.swift in Sources */, + DB0FCB882796BDA9006C02E2 /* SearchItem.swift in Sources */, + DB336F3D278D80040031E64B /* FeedFetchedResultsController.swift in Sources */, DB6D9F4926353FD7008423CD /* Subscription.swift in Sources */, DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */, DB6180ED26391C6C0018D199 /* TransitioningMath.swift in Sources */, + DB63F771279A858500455B82 /* Persistence+Notification.swift in Sources */, 2D6DE40026141DF600A63F6A /* SearchViewModel.swift in Sources */, DB51D172262832380062B7A1 /* BlurHashDecode.swift in Sources */, - DBAFB7352645463500371D5F /* Emojis.swift in Sources */, DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */, - DBE3CE13261D7D4200430CC6 /* StatusTableViewControllerAspect.swift in Sources */, + DB0617FD27855BFE0030EE79 /* ServerRuleItem.swift in Sources */, 5BB04FD5262E7AFF0043BFF6 /* ReportViewController.swift in Sources */, DBAE3F942616E28B004B8251 /* APIService+Follow.swift in Sources */, ); @@ -4447,6 +4292,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + DB47AB6227CF752B00CD73C7 /* MastodonUISnapshotTests.swift in Sources */, DB427DF825BAA00100D1B89D /* MastodonUITests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -4458,67 +4304,19 @@ 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 */, ); runOnlyForDeploymentPostprocessing = 0; }; - DB89B9EA25C10FD0008580ED /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 2DA7D05725CA693F00804E11 /* Application.swift in Sources */, - 2D927F1425C7EDD9004F19B8 /* Emoji.swift in Sources */, - 2DF75BC725D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift in Sources */, - DB89BA1225C1105C008580ED /* CoreDataStack.swift in Sources */, - 5B90C46F26259B2C0002E742 /* Setting.swift in Sources */, - DB89BA1C25C1107F008580ED /* NSManagedObjectContext.swift in Sources */, - DB9D6C2E25E504AC0051B173 /* Attachment.swift in Sources */, - 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 */, - DB89BA2725C110B4008580ED /* Status.swift in Sources */, - 2D152A9225C2980C009AA50C /* UIFont.swift in Sources */, - DB4481B325EE16D000BEFB67 /* PollOption.swift in Sources */, - DB89BA4425C1165F008580ED /* Managed.swift in Sources */, - 2D6125472625436B00299647 /* Notification.swift in Sources */, - DB89BA4325C1165F008580ED /* NetworkUpdatable.swift in Sources */, - DB8AF56825C13E2A002E6C99 /* HomeTimelineIndex.swift in Sources */, - DB45FAED25CA7A9A005A8AC7 /* MastodonAuthentication.swift in Sources */, - 2DF75BB925D1474100694EC8 /* ManagedObjectObserver.swift in Sources */, - 2D927F0225C7E4F2004F19B8 /* Mention.swift in Sources */, - DB89BA1D25C1107F008580ED /* URL.swift in Sources */, - 2D9DB969263A833E007C1D71 /* DomainBlock.swift in Sources */, - 2D0B7A1D261D839600B44727 /* SearchHistory.swift in Sources */, - 2D927F0825C7E9A8004F19B8 /* Tag.swift in Sources */, - 5B90C46E26259B2C0002E742 /* Subscription.swift in Sources */, - 5B90C47F26259BA90002E742 /* SubscriptionAlerts.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - DB89B9F225C10FD0008580ED /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - DB89B9FE25C10FD0008580ED /* CoreDataStackTests.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; DB8FABC226AEC7B2008E5AF4 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( DB0009A726AEE5DC009B9D2D /* Intents.intentdefinition in Sources */, - DBB8AB4E26AED12E00F6D281 /* Assets.swift in Sources */, DBB8AB4626AECDE200F6D281 /* SendPostIntentHandler.swift in Sources */, - DBB8AB5326AED25100F6D281 /* MastodonAuthenticationBox.swift in Sources */, DBB8AB4A26AED0B500F6D281 /* APIService.swift in Sources */, DBB8AB4C26AED11300F6D281 /* APIService+APIError.swift in Sources */, - DBB8AB4D26AED12B00F6D281 /* Strings.swift in Sources */, + DB6746E9278ED63F008A6B94 /* MastodonAuthenticationBox.swift in Sources */, DBB8AB5226AED1B300F6D281 /* APIService+Status+Publish.swift in Sources */, DB8FABCA26AEC7B2008E5AF4 /* IntentHandler.swift in Sources */, ); @@ -4535,25 +4333,22 @@ DBFEF06D26A67FB7006D7ED1 /* StatusAttachmentViewModel+UploadState.swift in Sources */, DBBC24CB26A546C000398BB9 /* ThemePreference.swift in Sources */, DBFEF05F26A57715006D7ED1 /* StatusAuthorView.swift in Sources */, + DB336F1C278D697E0031E64B /* MastodonUser.swift in Sources */, DBFEF05D26A57715006D7ED1 /* ContentWarningEditorView.swift in Sources */, DBFEF07526A69192006D7ED1 /* APIService+Media.swift in Sources */, DBFEF06F26A690C4006D7ED1 /* APIService+APIError.swift in Sources */, DBFEF05C26A57715006D7ED1 /* StatusEditorView.swift in Sources */, DBBC24C726A5456400398BB9 /* SystemTheme.swift in Sources */, - DBC6462926A1736700B0E31B /* Strings.swift in Sources */, DBBC24C826A5456400398BB9 /* ThemeService.swift in Sources */, DBBC24C926A5456400398BB9 /* MastodonTheme.swift in Sources */, DBFEF07C26A6BD0A006D7ED1 /* APIService+Status+Publish.swift in Sources */, DBB3BA2B26A81D060004F2D4 /* FLAnimatedImageView.swift in Sources */, + DB6746E8278ED639008A6B94 /* MastodonAuthenticationBox.swift in Sources */, DBBC24A826A52F9000398BB9 /* ComposeToolbarView.swift in Sources */, DBFEF05B26A57715006D7ED1 /* ComposeViewModel.swift in Sources */, DBBC24C626A5456000398BB9 /* Theme.swift in Sources */, DBFEF06326A577F2006D7ED1 /* StatusAttachmentViewModel.swift in Sources */, - DBBC24D226A5488600398BB9 /* AvatarConfigurableView.swift in Sources */, - DBC6462C26A176B000B0E31B /* Assets.swift in Sources */, DBFEF06926A67E45006D7ED1 /* AppearancePreference.swift in Sources */, - DBFEF07826A69209006D7ED1 /* MastodonAuthenticationBox.swift in Sources */, - DB0C946C26A700CE0088FB11 /* MastodonUser+Property.swift in Sources */, DBC6461526A170AB00B0E31B /* ShareViewController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -4599,26 +4394,6 @@ target = DB68047E2637CD4C00430867 /* AppShared */; targetProxy = DB6804C92637CE3000430867 /* PBXContainerItemProxy */; }; - DB6805292637D7DD00430867 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = DB68047E2637CD4C00430867 /* AppShared */; - targetProxy = DB6805282637D7DD00430867 /* PBXContainerItemProxy */; - }; - DB89B9F925C10FD0008580ED /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = DB89B9ED25C10FD0008580ED /* CoreDataStack */; - targetProxy = DB89B9F825C10FD0008580ED /* PBXContainerItemProxy */; - }; - DB89B9FB25C10FD0008580ED /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = DB427DD125BAA00100D1B89D /* Mastodon */; - targetProxy = DB89B9FA25C10FD0008580ED /* PBXContainerItemProxy */; - }; - DB89BA0225C10FD0008580ED /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = DB89B9ED25C10FD0008580ED /* CoreDataStack */; - targetProxy = DB89BA0125C10FD0008580ED /* PBXContainerItemProxy */; - }; DB8FABCD26AEC7B2008E5AF4 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = DB8FABC526AEC7B2008E5AF4 /* MastodonIntent */; @@ -4629,11 +4404,6 @@ target = DB68047E2637CD4C00430867 /* AppShared */; targetProxy = DB8FABD926AEC873008E5AF4 /* PBXContainerItemProxy */; }; - DB8FABDF26AEC87B008E5AF4 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = DB89B9ED25C10FD0008580ED /* CoreDataStack */; - targetProxy = DB8FABDE26AEC87B008E5AF4 /* PBXContainerItemProxy */; - }; DBC6461B26A170AB00B0E31B /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = DBC6461126A170AB00B0E31B /* ShareActionExtension */; @@ -4644,11 +4414,6 @@ target = DB68047E2637CD4C00430867 /* AppShared */; targetProxy = DBC6463526A195DB00B0E31B /* PBXContainerItemProxy */; }; - DBC6463A26A195DB00B0E31B /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = DB89B9ED25C10FD0008580ED /* CoreDataStack */; - targetProxy = DBC6463926A195DB00B0E31B /* PBXContainerItemProxy */; - }; DBF8AE19263293E400C9C23C /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = DBF8AE12263293E400C9C23C /* NotificationService */; @@ -4674,7 +4439,9 @@ DB4B777F26CA4EFA00B087B3 /* ru */, DB4B778426CA500E00B087B3 /* gd-GB */, DB4B779226CA50BA00B087B3 /* th */, - DBDC1CF9272C0FD600055C3D /* ku-TR */, + DB126A4C278C063F005726EE /* eu-ES */, + DB126A56278C088D005726EE /* sv-FI */, + DBEB19E927E4F37B00B0E80E /* ku */, ); name = Intents.intentdefinition; sourceTree = "<group>"; @@ -4695,32 +4462,13 @@ DB4B778226CA4EFA00B087B3 /* ru */, DB4B778726CA500E00B087B3 /* gd-GB */, DB4B779526CA50BA00B087B3 /* th */, - DBDC1CFC272C0FD600055C3D /* ku-TR */, + DB126A4F278C063F005726EE /* eu-ES */, + DB126A59278C088D005726EE /* sv-FI */, + DBEB19EA27E4F37B00B0E80E /* ku */, ); name = InfoPlist.strings; sourceTree = "<group>"; }; - DB3D100F25BAA75E00EAA174 /* Localizable.strings */ = { - isa = PBXVariantGroup; - children = ( - DB3D100E25BAA75E00EAA174 /* en */, - DB0F814D264CFFD300F2A12B /* ar */, - E9AABD3F26B64B8D00E237DA /* ja */, - DBA4B0D526BD10AD0077136E /* zh-Hans */, - DBA4B0D926BD10F40077136E /* ca */, - DBA4B0DD26BD11130077136E /* fr */, - DBA4B0E126BD11C80077136E /* es */, - DBA4B0E526BD11D10077136E /* es-419 */, - DBA4B0EA26C153820077136E /* de */, - DBA4B0EE26C153B20077136E /* nl */, - DB4B778126CA4EFA00B087B3 /* ru */, - DB4B778626CA500E00B087B3 /* gd-GB */, - DB4B779426CA50BA00B087B3 /* th */, - DBDC1CFB272C0FD600055C3D /* ku-TR */, - ); - name = Localizable.strings; - sourceTree = "<group>"; - }; DB427DDB25BAA00100D1B89D /* Main.storyboard */ = { isa = PBXVariantGroup; children = ( @@ -4737,27 +4485,6 @@ name = LaunchScreen.storyboard; sourceTree = "<group>"; }; - DB564BCE269F2F83001E39A7 /* Localizable.stringsdict */ = { - isa = PBXVariantGroup; - children = ( - DB564BCF269F2F83001E39A7 /* ar */, - DB564BD1269F2F8A001E39A7 /* en */, - E9AABD3E26B64B8D00E237DA /* ja */, - DBA4B0D426BD10AD0077136E /* zh-Hans */, - DBA4B0D826BD10F40077136E /* ca */, - DBA4B0DC26BD11130077136E /* fr */, - DBA4B0E026BD11C70077136E /* es */, - DBA4B0E426BD11D10077136E /* es-419 */, - DBA4B0E926C153820077136E /* de */, - DBA4B0ED26C153B10077136E /* nl */, - DB4B778026CA4EFA00B087B3 /* ru */, - DB4B778526CA500E00B087B3 /* gd-GB */, - DB4B779326CA50BA00B087B3 /* th */, - DBDC1CFA272C0FD600055C3D /* ku-TR */, - ); - name = Localizable.stringsdict; - sourceTree = "<group>"; - }; DBA4B0F926C269880077136E /* Intents.stringsdict */ = { isa = PBXVariantGroup; children = ( @@ -4774,7 +4501,9 @@ DB4B779026CA504900B087B3 /* fr */, DB4B779126CA504A00B087B3 /* ja */, DB4B779626CA50BA00B087B3 /* th */, - DBDC1CFD272C0FD600055C3D /* ku-TR */, + DB126A50278C063F005726EE /* eu-ES */, + DB126A5A278C088D005726EE /* sv-FI */, + DBEB19EB27E4F37B00B0E80E /* ku */, ); name = Intents.stringsdict; sourceTree = "<group>"; @@ -4919,7 +4648,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 88; + CURRENT_PROJECT_VERSION = 109; DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets"; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = Mastodon/Info.plist; @@ -4934,7 +4663,7 @@ SWIFT_OBJC_BRIDGING_HEADER = "Mastodon/Vender/Mastodon-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 1; + TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; }; name = Debug; @@ -4948,7 +4677,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 88; + CURRENT_PROJECT_VERSION = 109; DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets"; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = Mastodon/Info.plist; @@ -4962,7 +4691,7 @@ PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OBJC_BRIDGING_HEADER = "Mastodon/Vender/Mastodon-Bridging-Header.h"; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 1; + TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; }; name = Release; @@ -5056,11 +4785,11 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 88; + CURRENT_PROJECT_VERSION = 109; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5Z4GVSS33P; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 88; + DYLIB_CURRENT_VERSION = 109; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = AppShared/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -5087,11 +4816,11 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 88; + CURRENT_PROJECT_VERSION = 109; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5Z4GVSS33P; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 88; + DYLIB_CURRENT_VERSION = 109; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = AppShared/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -5110,110 +4839,13 @@ }; name = Release; }; - DB89BA0625C10FD0008580ED /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - APPLICATION_EXTENSION_API_ONLY = YES; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 88; - DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = 5Z4GVSS33P; - DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 88; - DYLIB_INSTALL_NAME_BASE = "@rpath"; - INFOPLIST_FILE = CoreDataStack/Info.plist; - INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@loader_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.CoreDataStack; - PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; - SKIP_INSTALL = YES; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - VERSIONING_SYSTEM = "apple-generic"; - VERSION_INFO_PREFIX = ""; - }; - name = Debug; - }; - DB89BA0725C10FD0008580ED /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - APPLICATION_EXTENSION_API_ONLY = YES; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 88; - DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = 5Z4GVSS33P; - DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 88; - DYLIB_INSTALL_NAME_BASE = "@rpath"; - INFOPLIST_FILE = CoreDataStack/Info.plist; - INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@loader_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.CoreDataStack; - PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; - SKIP_INSTALL = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - VERSIONING_SYSTEM = "apple-generic"; - VERSION_INFO_PREFIX = ""; - }; - name = Release; - }; - DB89BA0A25C10FD0008580ED /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = 5Z4GVSS33P; - INFOPLIST_FILE = CoreDataStackTests/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@loader_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.CoreDataStackTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Mastodon.app/Mastodon"; - }; - name = Debug; - }; - DB89BA0B25C10FD0008580ED /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = 5Z4GVSS33P; - INFOPLIST_FILE = CoreDataStackTests/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@loader_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.CoreDataStackTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Mastodon.app/Mastodon"; - }; - name = Release; - }; DB8FABD026AEC7B2008E5AF4 /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = 861BE60ED27430771CFD578D /* Pods-MastodonIntent.debug.xcconfig */; buildSettings = { CODE_SIGN_ENTITLEMENTS = MastodonIntent/MastodonIntent.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 88; + CURRENT_PROJECT_VERSION = 109; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = MastodonIntent/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -5227,68 +4859,18 @@ SKIP_INSTALL = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "APP_EXTENSION $(inherited)"; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 1; + TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; }; name = Debug; }; - DB8FABD126AEC7B2008E5AF4 /* ASDK - Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = C3789232A52F43529CA67E95 /* Pods-MastodonIntent.asdk - debug.xcconfig */; - buildSettings = { - CODE_SIGN_ENTITLEMENTS = MastodonIntent/MastodonIntent.entitlements; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 88; - DEVELOPMENT_TEAM = 5Z4GVSS33P; - INFOPLIST_FILE = MastodonIntent/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@executable_path/../../Frameworks", - ); - MARKETING_VERSION = 1.0.7; - PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.MastodonIntent; - PRODUCT_NAME = "$(TARGET_NAME)"; - SKIP_INSTALL = YES; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "APP_EXTENSION $(inherited)"; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 1; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = "ASDK - Debug"; - }; - DB8FABD226AEC7B2008E5AF4 /* ASDK - Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = F920AD4EC23B0D00F5CCA58E /* Pods-MastodonIntent.asdk - release.xcconfig */; - buildSettings = { - CODE_SIGN_ENTITLEMENTS = MastodonIntent/MastodonIntent.entitlements; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 88; - DEVELOPMENT_TEAM = 5Z4GVSS33P; - INFOPLIST_FILE = MastodonIntent/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@executable_path/../../Frameworks", - ); - MARKETING_VERSION = 1.0.7; - PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.MastodonIntent; - PRODUCT_NAME = "$(TARGET_NAME)"; - SKIP_INSTALL = YES; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "APP_EXTENSION $(inherited)"; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 1; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = "ASDK - Release"; - }; DB8FABD326AEC7B2008E5AF4 /* Release */ = { isa = XCBuildConfiguration; baseConfigurationReference = 159AC43EFE0A1F95FCB358A4 /* Pods-MastodonIntent.release.xcconfig */; buildSettings = { CODE_SIGN_ENTITLEMENTS = MastodonIntent/MastodonIntent.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 88; + CURRENT_PROJECT_VERSION = 109; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = MastodonIntent/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -5302,7 +4884,7 @@ SKIP_INSTALL = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "APP_EXTENSION $(inherited)"; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 1; + TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; }; name = Release; @@ -5313,7 +4895,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = ShareActionExtension/ShareActionExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 88; + CURRENT_PROJECT_VERSION = 109; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = ShareActionExtension/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -5327,68 +4909,18 @@ SKIP_INSTALL = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "APP_EXTENSION $(inherited)"; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 1; + TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; }; name = Debug; }; - DBC6461E26A170AB00B0E31B /* ASDK - Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 6130CBE4B26E3C976ACC1688 /* Pods-ShareActionExtension.asdk - debug.xcconfig */; - buildSettings = { - CODE_SIGN_ENTITLEMENTS = ShareActionExtension/ShareActionExtension.entitlements; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 88; - DEVELOPMENT_TEAM = 5Z4GVSS33P; - INFOPLIST_FILE = ShareActionExtension/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@executable_path/../../Frameworks", - ); - MARKETING_VERSION = 1.0.7; - PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.ShareActionExtension; - PRODUCT_NAME = "$(TARGET_NAME)"; - SKIP_INSTALL = YES; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "APP_EXTENSION $(inherited)"; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 1; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = "ASDK - Debug"; - }; - DBC6461F26A170AB00B0E31B /* ASDK - Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 5CE45680252519F42FEA2D13 /* Pods-ShareActionExtension.asdk - release.xcconfig */; - buildSettings = { - CODE_SIGN_ENTITLEMENTS = ShareActionExtension/ShareActionExtension.entitlements; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 88; - DEVELOPMENT_TEAM = 5Z4GVSS33P; - INFOPLIST_FILE = ShareActionExtension/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@executable_path/../../Frameworks", - ); - MARKETING_VERSION = 1.0.7; - PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.ShareActionExtension; - PRODUCT_NAME = "$(TARGET_NAME)"; - SKIP_INSTALL = YES; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "APP_EXTENSION $(inherited)"; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 1; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = "ASDK - Release"; - }; DBC6462026A170AB00B0E31B /* Release */ = { isa = XCBuildConfiguration; baseConfigurationReference = 95AD0663479892A2109EEFD0 /* Pods-ShareActionExtension.release.xcconfig */; buildSettings = { CODE_SIGN_ENTITLEMENTS = ShareActionExtension/ShareActionExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 88; + CURRENT_PROJECT_VERSION = 109; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = ShareActionExtension/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -5402,12 +4934,12 @@ SKIP_INSTALL = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "APP_EXTENSION $(inherited)"; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 1; + TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; }; name = Release; }; - DBCBCC0E2680BE3E000F5B51 /* ASDK - Release */ = { + DBEB19E127E4658E00B0E80E /* Release Snapshot */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; @@ -5442,17 +4974,11 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; @@ -5461,25 +4987,26 @@ GCC_WARN_UNUSED_VARIABLE = YES; INTENTS_CODEGEN_LANGUAGE = Swift; IPHONEOS_DEPLOYMENT_TARGET = 14.0; - MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; - ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = ASDK; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = SNAPSHOT; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; }; - name = "ASDK - Release"; + name = "Release Snapshot"; }; - DBCBCC0F2680BE3E000F5B51 /* ASDK - Release */ = { + DBEB19E227E4658E00B0E80E /* Release Snapshot */ = { isa = XCBuildConfiguration; - baseConfigurationReference = BD7598A87F4497045EDEF252 /* Pods-Mastodon.asdk - release.xcconfig */; + baseConfigurationReference = 0655B257371274BEB7EB1C19 /* Pods-Mastodon.release snapshot.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 88; + CURRENT_PROJECT_VERSION = 109; DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets"; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = Mastodon/Info.plist; @@ -5492,16 +5019,15 @@ 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; + TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; }; - name = "ASDK - Release"; + name = "Release Snapshot"; }; - DBCBCC102680BE3E000F5B51 /* ASDK - Release */ = { + DBEB19E327E4658E00B0E80E /* Release Snapshot */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 46DAB0EBDDFB678347CD96FF /* Pods-MastodonTests.asdk - release.xcconfig */; + baseConfigurationReference = 8E79CCBE51FBC3F7FE8CF49F /* Pods-MastodonTests.release snapshot.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -5518,11 +5044,11 @@ TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Mastodon.app/Mastodon"; }; - name = "ASDK - Release"; + name = "Release Snapshot"; }; - DBCBCC112680BE3E000F5B51 /* ASDK - Release */ = { + DBEB19E427E4658E00B0E80E /* Release Snapshot */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 8850E70A1D5FF51432E43653 /* Pods-Mastodon-MastodonUITests.asdk - release.xcconfig */; + baseConfigurationReference = 0827D1674B2523503E8605F6 /* Pods-Mastodon-MastodonUITests.release snapshot.xcconfig */; buildSettings = { CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = 5Z4GVSS33P; @@ -5538,64 +5064,45 @@ TARGETED_DEVICE_FAMILY = "1,2"; TEST_TARGET_NAME = Mastodon; }; - name = "ASDK - Release"; + name = "Release Snapshot"; }; - DBCBCC122680BE3E000F5B51 /* ASDK - Release */ = { + DBEB19E527E4658E00B0E80E /* Release Snapshot */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 3E08A432F40BA7B9CAA9DB68 /* Pods-AppShared.release snapshot.xcconfig */; buildSettings = { APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 88; + CURRENT_PROJECT_VERSION = 109; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5Z4GVSS33P; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 88; + DYLIB_CURRENT_VERSION = 109; DYLIB_INSTALL_NAME_BASE = "@rpath"; - INFOPLIST_FILE = CoreDataStack/Info.plist; + INFOPLIST_FILE = AppShared/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.CoreDataStack; + PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.AppShared; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; }; - name = "ASDK - Release"; + name = "Release Snapshot"; }; - DBCBCC132680BE3E000F5B51 /* ASDK - Release */ = { + DBEB19E627E4658E00B0E80E /* Release Snapshot */ = { isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = 5Z4GVSS33P; - INFOPLIST_FILE = CoreDataStackTests/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@loader_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.CoreDataStackTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Mastodon.app/Mastodon"; - }; - name = "ASDK - Release"; - }; - DBCBCC142680BE3E000F5B51 /* ASDK - Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 9CFF58FD900AC059428700E7 /* Pods-NotificationService.asdk - release.xcconfig */; + baseConfigurationReference = 8ADD558BE5B8255E5764A54F /* Pods-NotificationService.release snapshot.xcconfig */; buildSettings = { CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 88; + CURRENT_PROJECT_VERSION = 109; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = NotificationService/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -5608,278 +5115,60 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 1; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = "ASDK - Release"; - }; - DBCBCC152680BE3E000F5B51 /* ASDK - Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = DDB1B139FA8EA26F510D58B6 /* Pods-AppShared.asdk - release.xcconfig */; - buildSettings = { - APPLICATION_EXTENSION_API_ONLY = YES; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 88; - DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = 5Z4GVSS33P; - DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 88; - DYLIB_INSTALL_NAME_BASE = "@rpath"; - INFOPLIST_FILE = AppShared/Info.plist; - INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@loader_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.AppShared; - PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; - SKIP_INSTALL = YES; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; - VERSION_INFO_PREFIX = ""; }; - name = "ASDK - Release"; + name = "Release Snapshot"; }; - DBCBCC1E26818F6F000F5B51 /* ASDK - Debug */ = { + DBEB19E727E4658E00B0E80E /* Release Snapshot */ = { isa = XCBuildConfiguration; + baseConfigurationReference = F43DF6E8AB8C87914A64FC48 /* Pods-ShareActionExtension.release snapshot.xcconfig */; buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - INTENTS_CODEGEN_LANGUAGE = Swift; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; - MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; - MTL_FAST_MATH = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = iphoneos; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG ASDK"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - }; - name = "ASDK - Debug"; - }; - DBCBCC1F26818F6F000F5B51 /* ASDK - Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 1D6D967E77A5357E2C6110D9 /* Pods-Mastodon.asdk - debug.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements; + CODE_SIGN_ENTITLEMENTS = ShareActionExtension/ShareActionExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 88; - DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets"; + CURRENT_PROJECT_VERSION = 109; DEVELOPMENT_TEAM = 5Z4GVSS33P; - INFOPLIST_FILE = Mastodon/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - MARKETING_VERSION = 1.0.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; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = "ASDK - Debug"; - }; - DBCBCC2026818F6F000F5B51 /* ASDK - Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7CEFFAE9AF9284B13C0A758D /* Pods-MastodonTests.asdk - debug.xcconfig */; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = 5Z4GVSS33P; - INFOPLIST_FILE = MastodonTests/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@loader_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.MastodonTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Mastodon.app/Mastodon"; - }; - name = "ASDK - Debug"; - }; - DBCBCC2126818F6F000F5B51 /* ASDK - Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = E5C7236E58D14A0322FE00F2 /* Pods-Mastodon-MastodonUITests.asdk - debug.xcconfig */; - buildSettings = { - CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = 5Z4GVSS33P; - INFOPLIST_FILE = MastodonUITests/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@loader_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.MastodonUITests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_TARGET_NAME = Mastodon; - }; - name = "ASDK - Debug"; - }; - DBCBCC2226818F6F000F5B51 /* ASDK - Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - APPLICATION_EXTENSION_API_ONLY = YES; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 88; - DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = 5Z4GVSS33P; - DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 88; - DYLIB_INSTALL_NAME_BASE = "@rpath"; - INFOPLIST_FILE = CoreDataStack/Info.plist; - INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@loader_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.CoreDataStack; - PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; - SKIP_INSTALL = YES; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - VERSIONING_SYSTEM = "apple-generic"; - VERSION_INFO_PREFIX = ""; - }; - name = "ASDK - Debug"; - }; - DBCBCC2326818F6F000F5B51 /* ASDK - Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = 5Z4GVSS33P; - INFOPLIST_FILE = CoreDataStackTests/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@loader_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.CoreDataStackTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Mastodon.app/Mastodon"; - }; - name = "ASDK - Debug"; - }; - DBCBCC2426818F6F000F5B51 /* ASDK - Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 3B7FD8F28DDA8FBCE5562B78 /* Pods-NotificationService.asdk - debug.xcconfig */; - buildSettings = { - CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 88; - DEVELOPMENT_TEAM = 5Z4GVSS33P; - INFOPLIST_FILE = NotificationService/Info.plist; + INFOPLIST_FILE = ShareActionExtension/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); MARKETING_VERSION = 1.0.7; - PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.NotificationService; + PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.ShareActionExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 1; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = "ASDK - Debug"; - }; - DBCBCC2526818F6F000F5B51 /* ASDK - Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = A9B1FB898DFD6063B044298C /* Pods-AppShared.asdk - debug.xcconfig */; - buildSettings = { - APPLICATION_EXTENSION_API_ONLY = YES; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 88; - DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = 5Z4GVSS33P; - DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 88; - DYLIB_INSTALL_NAME_BASE = "@rpath"; - INFOPLIST_FILE = AppShared/Info.plist; - INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@loader_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.AppShared; - PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; - SKIP_INSTALL = YES; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "APP_EXTENSION $(inherited)"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; - VERSION_INFO_PREFIX = ""; }; - name = "ASDK - Debug"; + name = "Release Snapshot"; + }; + DBEB19E827E4658E00B0E80E /* Release Snapshot */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 2C12EB4B3699D5D597027962 /* Pods-MastodonIntent.release snapshot.xcconfig */; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = MastodonIntent/MastodonIntent.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 109; + DEVELOPMENT_TEAM = 5Z4GVSS33P; + INFOPLIST_FILE = MastodonIntent/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0.7; + PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.MastodonIntent; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "APP_EXTENSION $(inherited)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = "Release Snapshot"; }; DBF8AE1C263293E400C9C23C /* Debug */ = { isa = XCBuildConfiguration; @@ -5887,7 +5176,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 88; + CURRENT_PROJECT_VERSION = 109; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = NotificationService/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -5900,7 +5189,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 1; + TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; }; name = Debug; @@ -5911,7 +5200,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 88; + CURRENT_PROJECT_VERSION = 109; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = NotificationService/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -5924,7 +5213,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 1; + TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; }; name = Release; @@ -5936,9 +5225,8 @@ isa = XCConfigurationList; buildConfigurations = ( DB427DFA25BAA00100D1B89D /* Debug */, - DBCBCC1E26818F6F000F5B51 /* ASDK - Debug */, - DBCBCC0E2680BE3E000F5B51 /* ASDK - Release */, DB427DFB25BAA00100D1B89D /* Release */, + DBEB19E127E4658E00B0E80E /* Release Snapshot */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; @@ -5947,9 +5235,8 @@ isa = XCConfigurationList; buildConfigurations = ( DB427DFD25BAA00100D1B89D /* Debug */, - DBCBCC1F26818F6F000F5B51 /* ASDK - Debug */, - DBCBCC0F2680BE3E000F5B51 /* ASDK - Release */, DB427DFE25BAA00100D1B89D /* Release */, + DBEB19E227E4658E00B0E80E /* Release Snapshot */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; @@ -5958,9 +5245,8 @@ isa = XCConfigurationList; buildConfigurations = ( DB427E0025BAA00100D1B89D /* Debug */, - DBCBCC2026818F6F000F5B51 /* ASDK - Debug */, - DBCBCC102680BE3E000F5B51 /* ASDK - Release */, DB427E0125BAA00100D1B89D /* Release */, + DBEB19E327E4658E00B0E80E /* Release Snapshot */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; @@ -5969,9 +5255,8 @@ isa = XCConfigurationList; buildConfigurations = ( DB427E0325BAA00100D1B89D /* Debug */, - DBCBCC2126818F6F000F5B51 /* ASDK - Debug */, - DBCBCC112680BE3E000F5B51 /* ASDK - Release */, DB427E0425BAA00100D1B89D /* Release */, + DBEB19E427E4658E00B0E80E /* Release Snapshot */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; @@ -5980,31 +5265,8 @@ isa = XCConfigurationList; buildConfigurations = ( DB6804892637CD4C00430867 /* Debug */, - DBCBCC2526818F6F000F5B51 /* ASDK - Debug */, - DBCBCC152680BE3E000F5B51 /* ASDK - Release */, DB68048A2637CD4C00430867 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - DB89BA0525C10FD0008580ED /* Build configuration list for PBXNativeTarget "CoreDataStack" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - DB89BA0625C10FD0008580ED /* Debug */, - DBCBCC2226818F6F000F5B51 /* ASDK - Debug */, - DBCBCC122680BE3E000F5B51 /* ASDK - Release */, - DB89BA0725C10FD0008580ED /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - DB89BA0925C10FD0008580ED /* Build configuration list for PBXNativeTarget "CoreDataStackTests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - DB89BA0A25C10FD0008580ED /* Debug */, - DBCBCC2326818F6F000F5B51 /* ASDK - Debug */, - DBCBCC132680BE3E000F5B51 /* ASDK - Release */, - DB89BA0B25C10FD0008580ED /* Release */, + DBEB19E527E4658E00B0E80E /* Release Snapshot */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; @@ -6013,9 +5275,8 @@ isa = XCConfigurationList; buildConfigurations = ( DB8FABD026AEC7B2008E5AF4 /* Debug */, - DB8FABD126AEC7B2008E5AF4 /* ASDK - Debug */, - DB8FABD226AEC7B2008E5AF4 /* ASDK - Release */, DB8FABD326AEC7B2008E5AF4 /* Release */, + DBEB19E827E4658E00B0E80E /* Release Snapshot */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; @@ -6024,9 +5285,8 @@ isa = XCConfigurationList; buildConfigurations = ( DBC6461D26A170AB00B0E31B /* Debug */, - DBC6461E26A170AB00B0E31B /* ASDK - Debug */, - DBC6461F26A170AB00B0E31B /* ASDK - Release */, DBC6462026A170AB00B0E31B /* Release */, + DBEB19E727E4658E00B0E80E /* Release Snapshot */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; @@ -6035,9 +5295,8 @@ isa = XCConfigurationList; buildConfigurations = ( DBF8AE1C263293E400C9C23C /* Debug */, - DBCBCC2426818F6F000F5B51 /* ASDK - Debug */, - DBCBCC142680BE3E000F5B51 /* ASDK - Release */, DBF8AE1D263293E400C9C23C /* Release */, + DBEB19E627E4658E00B0E80E /* Release Snapshot */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; @@ -6082,7 +5341,7 @@ repositoryURL = "https://github.com/TwidereProject/MetaTextKit.git"; requirement = { kind = exactVersion; - version = 2.1.2; + version = 2.2.1; }; }; DB0E2D2C26833FF600865C3C /* XCRemoteSwiftPackageReference "Nuke-FLAnimatedImage-Plugin" */ = { @@ -6220,6 +5479,10 @@ package = DB3D0FF125BAA61700EAA174 /* XCRemoteSwiftPackageReference "AlamofireImage" */; productName = AlamofireImage; }; + DB179266278D5A4A00B71DEB /* MastodonSDK */ = { + isa = XCSwiftPackageProductDependency; + productName = MastodonSDK; + }; DB3D0FF225BAA61700EAA174 /* AlamofireImage */ = { isa = XCSwiftPackageProductDependency; package = DB3D0FF125BAA61700EAA174 /* XCRemoteSwiftPackageReference "AlamofireImage" */; @@ -6283,43 +5546,17 @@ package = DB0140BB25C40D7500F9F3CF /* XCRemoteSwiftPackageReference "CommonOSLog" */; productName = CommonOSLog; }; - DBBC24CC26A5471E00398BB9 /* MastodonExtension */ = { - isa = XCSwiftPackageProductDependency; - productName = MastodonExtension; - }; DBBC24D026A5484F00398BB9 /* UITextView+Placeholder */ = { isa = XCSwiftPackageProductDependency; package = DB9A487C2603456B008B817C /* XCRemoteSwiftPackageReference "UITextView-Placeholder" */; productName = "UITextView+Placeholder"; }; - DBC6462426A1720B00B0E31B /* MastodonUI */ = { - isa = XCSwiftPackageProductDependency; - productName = MastodonUI; - }; - DBC6462A26A1738900B0E31B /* MastodonUI */ = { - isa = XCSwiftPackageProductDependency; - productName = MastodonUI; - }; DBF7A0FB26830C33004176A2 /* FPSIndicator */ = { isa = XCSwiftPackageProductDependency; package = DBF7A0FA26830C33004176A2 /* XCRemoteSwiftPackageReference "FPSIndicator" */; productName = FPSIndicator; }; /* End XCSwiftPackageProductDependency section */ - -/* Begin XCVersionGroup section */ - DB89BA3525C1145C008580ED /* CoreData.xcdatamodeld */ = { - isa = XCVersionGroup; - children = ( - DBF156DD27006F5D00EC00B7 /* CoreData 2.xcdatamodel */, - DB89BA3625C1145C008580ED /* CoreData.xcdatamodel */, - ); - currentVersion = DBF156DD27006F5D00EC00B7 /* CoreData 2.xcdatamodel */; - path = CoreData.xcdatamodeld; - sourceTree = "<group>"; - versionGroupType = wrapper.xcdatamodel; - }; -/* End XCVersionGroup section */ }; rootObject = DB427DCA25BAA00100D1B89D /* Project object */; } diff --git a/Mastodon.xcodeproj/xcshareddata/xcschemes/Mastodon - Release.xcscheme b/Mastodon.xcodeproj/xcshareddata/xcschemes/Mastodon - Release.xcscheme index 15ecdcbe9..d5959cead 100644 --- a/Mastodon.xcodeproj/xcshareddata/xcschemes/Mastodon - Release.xcscheme +++ b/Mastodon.xcodeproj/xcshareddata/xcschemes/Mastodon - Release.xcscheme @@ -48,16 +48,6 @@ ReferencedContainer = "container:Mastodon.xcodeproj"> </BuildableReference> </TestableReference> - <TestableReference - skipped = "NO"> - <BuildableReference - BuildableIdentifier = "primary" - BlueprintIdentifier = "DB89B9F525C10FD0008580ED" - BuildableName = "CoreDataStackTests.xctest" - BlueprintName = "CoreDataStackTests" - ReferencedContainer = "container:Mastodon.xcodeproj"> - </BuildableReference> - </TestableReference> </Testables> </TestAction> <LaunchAction diff --git a/Mastodon.xcodeproj/xcshareddata/xcschemes/Mastodon - ASDK.xcscheme b/Mastodon.xcodeproj/xcshareddata/xcschemes/Mastodon - Snapshot.xcscheme similarity index 77% rename from Mastodon.xcodeproj/xcshareddata/xcschemes/Mastodon - ASDK.xcscheme rename to Mastodon.xcodeproj/xcshareddata/xcschemes/Mastodon - Snapshot.xcscheme index 4ce52bd58..96cbba566 100644 --- a/Mastodon.xcodeproj/xcshareddata/xcschemes/Mastodon - ASDK.xcscheme +++ b/Mastodon.xcodeproj/xcshareddata/xcschemes/Mastodon - Snapshot.xcscheme @@ -1,7 +1,7 @@ <?xml version="1.0" encoding="UTF-8"?> <Scheme LastUpgradeVersion = "1250" - version = "1.3"> + version = "1.7"> <BuildAction parallelizeBuildables = "YES" buildImplicitDependencies = "YES"> @@ -20,16 +20,12 @@ ReferencedContainer = "container:Mastodon.xcodeproj"> </BuildableReference> </BuildActionEntry> - </BuildActionEntries> - </BuildAction> - <TestAction - buildConfiguration = "ASDK - Debug" - selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" - selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - shouldUseLaunchSchemeArgsEnv = "YES"> - <Testables> - <TestableReference - skipped = "NO"> + <BuildActionEntry + buildForTesting = "NO" + buildForRunning = "NO" + buildForProfiling = "NO" + buildForArchiving = "NO" + buildForAnalyzing = "NO"> <BuildableReference BuildableIdentifier = "primary" BlueprintIdentifier = "DB427DE725BAA00100D1B89D" @@ -37,9 +33,13 @@ BlueprintName = "MastodonTests" ReferencedContainer = "container:Mastodon.xcodeproj"> </BuildableReference> - </TestableReference> - <TestableReference - skipped = "NO"> + </BuildActionEntry> + <BuildActionEntry + buildForTesting = "NO" + buildForRunning = "NO" + buildForProfiling = "NO" + buildForArchiving = "NO" + buildForAnalyzing = "NO"> <BuildableReference BuildableIdentifier = "primary" BlueprintIdentifier = "DB427DF225BAA00100D1B89D" @@ -47,28 +47,32 @@ BlueprintName = "MastodonUITests" ReferencedContainer = "container:Mastodon.xcodeproj"> </BuildableReference> - </TestableReference> - <TestableReference - skipped = "NO"> - <BuildableReference - BuildableIdentifier = "primary" - BlueprintIdentifier = "DB89B9F525C10FD0008580ED" - BuildableName = "CoreDataStackTests.xctest" - BlueprintName = "CoreDataStackTests" - ReferencedContainer = "container:Mastodon.xcodeproj"> - </BuildableReference> - </TestableReference> - </Testables> - </TestAction> - <LaunchAction - buildConfiguration = "ASDK - Debug" + </BuildActionEntry> + </BuildActionEntries> + </BuildAction> + <TestAction + buildConfiguration = "Release Snapshot" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + shouldUseLaunchSchemeArgsEnv = "YES"> + <TestPlans> + <TestPlanReference + reference = "container:AppStoreSnapshotTestPlan.xctestplan" + default = "YES"> + </TestPlanReference> + </TestPlans> + </TestAction> + <LaunchAction + buildConfiguration = "Release Snapshot" + selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" + selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + disableMainThreadChecker = "YES" launchStyle = "0" useCustomWorkingDirectory = "NO" ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" debugServiceExtension = "internal" + enableGPUValidationMode = "1" allowLocationSimulation = "YES"> <BuildableProductRunnable runnableDebuggingMode = "0"> @@ -82,7 +86,7 @@ </BuildableProductRunnable> </LaunchAction> <ProfileAction - buildConfiguration = "ASDK - Release" + buildConfiguration = "Release" shouldUseLaunchSchemeArgsEnv = "YES" savedToolIdentifier = "" useCustomWorkingDirectory = "NO" @@ -99,10 +103,10 @@ </BuildableProductRunnable> </ProfileAction> <AnalyzeAction - buildConfiguration = "ASDK - Debug"> + buildConfiguration = "Debug"> </AnalyzeAction> <ArchiveAction - buildConfiguration = "ASDK - Release" + buildConfiguration = "Release" revealArchiveInOrganizer = "YES"> </ArchiveAction> </Scheme> diff --git a/Mastodon.xcodeproj/xcshareddata/xcschemes/Mastodon.xcscheme b/Mastodon.xcodeproj/xcshareddata/xcschemes/Mastodon.xcscheme index de059787b..488d5a2da 100644 --- a/Mastodon.xcodeproj/xcshareddata/xcschemes/Mastodon.xcscheme +++ b/Mastodon.xcodeproj/xcshareddata/xcschemes/Mastodon.xcscheme @@ -32,6 +32,9 @@ reference = "container:Mastodon/Mastodon.xctestplan" default = "YES"> </TestPlanReference> + <TestPlanReference + reference = "container:AppStoreSnapshotTestPlan copy.xctestplan"> + </TestPlanReference> </TestPlans> <Testables> <TestableReference diff --git a/Mastodon.xcodeproj/xcshareddata/xcschemes/NotificationService.xcscheme b/Mastodon.xcodeproj/xcshareddata/xcschemes/NotificationService.xcscheme deleted file mode 100644 index d372229c4..000000000 --- a/Mastodon.xcodeproj/xcshareddata/xcschemes/NotificationService.xcscheme +++ /dev/null @@ -1,97 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<Scheme - LastUpgradeVersion = "1250" - wasCreatedForAppExtension = "YES" - version = "2.0"> - <BuildAction - parallelizeBuildables = "YES" - buildImplicitDependencies = "YES"> - <BuildActionEntries> - <BuildActionEntry - buildForTesting = "YES" - buildForRunning = "YES" - buildForProfiling = "YES" - buildForArchiving = "YES" - buildForAnalyzing = "YES"> - <BuildableReference - BuildableIdentifier = "primary" - BlueprintIdentifier = "DBF8AE12263293E400C9C23C" - BuildableName = "NotificationService.appex" - BlueprintName = "NotificationService" - ReferencedContainer = "container:Mastodon.xcodeproj"> - </BuildableReference> - </BuildActionEntry> - <BuildActionEntry - buildForTesting = "YES" - buildForRunning = "YES" - buildForProfiling = "YES" - buildForArchiving = "YES" - buildForAnalyzing = "YES"> - <BuildableReference - BuildableIdentifier = "primary" - BlueprintIdentifier = "DB427DD125BAA00100D1B89D" - BuildableName = "Mastodon.app" - BlueprintName = "Mastodon" - ReferencedContainer = "container:Mastodon.xcodeproj"> - </BuildableReference> - </BuildActionEntry> - </BuildActionEntries> - </BuildAction> - <TestAction - buildConfiguration = "Debug" - selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" - selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - shouldUseLaunchSchemeArgsEnv = "YES"> - <Testables> - </Testables> - </TestAction> - <LaunchAction - buildConfiguration = "Debug" - selectedDebuggerIdentifier = "" - selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn" - launchStyle = "0" - askForAppToLaunch = "Yes" - useCustomWorkingDirectory = "NO" - ignoresPersistentStateOnLaunch = "NO" - debugDocumentVersioning = "YES" - debugServiceExtension = "internal" - allowLocationSimulation = "YES" - launchAutomaticallySubstyle = "2"> - <BuildableProductRunnable - runnableDebuggingMode = "0"> - <BuildableReference - BuildableIdentifier = "primary" - BlueprintIdentifier = "DB427DD125BAA00100D1B89D" - BuildableName = "Mastodon.app" - BlueprintName = "Mastodon" - ReferencedContainer = "container:Mastodon.xcodeproj"> - </BuildableReference> - </BuildableProductRunnable> - </LaunchAction> - <ProfileAction - buildConfiguration = "Release" - shouldUseLaunchSchemeArgsEnv = "YES" - savedToolIdentifier = "" - useCustomWorkingDirectory = "NO" - debugDocumentVersioning = "YES" - askForAppToLaunch = "Yes" - launchAutomaticallySubstyle = "2"> - <BuildableProductRunnable - runnableDebuggingMode = "0"> - <BuildableReference - BuildableIdentifier = "primary" - BlueprintIdentifier = "DB427DD125BAA00100D1B89D" - BuildableName = "Mastodon.app" - BlueprintName = "Mastodon" - ReferencedContainer = "container:Mastodon.xcodeproj"> - </BuildableReference> - </BuildableProductRunnable> - </ProfileAction> - <AnalyzeAction - buildConfiguration = "Debug"> - </AnalyzeAction> - <ArchiveAction - buildConfiguration = "Release" - revealArchiveInOrganizer = "YES"> - </ArchiveAction> -</Scheme> diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index 5c99e944b..a90323e98 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -6,28 +6,30 @@ <dict> <key>AppShared.xcscheme_^#shared#^_</key> <dict> + <key>isShown</key> + <true/> <key>orderHint</key> - <integer>44</integer> + <integer>3</integer> </dict> <key>CoreDataStack.xcscheme_^#shared#^_</key> <dict> <key>orderHint</key> - <integer>45</integer> - </dict> - <key>Mastodon - ASDK.xcscheme_^#shared#^_</key> - <dict> - <key>orderHint</key> - <integer>4</integer> + <integer>27</integer> </dict> <key>Mastodon - RTL.xcscheme_^#shared#^_</key> <dict> <key>orderHint</key> - <integer>17</integer> + <integer>18</integer> </dict> <key>Mastodon - Release.xcscheme_^#shared#^_</key> <dict> <key>orderHint</key> - <integer>3</integer> + <integer>1</integer> + </dict> + <key>Mastodon - Snapshot.xcscheme_^#shared#^_</key> + <dict> + <key>orderHint</key> + <integer>2</integer> </dict> <key>Mastodon - ar.xcscheme_^#shared#^_</key> <dict> @@ -102,7 +104,7 @@ <key>MastodonIntent.xcscheme_^#shared#^_</key> <dict> <key>orderHint</key> - <integer>43</integer> + <integer>49</integer> </dict> <key>MastodonIntents.xcscheme_^#shared#^_</key> <dict> @@ -117,15 +119,41 @@ <key>NotificationService.xcscheme_^#shared#^_</key> <dict> <key>orderHint</key> - <integer>7</integer> + <integer>51</integer> </dict> <key>ShareActionExtension.xcscheme_^#shared#^_</key> <dict> <key>orderHint</key> - <integer>42</integer> + <integer>50</integer> </dict> </dict> <key>SuppressBuildableAutocreation</key> - <dict/> + <dict> + <key>DB427DD125BAA00100D1B89D</key> + <dict> + <key>primary</key> + <true/> + </dict> + <key>DB427DE725BAA00100D1B89D</key> + <dict> + <key>primary</key> + <true/> + </dict> + <key>DB427DF225BAA00100D1B89D</key> + <dict> + <key>primary</key> + <true/> + </dict> + <key>DB68047E2637CD4C00430867</key> + <dict> + <key>primary</key> + <true/> + </dict> + <key>DB89B9F525C10FD0008580ED</key> + <dict> + <key>primary</key> + <true/> + </dict> + </dict> </dict> </plist> diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved index 11dde7269..11d453883 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": "d120af1e8638c7da36c8481fd61a66c0c08dc4fc", - "version": "5.4.4" + "revision": "f82c23a8a7ef8dc1a49a8bfc6a96883e79121864", + "version": "5.5.0" } }, { @@ -57,7 +57,7 @@ }, { "package": "FLAnimatedImage", - "repositoryURL": "https://github.com/Flipboard/FLAnimatedImage", + "repositoryURL": "https://github.com/Flipboard/FLAnimatedImage.git", "state": { "branch": null, "revision": "e7f9fd4681ae41bf6f3056db08af4f401d61da52", @@ -96,8 +96,8 @@ "repositoryURL": "https://github.com/TwidereProject/MetaTextKit.git", "state": { "branch": null, - "revision": "7af4182f64329440a4656f2cba307cb5848e496a", - "version": "2.1.2" + "revision": "3ea336d3de7938dc112084c596a646e697b0feee", + "version": "2.2.1" } }, { @@ -141,8 +141,8 @@ "repositoryURL": "https://github.com/SDWebImage/SDWebImage.git", "state": { "branch": null, - "revision": "a72df4849408da7e5d3c1b586797b7c601c41d1b", - "version": "5.12.1" + "revision": "2c53f531f1bedd253f55d85105409c28ed4a922c", + "version": "5.12.3" } }, { @@ -195,8 +195,8 @@ "repositoryURL": "https://github.com/uias/Tabman", "state": { "branch": null, - "revision": "f43489cdd743ba7ad86a422ebb5fcbf34e333df4", - "version": "2.11.1" + "revision": "a9f10cb862a32e6a22549836af013abd6b0692d3", + "version": "2.12.0" } }, { @@ -213,8 +213,17 @@ "repositoryURL": "https://github.com/TimOliver/TOCropViewController.git", "state": { "branch": null, - "revision": "dad97167bf1be16aeecd109130900995dd01c515", - "version": "2.6.0" + "revision": "d0470491f56e734731bbf77991944c0dfdee3e0e", + "version": "2.6.1" + } + }, + { + "package": "UITextView+Placeholder", + "repositoryURL": "https://github.com/MainasuK/UITextView-Placeholder.git", + "state": { + "branch": null, + "revision": "20f513ded04a040cdf5467f0891849b1763ede3b", + "version": "1.4.1" } } ] diff --git a/Mastodon/.sourcery.yml b/Mastodon/.sourcery.yml new file mode 100644 index 000000000..391430e55 --- /dev/null +++ b/Mastodon/.sourcery.yml @@ -0,0 +1,7 @@ +sources: + - . + - ../MastodonSDK/Sources +templates: + - ./Template +output: + Generated diff --git a/Mastodon/Activity/SafariActivity.swift b/Mastodon/Activity/SafariActivity.swift index a43e34f9a..62a193eaf 100644 --- a/Mastodon/Activity/SafariActivity.swift +++ b/Mastodon/Activity/SafariActivity.swift @@ -7,6 +7,8 @@ import UIKit import SafariServices +import MastodonAsset +import MastodonLocalization final class SafariActivity: UIActivity { @@ -22,7 +24,7 @@ final class SafariActivity: UIActivity { } override var activityTitle: String? { - return L10n.Common.Controls.Actions.openInSafari + return UserDefaults.shared.preferredUsingDefaultBrowser ? L10n.Common.Controls.Actions.openInBrowser : L10n.Common.Controls.Actions.openInSafari } override var activityImage: UIImage? { @@ -55,8 +57,10 @@ final class SafariActivity: UIActivity { return } - sceneCoordinator?.present(scene: .safari(url: url as URL), from: nil, transition: .safariPresent(animated: true, completion: nil)) - activityDidFinish(true) + Task { + await sceneCoordinator?.present(scene: .safari(url: url as URL), from: nil, transition: .safariPresent(animated: true, completion: nil)) + activityDidFinish(true) + } } } diff --git a/Mastodon/Activity/ShareActivityProvider.swift b/Mastodon/Activity/ShareActivityProvider.swift new file mode 100644 index 000000000..524a0427d --- /dev/null +++ b/Mastodon/Activity/ShareActivityProvider.swift @@ -0,0 +1,13 @@ +// +// ShareActivityProvider.swift +// Mastodon +// +// Created by MainasuK on 2022-1-25. +// + +import UIKit + +protocol ShareActivityProvider { + var activities: [Any] { get } + var applicationActivities: [UIActivity] { get } +} diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index 9fbb2b774..c8ce4acbd 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -10,6 +10,8 @@ import SafariServices import CoreDataStack import MastodonSDK import PanModal +import MastodonAsset +import MastodonLocalization final public class SceneCoordinator { @@ -43,7 +45,7 @@ final public class SceneCoordinator { return Just(nil).eraseToAnyPublisher() } - let accessToken = pushNotification._accessToken // use raw accessToken value without normalize + 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() @@ -157,11 +159,6 @@ extension SceneCoordinator { case mastodonConfirmEmail(viewModel: MastodonConfirmEmailViewModel) case mastodonResendEmail(viewModel: MastodonResendEmailViewModel) case mastodonWebView(viewModel:WebViewModel) - - #if ASDK - // ASDK - case asyncHome - #endif // search case searchDetail(viewModel: SearchDetailViewModel) @@ -187,6 +184,8 @@ extension SceneCoordinator { // report case report(viewModel: ReportViewModel) + case reportSupplementary(viewModel: ReportSupplementaryViewModel) + case reportResult(viewModel: ReportResultViewModel) // suggestion account case suggestionAccount(viewModel: SuggestionAccountViewModel) @@ -199,10 +198,6 @@ extension SceneCoordinator { case alertController(alertController: UIAlertController) case activityViewController(activityViewController: UIActivityViewController, sourceView: UIView?, barButtonItem: UIBarButtonItem?) - #if DEBUG - case publicTimeline - #endif - var isOnboarding: Bool { switch self { case .welcome, @@ -216,7 +211,7 @@ extension SceneCoordinator { return false } } - } + } // end enum Scene { } } extension SceneCoordinator { @@ -260,7 +255,7 @@ extension SceneCoordinator { DispatchQueue.main.async { self.present( scene: .welcome, - from: nil, + from: self.sceneDelegate.window?.rootViewController, transition: .modal(animated: animated, completion: nil) ) } @@ -271,6 +266,7 @@ extension SceneCoordinator { } @discardableResult + @MainActor func present(scene: Scene, from sender: UIViewController?, transition: Transition) -> UIViewController? { guard let viewController = get(scene: scene) else { return nil @@ -311,7 +307,7 @@ extension SceneCoordinator { case .modal(let animated, let completion): let modalNavigationController: UINavigationController = { if scene.isOnboarding { - return AdaptiveStatusBarStyleNavigationController(rootViewController: viewController) + return OnboardingNavigationController(rootViewController: viewController) } else { return UINavigationController(rootViewController: viewController) } @@ -412,11 +408,6 @@ private extension SceneCoordinator { let _viewController = WebViewController() _viewController.viewModel = viewModel viewController = _viewController - #if ASDK - case .asyncHome: - let _viewController = AsyncHomeTimelineViewController() - viewController = _viewController - #endif case .searchDetail(let viewModel): let _viewController = SearchDetailViewController() _viewController.viewModel = viewModel @@ -452,6 +443,18 @@ private extension SceneCoordinator { let _viewController = FollowingListViewController() _viewController.viewModel = viewModel viewController = _viewController + case .report(let viewModel): + let _viewController = ReportViewController() + _viewController.viewModel = viewModel + viewController = _viewController + case .reportSupplementary(let viewModel): + let _viewController = ReportSupplementaryViewController() + _viewController.viewModel = viewModel + viewController = _viewController + case .reportResult(let viewModel): + let _viewController = ReportResultViewController() + _viewController.viewModel = viewModel + viewController = _viewController case .suggestionAccount(let viewModel): let _viewController = SuggestionAccountViewController() _viewController.viewModel = viewModel @@ -487,16 +490,6 @@ private extension SceneCoordinator { let _viewController = SettingsViewController() _viewController.viewModel = viewModel viewController = _viewController - case .report(let viewModel): - let _viewController = ReportViewController() - _viewController.viewModel = viewModel - viewController = _viewController - #if DEBUG - case .publicTimeline: - let _viewController = PublicTimelineViewController() - _viewController.viewModel = PublicTimelineViewModel(context: appContext) - viewController = _viewController - #endif } setupDependency(for: viewController as? NeedsDependency) diff --git a/Mastodon/Diffiable/Account/SelectedAccountItem.swift b/Mastodon/Diffiable/Account/SelectedAccountItem.swift new file mode 100644 index 000000000..05ecdae8d --- /dev/null +++ b/Mastodon/Diffiable/Account/SelectedAccountItem.swift @@ -0,0 +1,15 @@ +// +// SelectedAccountItem.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/22. +// + +import CoreData +import Foundation +import CoreDataStack + +enum SelectedAccountItem: Hashable { + case account(ManagedObjectRecord<MastodonUser>) + case placeHolder(uuid: UUID) +} diff --git a/Mastodon/Diffiable/Section/SelectedAccountSection.swift b/Mastodon/Diffiable/Account/SelectedAccountSection.swift similarity index 73% rename from Mastodon/Diffiable/Section/SelectedAccountSection.swift rename to Mastodon/Diffiable/Account/SelectedAccountSection.swift index 4f18ef873..6c02d7059 100644 --- a/Mastodon/Diffiable/Section/SelectedAccountSection.swift +++ b/Mastodon/Diffiable/Account/SelectedAccountSection.swift @@ -17,15 +17,17 @@ enum SelectedAccountSection: Equatable, Hashable { extension SelectedAccountSection { static func collectionViewDiffableDataSource( - for collectionView: UICollectionView, - managedObjectContext: NSManagedObjectContext + collectionView: UICollectionView, + context: AppContext ) -> UICollectionViewDiffableDataSource<SelectedAccountSection, SelectedAccountItem> { UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, item -> UICollectionViewCell? in let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: SuggestionAccountCollectionViewCell.self), for: indexPath) as! SuggestionAccountCollectionViewCell switch item { - case .accountObjectID(let objectID): - let user = managedObjectContext.object(with: objectID) as! MastodonUser - cell.config(with: user) + case .account(let record): + context.managedObjectContext.performAndWait { + guard let user = record.object(in: context.managedObjectContext) else { return } + cell.config(with: user) + } case .placeHolder: cell.configAsPlaceHolder() } diff --git a/Mastodon/Diffiable/Item/AutoCompleteItem.swift b/Mastodon/Diffiable/Compose/AutoCompleteItem.swift similarity index 100% rename from Mastodon/Diffiable/Item/AutoCompleteItem.swift rename to Mastodon/Diffiable/Compose/AutoCompleteItem.swift diff --git a/Mastodon/Diffiable/Section/Compose/AutoCompleteSection.swift b/Mastodon/Diffiable/Compose/AutoCompleteSection.swift similarity index 94% rename from Mastodon/Diffiable/Section/Compose/AutoCompleteSection.swift rename to Mastodon/Diffiable/Compose/AutoCompleteSection.swift index ed205b134..1a2bf45f0 100644 --- a/Mastodon/Diffiable/Section/Compose/AutoCompleteSection.swift +++ b/Mastodon/Diffiable/Compose/AutoCompleteSection.swift @@ -8,6 +8,8 @@ import UIKit import MastodonSDK import MastodonMeta +import MastodonAsset +import MastodonLocalization enum AutoCompleteSection: Equatable, Hashable { case main @@ -80,7 +82,7 @@ extension AutoCompleteSection { } cell.subtitleLabel.text = "@" + account.acct cell.avatarImageView.isHidden = false - cell.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: URL(string: account.avatar))) + cell.avatarImageView.configure(configuration: .init(url: URL(string: account.avatar))) } private static func configureEmoji(cell: AutoCompleteTableViewCell, emoji: Mastodon.Entity.Emoji, isFirst: Bool) { @@ -90,7 +92,7 @@ extension AutoCompleteSection { // cell.subtitleLabel.text = isFirst ? L10n.Scene.Compose.AutoComplete.spaceToAdd : " " cell.subtitleLabel.text = " " cell.avatarImageView.isHidden = false - cell.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: URL(string: emoji.url))) + cell.avatarImageView.configure(configuration: .init(url: URL(string: emoji.url))) } } diff --git a/Mastodon/Diffiable/Item/ComposeStatusAttachmentItem.swift b/Mastodon/Diffiable/Compose/ComposeStatusAttachmentItem.swift similarity index 100% rename from Mastodon/Diffiable/Item/ComposeStatusAttachmentItem.swift rename to Mastodon/Diffiable/Compose/ComposeStatusAttachmentItem.swift diff --git a/Mastodon/Diffiable/Section/Compose/ComposeStatusAttachmentSection.swift b/Mastodon/Diffiable/Compose/ComposeStatusAttachmentSection.swift similarity index 100% rename from Mastodon/Diffiable/Section/Compose/ComposeStatusAttachmentSection.swift rename to Mastodon/Diffiable/Compose/ComposeStatusAttachmentSection.swift diff --git a/Mastodon/Diffiable/Item/ComposeStatusItem.swift b/Mastodon/Diffiable/Compose/ComposeStatusItem.swift similarity index 53% rename from Mastodon/Diffiable/Item/ComposeStatusItem.swift rename to Mastodon/Diffiable/Compose/ComposeStatusItem.swift index c2c3f46d8..65650dcdc 100644 --- a/Mastodon/Diffiable/Item/ComposeStatusItem.swift +++ b/Mastodon/Diffiable/Compose/ComposeStatusItem.swift @@ -9,11 +9,12 @@ import Foundation import Combine import CoreData import MastodonMeta +import CoreDataStack /// Note: update Equatable when change case enum ComposeStatusItem { - case replyTo(statusObjectID: NSManagedObjectID) - case input(replyToStatusObjectID: NSManagedObjectID?, attribute: ComposeStatusAttribute) + case replyTo(record: ManagedObjectRecord<Status>) + case input(replyTo: ManagedObjectRecord<Status>?, attribute: ComposeStatusAttribute) case attachment(attachmentAttribute: ComposeStatusAttachmentAttribute) case pollOption(pollOptionAttributes: [ComposeStatusPollItem.PollOptionAttribute], pollExpiresOptionAttribute: ComposeStatusPollItem.PollExpiresOptionAttribute) } @@ -21,26 +22,21 @@ enum ComposeStatusItem { extension ComposeStatusItem: Hashable { } extension ComposeStatusItem { - final class ComposeStatusAttribute: Equatable, Hashable { + final class ComposeStatusAttribute: Hashable { private let id = UUID() - - let avatarURL = CurrentValueSubject<URL?, Never>(nil) - let displayName = CurrentValueSubject<String?, Never>(nil) - let emojiMeta = CurrentValueSubject<MastodonContent.Emojis, Never>([:]) - let username = CurrentValueSubject<String?, Never>(nil) - let composeContent = CurrentValueSubject<String?, Never>(nil) - let isContentWarningComposing = CurrentValueSubject<Bool, Never>(false) - let contentWarningContent = CurrentValueSubject<String, Never>("") + @Published var author: ManagedObjectRecord<MastodonUser>? + + @Published var composeContent: String? + + @Published var isContentWarningComposing = false + @Published var contentWarningContent = "" static func == (lhs: ComposeStatusAttribute, rhs: ComposeStatusAttribute) -> Bool { - return lhs.avatarURL.value == rhs.avatarURL.value && - lhs.displayName.value == rhs.displayName.value && - lhs.emojiMeta.value == rhs.emojiMeta.value && - lhs.username.value == rhs.username.value && - lhs.composeContent.value == rhs.composeContent.value && - lhs.isContentWarningComposing.value == rhs.isContentWarningComposing.value && - lhs.contentWarningContent.value == rhs.contentWarningContent.value + return lhs.author == rhs.author + && lhs.composeContent == rhs.composeContent + && lhs.isContentWarningComposing == rhs.isContentWarningComposing + && lhs.contentWarningContent == rhs.contentWarningContent } func hash(into hasher: inout Hasher) { diff --git a/Mastodon/Diffiable/Item/ComposeStatusPollItem.swift b/Mastodon/Diffiable/Compose/ComposeStatusPollItem.swift similarity index 98% rename from Mastodon/Diffiable/Item/ComposeStatusPollItem.swift rename to Mastodon/Diffiable/Compose/ComposeStatusPollItem.swift index 2e45484c7..0a315454e 100644 --- a/Mastodon/Diffiable/Item/ComposeStatusPollItem.swift +++ b/Mastodon/Diffiable/Compose/ComposeStatusPollItem.swift @@ -7,6 +7,8 @@ import Foundation import Combine +import MastodonAsset +import MastodonLocalization enum ComposeStatusPollItem { case pollOption(attribute: PollOptionAttribute) diff --git a/Mastodon/Diffiable/Section/Compose/ComposeStatusPollSection.swift b/Mastodon/Diffiable/Compose/ComposeStatusPollSection.swift similarity index 100% rename from Mastodon/Diffiable/Section/Compose/ComposeStatusPollSection.swift rename to Mastodon/Diffiable/Compose/ComposeStatusPollSection.swift diff --git a/Mastodon/Diffiable/Section/Compose/ComposeStatusSection.swift b/Mastodon/Diffiable/Compose/ComposeStatusSection.swift similarity index 57% rename from Mastodon/Diffiable/Section/Compose/ComposeStatusSection.swift rename to Mastodon/Diffiable/Compose/ComposeStatusSection.swift index 45b0656f4..45ed86783 100644 --- a/Mastodon/Diffiable/Section/Compose/ComposeStatusSection.swift +++ b/Mastodon/Diffiable/Compose/ComposeStatusSection.swift @@ -14,7 +14,7 @@ import MastodonMeta import AlamofireImage enum ComposeStatusSection: Equatable, Hashable { - case repliedTo + case replyTo case status case attachment case poll @@ -24,43 +24,44 @@ extension ComposeStatusSection { enum ComposeKind { case post case hashtag(hashtag: String) - case mention(mastodonUserObjectID: NSManagedObjectID) - case reply(repliedToStatusObjectID: NSManagedObjectID) + case mention(user: ManagedObjectRecord<MastodonUser>) + case reply(status: ManagedObjectRecord<Status>) } } extension ComposeStatusSection { - static func configureStatusContent( + static func configure( cell: ComposeStatusContentTableViewCell, attribute: ComposeStatusItem.ComposeStatusAttribute ) { - // set avatar - attribute.avatarURL - .receive(on: DispatchQueue.main) - .sink { avatarURL in - cell.statusView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: avatarURL)) - } - .store(in: &cell.disposeBag) - // set display name and username - Publishers.CombineLatest3( - attribute.displayName, - attribute.emojiMeta, - attribute.username - ) - .receive(on: DispatchQueue.main) - .sink { displayName, emojiMeta, username in - do { - let mastodonContent = MastodonContent(content: displayName ?? " ", emojis: emojiMeta) - let metaContent = try MastodonMetaContent.convert(document: mastodonContent) - cell.statusView.nameLabel.configure(content: metaContent) - } catch { - let metaContent = PlaintextMetaContent(string: " ") - cell.statusView.nameLabel.configure(content: metaContent) - } - cell.statusView.usernameLabel.text = username.flatMap { "@" + $0 } ?? " " - } - .store(in: &cell.disposeBag) +// cell.prepa +// // set avatar +// attribute.avatarURL +// .receive(on: DispatchQueue.main) +// .sink { avatarURL in +// cell.statusView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: avatarURL)) +// } +// .store(in: &cell.disposeBag) +// // set display name and username +// Publishers.CombineLatest3( +// attribute.displayName, +// attribute.emojiMeta, +// attribute.username +// ) +// .receive(on: DispatchQueue.main) +// .sink { displayName, emojiMeta, username in +// do { +// let mastodonContent = MastodonContent(content: displayName ?? " ", emojis: emojiMeta) +// let metaContent = try MastodonMetaContent.convert(document: mastodonContent) +// cell.statusView.nameLabel.configure(content: metaContent) +// } catch { +// let metaContent = PlaintextMetaContent(string: " ") +// cell.statusView.nameLabel.configure(content: metaContent) +// } +// cell.statusView.usernameLabel.text = username.flatMap { "@" + $0 } ?? " " +// } +// .store(in: &cell.disposeBag) } } diff --git a/Mastodon/Diffiable/Item/CustomEmojiPickerItem.swift b/Mastodon/Diffiable/Compose/CustomEmojiPickerItem.swift similarity index 100% rename from Mastodon/Diffiable/Item/CustomEmojiPickerItem.swift rename to Mastodon/Diffiable/Compose/CustomEmojiPickerItem.swift diff --git a/Mastodon/Diffiable/Section/Compose/CustomEmojiPickerSection.swift b/Mastodon/Diffiable/Compose/CustomEmojiPickerSection.swift similarity index 100% rename from Mastodon/Diffiable/Section/Compose/CustomEmojiPickerSection.swift rename to Mastodon/Diffiable/Compose/CustomEmojiPickerSection.swift diff --git a/Mastodon/Diffiable/DataSource/ASTableNode.swift b/Mastodon/Diffiable/DataSource/ASTableNode.swift deleted file mode 100644 index 36ff1fb07..000000000 --- a/Mastodon/Diffiable/DataSource/ASTableNode.swift +++ /dev/null @@ -1,85 +0,0 @@ -// -// ASTableNode.swift -// Mastodon -// -// Created by Cirno MainasuK on 2021-6-19. -// - -#if ASDK - -import UIKit -import AsyncDisplayKit -import DifferenceKit -import DiffableDataSources - -extension ASTableNode: ReloadableTableView { - public func reload<C>( - using stagedChangeset: StagedChangeset<C>, - deleteSectionsAnimation: @autoclosure () -> UITableView.RowAnimation, - insertSectionsAnimation: @autoclosure () -> UITableView.RowAnimation, - reloadSectionsAnimation: @autoclosure () -> UITableView.RowAnimation, - deleteRowsAnimation: @autoclosure () -> UITableView.RowAnimation, - insertRowsAnimation: @autoclosure () -> UITableView.RowAnimation, - reloadRowsAnimation: @autoclosure () -> UITableView.RowAnimation, - interrupt: ((Changeset<C>) -> Bool)? = nil, - setData: (C) -> Void - ) { - if case .none = view.window, let data = stagedChangeset.last?.data { - setData(data) - return reloadData() - } - - for changeset in stagedChangeset { - if let interrupt = interrupt, interrupt(changeset), let data = stagedChangeset.last?.data { - setData(data) - return reloadData() - } - - func updates() { - setData(changeset.data) - - if !changeset.sectionDeleted.isEmpty { - deleteSections(IndexSet(changeset.sectionDeleted), with: deleteSectionsAnimation()) - } - - if !changeset.sectionInserted.isEmpty { - insertSections(IndexSet(changeset.sectionInserted), with: insertSectionsAnimation()) - } - - if !changeset.sectionUpdated.isEmpty { - reloadSections(IndexSet(changeset.sectionUpdated), with: reloadSectionsAnimation()) - } - - for (source, target) in changeset.sectionMoved { - moveSection(source, toSection: target) - } - - if !changeset.elementDeleted.isEmpty { - deleteRows(at: changeset.elementDeleted.map { IndexPath(row: $0.element, section: $0.section) }, with: deleteRowsAnimation()) - } - - if !changeset.elementInserted.isEmpty { - insertRows(at: changeset.elementInserted.map { IndexPath(row: $0.element, section: $0.section) }, with: insertRowsAnimation()) - } - - if !changeset.elementUpdated.isEmpty { - reloadRows(at: changeset.elementUpdated.map { IndexPath(row: $0.element, section: $0.section) }, with: reloadRowsAnimation()) - } - - for (source, target) in changeset.elementMoved { - moveRow(at: IndexPath(row: source.element, section: source.section), to: IndexPath(row: target.element, section: target.section)) - } - } - - if isNodeLoaded { - view.beginUpdates() - updates() - view.endUpdates(animated: false, completion: nil) - } else { - updates() - } - } - } -} - -#endif diff --git a/Mastodon/Diffiable/DataSource/TableNodeDiffableDataSource.swift b/Mastodon/Diffiable/DataSource/TableNodeDiffableDataSource.swift deleted file mode 100644 index 54ab22a4c..000000000 --- a/Mastodon/Diffiable/DataSource/TableNodeDiffableDataSource.swift +++ /dev/null @@ -1,115 +0,0 @@ -// -// TableNodeDiffableDataSource.swift -// Mastodon -// -// Created by Cirno MainasuK on 2021-6-19. -// - -#if ASDK - -import UIKit -import AsyncDisplayKit -import DiffableDataSources - -open class TableNodeDiffableDataSource<SectionIdentifierType: Hashable, ItemIdentifierType: Hashable>: NSObject, ASTableDataSource { - /// The type of closure providing the cell. - public typealias CellProvider = (ASTableNode, IndexPath, ItemIdentifierType) -> ASCellNodeBlock? - - /// The default animation to updating the views. - public var defaultRowAnimation: UITableView.RowAnimation = .automatic - - private weak var tableNode: ASTableNode? - private let cellProvider: CellProvider - private let core = DiffableDataSourceCore<SectionIdentifierType, ItemIdentifierType>() - - /// Creates a new data source. - /// - /// - Parameters: - /// - tableView: A table view instance to be managed. - /// - cellProvider: A closure to dequeue the cell for rows. - public init(tableNode: ASTableNode, cellProvider: @escaping CellProvider) { - self.tableNode = tableNode - self.cellProvider = cellProvider - super.init() - - tableNode.delegate = self - } - - /// Applies given snapshot to perform automatic diffing update. - /// - /// - Parameters: - /// - snapshot: A snapshot object to be applied to data model. - /// - animatingDifferences: A Boolean value indicating whether to update with - /// diffing animation. - /// - completion: An optional completion block which is called when the complete - /// performing updates. - public func apply(_ snapshot: DiffableDataSourceSnapshot<SectionIdentifierType, ItemIdentifierType>, animatingDifferences: Bool = true, completion: (() -> Void)? = nil) { - core.apply(snapshot, view: tableNode, animatingDifferences: animatingDifferences, completion: completion) - } - - /// Returns a new snapshot object of current state. - /// - /// - Returns: A new snapshot object of current state. - public func snapshot() -> DiffableDataSourceSnapshot<SectionIdentifierType, ItemIdentifierType> { - return core.snapshot() - } - - /// Returns an item identifier for given index path. - /// - /// - Parameters: - /// - indexPath: An index path for the item identifier. - /// - /// - Returns: An item identifier for given index path. - public func itemIdentifier(for indexPath: IndexPath) -> ItemIdentifierType? { - return core.itemIdentifier(for: indexPath) - } - - /// Returns an index path for given item identifier. - /// - /// - Parameters: - /// - itemIdentifier: An identifier of item. - /// - /// - Returns: An index path for given item identifier. - public func indexPath(for itemIdentifier: ItemIdentifierType) -> IndexPath? { - return core.indexPath(for: itemIdentifier) - } - - /// Returns the number of sections in the data source. - /// - /// - Parameters: - /// - tableNode: A table node instance managed by `self`. - /// - /// - Returns: The number of sections in the data source. - public func numberOfSections(in tableNode: ASTableNode) -> Int { - return core.numberOfSections() - } - - /// Returns the number of items in the specified section. - /// - /// - Parameters: - /// - tableNode: A table node instance managed by `self`. - /// - section: An index of section. - /// - /// - Returns: The number of items in the specified section. - public func tableNode(_ tableNode: ASTableNode, numberOfRowsInSection section: Int) -> Int { - return core.numberOfItems(inSection: section) - } - - /// Returns a cell for row at specified index path. - /// - /// - Parameters: - /// - tableView: A table view instance managed by `self`. - /// - indexPath: An index path for cell. - /// - /// - Returns: A cell for row at specified index path. - open func tableNode(_ tableNode: ASTableNode, nodeBlockForRowAt indexPath: IndexPath) -> ASCellNodeBlock { - let itemIdentifier = core.unsafeItemIdentifier(for: indexPath) - guard let block = cellProvider(tableNode, indexPath, itemIdentifier) else { - fatalError("UITableView dataSource returned a nil cell for row at index path: \(indexPath), tableNode: \(tableNode), itemIdentifier: \(itemIdentifier)") - } - - return block - } -} - -#endif diff --git a/Mastodon/Diffiable/FetchedResultsController/FeedFetchedResultsController.swift b/Mastodon/Diffiable/FetchedResultsController/FeedFetchedResultsController.swift new file mode 100644 index 000000000..ab555c1c3 --- /dev/null +++ b/Mastodon/Diffiable/FetchedResultsController/FeedFetchedResultsController.swift @@ -0,0 +1,90 @@ +// +// FeedFetchedResultsController.swift +// FeedFetchedResultsController +// +// Created by Cirno MainasuK on 2021-8-19. +// Copyright © 2021 Twidere. All rights reserved. +// + +import os.log +import Foundation +import UIKit +import Combine +import CoreData +import CoreDataStack +import MastodonSDK + +final public class FeedFetchedResultsController: NSObject { + + public let logger = Logger(subsystem: "FeedFetchedResultsController", category: "DB") + + var disposeBag = Set<AnyCancellable>() + + public let fetchedResultsController: NSFetchedResultsController<Feed> + + // input + @Published public var predicate = Feed.predicate(kind: .none, acct: .none) + + // output + private let _objectIDs = PassthroughSubject<[NSManagedObjectID], Never>() + @Published public var records: [ManagedObjectRecord<Feed>] = [] + + public init(managedObjectContext: NSManagedObjectContext) { + self.fetchedResultsController = { + let fetchRequest = Feed.sortedFetchRequest + // make sure initial query return empty results + fetchRequest.returnsObjectsAsFaults = false + fetchRequest.shouldRefreshRefetchedObjects = true + fetchRequest.fetchBatchSize = 15 + let controller = NSFetchedResultsController( + fetchRequest: fetchRequest, + managedObjectContext: managedObjectContext, + sectionNameKeyPath: nil, + cacheName: nil + ) + + return controller + }() + super.init() + + // debounce output to prevent UI update issues + _objectIDs + .throttle(for: 0.1, scheduler: DispatchQueue.main, latest: true) + .map { objectIDs in objectIDs.map { ManagedObjectRecord(objectID: $0) } } + .assign(to: &$records) + + fetchedResultsController.delegate = self + + $predicate + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] predicate in + guard let self = self else { return } + self.fetchedResultsController.fetchRequest.predicate = predicate + do { + try self.fetchedResultsController.performFetch() + } catch { + assertionFailure(error.localizedDescription) + } + } + .store(in: &disposeBag) + } + + deinit { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") + } + +} + +// MARK: - NSFetchedResultsControllerDelegate +extension FeedFetchedResultsController: NSFetchedResultsControllerDelegate { + public func controller( + _ controller: NSFetchedResultsController<NSFetchRequestResult>, + didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference + ) { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + let snapshot = snapshot as NSDiffableDataSourceSnapshot<String, NSManagedObjectID> + self._objectIDs.send(snapshot.itemIdentifiers) + } +} + diff --git a/Mastodon/Diffiable/FetchedResultsController/SearchHistoryFetchedResultController.swift b/Mastodon/Diffiable/FetchedResultsController/SearchHistoryFetchedResultController.swift index 6d4461eab..c3521c6fe 100644 --- a/Mastodon/Diffiable/FetchedResultsController/SearchHistoryFetchedResultController.swift +++ b/Mastodon/Diffiable/FetchedResultsController/SearchHistoryFetchedResultController.swift @@ -21,8 +21,9 @@ final class SearchHistoryFetchedResultController: NSObject { let userID = CurrentValueSubject<Mastodon.Entity.Status.ID?, Never>(nil) // output - let objectIDs = CurrentValueSubject<[NSManagedObjectID], Never>([]) - + let _objectIDs = CurrentValueSubject<[NSManagedObjectID], Never>([]) + @Published var records: [ManagedObjectRecord<SearchHistory>] = [] + init(managedObjectContext: NSManagedObjectContext) { self.fetchedResultsController = { let fetchRequest = SearchHistory.sortedFetchRequest @@ -38,12 +39,18 @@ final class SearchHistoryFetchedResultController: NSObject { return controller }() super.init() + + // debounce output to prevent UI update issues + _objectIDs + .throttle(for: 0.1, scheduler: DispatchQueue.main, latest: true) + .map { objectIDs in objectIDs.map { ManagedObjectRecord(objectID: $0) } } + .assign(to: &$records) fetchedResultsController.delegate = self Publishers.CombineLatest( - self.domain.removeDuplicates(), - self.userID.removeDuplicates() + self.domain, + self.userID ) .receive(on: DispatchQueue.main) .sink { [weak self] domain, userID in @@ -67,6 +74,6 @@ extension SearchHistoryFetchedResultController: NSFetchedResultsControllerDelega os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) let objects = fetchedResultsController.fetchedObjects ?? [] - self.objectIDs.value = objects.map { $0.objectID } + self._objectIDs.value = objects.map { $0.objectID } } } diff --git a/Mastodon/Diffiable/FetchedResultsController/StatusFetchedResultsController.swift b/Mastodon/Diffiable/FetchedResultsController/StatusFetchedResultsController.swift index dd373b29f..24d8a6790 100644 --- a/Mastodon/Diffiable/FetchedResultsController/StatusFetchedResultsController.swift +++ b/Mastodon/Diffiable/FetchedResultsController/StatusFetchedResultsController.swift @@ -11,6 +11,7 @@ import Combine import CoreData import CoreDataStack import MastodonSDK +import MastodonUI final class StatusFetchedResultsController: NSObject { @@ -23,7 +24,8 @@ final class StatusFetchedResultsController: NSObject { let statusIDs = CurrentValueSubject<[Mastodon.Entity.Status.ID], Never>([]) // output - let objectIDs = CurrentValueSubject<[NSManagedObjectID], Never>([]) + let _objectIDs = CurrentValueSubject<[NSManagedObjectID], Never>([]) + @Published var records: [ManagedObjectRecord<Status>] = [] init(managedObjectContext: NSManagedObjectContext, domain: String?, additionalTweetPredicate: NSPredicate?) { self.domain.value = domain ?? "" @@ -43,11 +45,17 @@ final class StatusFetchedResultsController: NSObject { }() super.init() + // debounce output to prevent UI update issues + _objectIDs + .throttle(for: 0.1, scheduler: DispatchQueue.main, latest: true) + .map { objectIDs in objectIDs.map { ManagedObjectRecord(objectID: $0) } } + .assign(to: &$records) + fetchedResultsController.delegate = self Publishers.CombineLatest( - self.domain.removeDuplicates().eraseToAnyPublisher(), - self.statusIDs.removeDuplicates().eraseToAnyPublisher() + self.domain.removeDuplicates(), + self.statusIDs.removeDuplicates() ) .receive(on: DispatchQueue.main) .sink { [weak self] domain, ids in @@ -68,6 +76,18 @@ final class StatusFetchedResultsController: NSObject { } +extension StatusFetchedResultsController { + + public func append(statusIDs: [Mastodon.Entity.Status.ID]) { + var result = self.statusIDs.value + for statusID in statusIDs where !result.contains(statusID) { + result.append(statusID) + } + self.statusIDs.value = result + } + +} + // MARK: - NSFetchedResultsControllerDelegate extension StatusFetchedResultsController: NSFetchedResultsControllerDelegate { func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { @@ -82,6 +102,6 @@ extension StatusFetchedResultsController: NSFetchedResultsControllerDelegate { } .sorted { $0.0 < $1.0 } .map { $0.1.objectID } - self.objectIDs.value = items + self._objectIDs.value = items } } diff --git a/Mastodon/Diffiable/FetchedResultsController/UserFetchedResultsController.swift b/Mastodon/Diffiable/FetchedResultsController/UserFetchedResultsController.swift index f46ee978d..c0922afcb 100644 --- a/Mastodon/Diffiable/FetchedResultsController/UserFetchedResultsController.swift +++ b/Mastodon/Diffiable/FetchedResultsController/UserFetchedResultsController.swift @@ -11,6 +11,7 @@ import Combine import CoreData import CoreDataStack import MastodonSDK +import MastodonUI final class UserFetchedResultsController: NSObject { @@ -19,14 +20,20 @@ final class UserFetchedResultsController: NSObject { let fetchedResultsController: NSFetchedResultsController<MastodonUser> // input - let domain = CurrentValueSubject<String?, Never>(nil) - let userIDs = CurrentValueSubject<[Mastodon.Entity.Account.ID], Never>([]) + @Published var domain: String? = nil + @Published var userIDs: [Mastodon.Entity.Account.ID] = [] + @Published var additionalPredicate: NSPredicate? // output - let objectIDs = CurrentValueSubject<[NSManagedObjectID], Never>([]) + let _objectIDs = CurrentValueSubject<[NSManagedObjectID], Never>([]) + @Published var records: [ManagedObjectRecord<MastodonUser>] = [] - init(managedObjectContext: NSManagedObjectContext, domain: String?, additionalTweetPredicate: NSPredicate?) { - self.domain.value = domain ?? "" + init( + managedObjectContext: NSManagedObjectContext, + domain: String?, + additionalPredicate: NSPredicate? + ) { + self.domain = domain ?? "" self.fetchedResultsController = { let fetchRequest = MastodonUser.sortedFetchRequest fetchRequest.predicate = MastodonUser.predicate(domain: domain ?? "", ids: []) @@ -41,19 +48,27 @@ final class UserFetchedResultsController: NSObject { return controller }() + self.additionalPredicate = additionalPredicate super.init() + + // debounce output to prevent UI update issues + _objectIDs + .throttle(for: 0.1, scheduler: DispatchQueue.main, latest: true) + .map { objectIDs in objectIDs.map { ManagedObjectRecord(objectID: $0) } } + .assign(to: &$records) fetchedResultsController.delegate = self - Publishers.CombineLatest( - self.domain.removeDuplicates().eraseToAnyPublisher(), - self.userIDs.removeDuplicates().eraseToAnyPublisher() + Publishers.CombineLatest3( + self.$domain.removeDuplicates(), + self.$userIDs.removeDuplicates(), + self.$additionalPredicate.removeDuplicates() ) .receive(on: DispatchQueue.main) - .sink { [weak self] domain, ids in + .sink { [weak self] domain, ids, additionalPredicate in guard let self = self else { return } var predicates = [MastodonUser.predicate(domain: domain ?? "", ids: ids)] - if let additionalPredicate = additionalTweetPredicate { + if let additionalPredicate = additionalPredicate { predicates.append(additionalPredicate) } self.fetchedResultsController.fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) @@ -68,12 +83,24 @@ final class UserFetchedResultsController: NSObject { } +extension UserFetchedResultsController { + + public func append(userIDs: [Mastodon.Entity.Account.ID]) { + var result = self.userIDs + for userID in userIDs where !result.contains(userID) { + result.append(userID) + } + self.userIDs = result + } + +} + // MARK: - NSFetchedResultsControllerDelegate extension UserFetchedResultsController: NSFetchedResultsControllerDelegate { func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - let indexes = userIDs.value + let indexes = userIDs let objects = fetchedResultsController.fetchedObjects ?? [] let items: [NSManagedObjectID] = objects @@ -82,6 +109,6 @@ extension UserFetchedResultsController: NSFetchedResultsControllerDelegate { } .sorted { $0.0 < $1.0 } .map { $0.1.objectID } - self.objectIDs.value = items + self._objectIDs.value = items } } diff --git a/Mastodon/Diffiable/Item/Item.swift b/Mastodon/Diffiable/Item/Item.swift deleted file mode 100644 index 220a7fdba..000000000 --- a/Mastodon/Diffiable/Item/Item.swift +++ /dev/null @@ -1,198 +0,0 @@ -// -// Item.swift -// Mastodon -// -// Created by sxiaojian on 2021/1/27. -// - -import Combine -import CoreData -import CoreDataStack -import Foundation -import MastodonSDK -import DifferenceKit - -/// Note: update Equatable when change case -enum Item { - // timeline - case homeTimelineIndex(objectID: NSManagedObjectID, attribute: StatusAttribute) - - // thread - case root(statusObjectID: NSManagedObjectID, attribute: StatusAttribute) - case reply(statusObjectID: NSManagedObjectID, attribute: StatusAttribute) - case leaf(statusObjectID: NSManagedObjectID, attribute: StatusAttribute) - case leafBottomLoader(statusObjectID: NSManagedObjectID) - - // normal list - case status(objectID: NSManagedObjectID, attribute: StatusAttribute) - - // loader - case homeMiddleLoader(upperTimelineIndexAnchorObjectID: NSManagedObjectID) - case publicMiddleLoader(statusID: String) - case topLoader - case bottomLoader - case emptyBottomLoader - - case emptyStateHeader(attribute: EmptyStateHeaderAttribute) - - // reports - case reportStatus(objectID: NSManagedObjectID, attribute: ReportStatusAttribute) -} - -extension Item { - class StatusAttribute { - var isSeparatorLineHidden: Bool - - /// is media loaded or not - let isImageLoaded = CurrentValueSubject<Bool, Never>(false) - - /// flag for current sensitive content reveal state - /// - /// - true: displaying sensitive content - /// - false: displaying content warning overlay - let isRevealing = CurrentValueSubject<Bool, Never>(false) - - init(isSeparatorLineHidden: Bool = false) { - self.isSeparatorLineHidden = isSeparatorLineHidden - } - } - - class EmptyStateHeaderAttribute: Hashable { - let id = UUID() - let reason: Reason - - enum Reason: Equatable { - case noStatusFound - case blocking(name: String?) - case blocked(name: String?) - case suspended(name: String?) - - static func == (lhs: Item.EmptyStateHeaderAttribute.Reason, rhs: Item.EmptyStateHeaderAttribute.Reason) -> Bool { - switch (lhs, rhs) { - case (.noStatusFound, noStatusFound): return true - case (.blocking(let nameLeft), blocking(let nameRight)): return nameLeft == nameRight - case (.blocked(let nameLeft), blocked(let nameRight)): return nameLeft == nameRight - case (.suspended(let nameLeft), .suspended(let nameRight)): return nameLeft == nameRight - default: return false - } - } - } - - init(reason: Reason) { - self.reason = reason - } - - static func == (lhs: Item.EmptyStateHeaderAttribute, rhs: Item.EmptyStateHeaderAttribute) -> Bool { - return lhs.reason == rhs.reason - } - - func hash(into hasher: inout Hasher) { - hasher.combine(id) - } - } - - class ReportStatusAttribute: StatusAttribute { - var isSelected: Bool - - init(isSeparatorLineHidden: Bool = false, isSelected: Bool = false) { - self.isSelected = isSelected - super.init(isSeparatorLineHidden: isSeparatorLineHidden) - } - } - -} - -extension Item: Equatable { - static func == (lhs: Item, rhs: Item) -> Bool { - switch (lhs, rhs) { - case (.homeTimelineIndex(let objectIDLeft, _), .homeTimelineIndex(let objectIDRight, _)): - return objectIDLeft == objectIDRight - case (.root(let objectIDLeft, _), .root(let objectIDRight, _)): - return objectIDLeft == objectIDRight - case (.reply(let objectIDLeft, _), .reply(let objectIDRight, _)): - return objectIDLeft == objectIDRight - case (.leaf(let objectIDLeft, _), .leaf(let objectIDRight, _)): - return objectIDLeft == objectIDRight - case (.leafBottomLoader(let objectIDLeft), .leafBottomLoader(let objectIDRight)): - return objectIDLeft == objectIDRight - case (.status(let objectIDLeft, _), .status(let objectIDRight, _)): - return objectIDLeft == objectIDRight - case (.homeMiddleLoader(let upperLeft), .homeMiddleLoader(let upperRight)): - return upperLeft == upperRight - case (.publicMiddleLoader(let upperLeft), .publicMiddleLoader(let upperRight)): - return upperLeft == upperRight - case (.topLoader, .topLoader): - return true - case (.bottomLoader, .bottomLoader): - return true - case (.emptyBottomLoader, .emptyBottomLoader): - return true - case (.emptyStateHeader(let attributeLeft), .emptyStateHeader(let attributeRight)): - return attributeLeft == attributeRight - case (.reportStatus(let objectIDLeft, _), .reportStatus(let objectIDRight, _)): - return objectIDLeft == objectIDRight - default: - return false - } - } -} - -extension Item: Hashable { - func hash(into hasher: inout Hasher) { - switch self { - case .homeTimelineIndex(let objectID, _): - hasher.combine(objectID) - case .root(let objectID, _): - hasher.combine(objectID) - case .reply(let objectID, _): - hasher.combine(objectID) - case .leaf(let objectID, _): - hasher.combine(objectID) - case .leafBottomLoader(let objectID): - hasher.combine(objectID) - case .status(let objectID, _): - hasher.combine(objectID) - case .homeMiddleLoader(upperTimelineIndexAnchorObjectID: let upper): - hasher.combine(String(describing: Item.homeMiddleLoader.self)) - hasher.combine(upper) - case .publicMiddleLoader(let upper): - hasher.combine(String(describing: Item.publicMiddleLoader.self)) - hasher.combine(upper) - case .topLoader: - hasher.combine(String(describing: Item.topLoader.self)) - case .bottomLoader: - hasher.combine(String(describing: Item.bottomLoader.self)) - case .emptyBottomLoader: - hasher.combine(String(describing: Item.emptyBottomLoader.self)) - case .emptyStateHeader(let attribute): - hasher.combine(attribute) - case .reportStatus(let objectID, _): - hasher.combine(objectID) - } - } -} - -extension Item: Differentiable { } - -extension Item { - var statusObjectItem: StatusObjectItem? { - switch self { - case .homeTimelineIndex(let objectID, _): - return .homeTimelineIndex(objectID: objectID) - case .root(let objectID, _), - .reply(let objectID, _), - .leaf(let objectID, _), - .status(let objectID, _), - .reportStatus(let objectID, _): - return .status(objectID: objectID) - case .leafBottomLoader, - .homeMiddleLoader, - .publicMiddleLoader, - .topLoader, - .bottomLoader, - .emptyBottomLoader, - .emptyStateHeader: - return nil - } - } -} diff --git a/Mastodon/Diffiable/Item/NotificationItem.swift b/Mastodon/Diffiable/Item/NotificationItem.swift deleted file mode 100644 index fc7d0e0d9..000000000 --- a/Mastodon/Diffiable/Item/NotificationItem.swift +++ /dev/null @@ -1,56 +0,0 @@ -// -// NotificationItem.swift -// Mastodon -// -// Created by sxiaojian on 2021/4/13. -// - -import CoreData -import Foundation - -enum NotificationItem { - case notification(objectID: NSManagedObjectID, attribute: Item.StatusAttribute) - case notificationStatus(objectID: NSManagedObjectID, attribute: Item.StatusAttribute) // display notification status without card wrapper - case bottomLoader -} - -extension NotificationItem: Equatable { - static func == (lhs: NotificationItem, rhs: NotificationItem) -> Bool { - switch (lhs, rhs) { - case (.notification(let idLeft, _), .notification(let idRight, _)): - return idLeft == idRight - case (.notificationStatus(let idLeft, _), .notificationStatus(let idRight, _)): - return idLeft == idRight - case (.bottomLoader, .bottomLoader): - return true - default: - return false - } - } -} - -extension NotificationItem: Hashable { - func hash(into hasher: inout Hasher) { - switch self { - case .notification(let id, _): - hasher.combine(id) - case .notificationStatus(let id, _): - hasher.combine(id) - case .bottomLoader: - hasher.combine(String(describing: NotificationItem.bottomLoader.self)) - } - } -} - -extension NotificationItem { - var statusObjectItem: StatusObjectItem? { - switch self { - case .notification(let objectID, _): - return .mastodonNotification(objectID: objectID) - case .notificationStatus(let objectID, _): - return .mastodonNotification(objectID: objectID) - case .bottomLoader: - return nil - } - } -} diff --git a/Mastodon/Diffiable/Item/PollItem.swift b/Mastodon/Diffiable/Item/PollItem.swift deleted file mode 100644 index 0622e1d32..000000000 --- a/Mastodon/Diffiable/Item/PollItem.swift +++ /dev/null @@ -1,68 +0,0 @@ -// -// PollItem.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-3-2. -// - -import Foundation -import CoreData - -/// Note: update Equatable when change case -enum PollItem { - case option(objectID: NSManagedObjectID, attribute: Attribute) -} - - -extension PollItem { - class Attribute: Hashable { - - enum SelectState: Equatable, Hashable { - case none - case off - case on - } - - enum VoteState: Equatable, Hashable { - case hidden - case reveal(voted: Bool, percentage: Double, animated: Bool) - } - - var selectState: SelectState - var voteState: VoteState - - init(selectState: SelectState, voteState: VoteState) { - self.selectState = selectState - self.voteState = voteState - } - - static func == (lhs: PollItem.Attribute, rhs: PollItem.Attribute) -> Bool { - return lhs.selectState == rhs.selectState && - lhs.voteState == rhs.voteState - } - - func hash(into hasher: inout Hasher) { - hasher.combine(selectState) - hasher.combine(voteState) - } - } -} - -extension PollItem: Equatable { - static func == (lhs: PollItem, rhs: PollItem) -> Bool { - switch (lhs, rhs) { - case (.option(let objectIDLeft, _), .option(let objectIDRight, _)): - return objectIDLeft == objectIDRight - } - } -} - - -extension PollItem: Hashable { - func hash(into hasher: inout Hasher) { - switch self { - case .option(let objectID, _): - hasher.combine(objectID) - } - } -} diff --git a/Mastodon/Diffiable/Item/ProfileFieldItem.swift b/Mastodon/Diffiable/Item/ProfileFieldItem.swift deleted file mode 100644 index 781da1851..000000000 --- a/Mastodon/Diffiable/Item/ProfileFieldItem.swift +++ /dev/null @@ -1,103 +0,0 @@ -// -// ProfileFieldItem.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-5-25. -// - -import Foundation -import Combine -import MastodonSDK -import MastodonMeta - -enum ProfileFieldItem { - case field(field: FieldValue, attribute: FieldItemAttribute) - case addEntry(attribute: AddEntryItemAttribute) -} - -protocol ProfileFieldListSeparatorLineConfigurable: AnyObject { - var isLast: Bool { get set } -} - -extension ProfileFieldItem { - var listSeparatorLineConfigurable: ProfileFieldListSeparatorLineConfigurable? { - switch self { - case .field(_, let attribute): - return attribute - case .addEntry(let attribute): - return attribute - } - } -} - -extension ProfileFieldItem { - struct FieldValue: Equatable, Hashable { - let id: UUID - - var name: CurrentValueSubject<String, Never> - var value: CurrentValueSubject<String, Never> - - init(id: UUID = UUID(), name: String, value: String) { - self.id = id - self.name = CurrentValueSubject(name) - self.value = CurrentValueSubject(value) - } - - static func == (lhs: ProfileFieldItem.FieldValue, rhs: ProfileFieldItem.FieldValue) -> Bool { - return lhs.id == rhs.id - && lhs.name.value == rhs.name.value - && lhs.value.value == rhs.value.value - } - - func hash(into hasher: inout Hasher) { - hasher.combine(id) - } - } -} - -extension ProfileFieldItem { - class FieldItemAttribute: Equatable, ProfileFieldListSeparatorLineConfigurable { - let emojiMeta = CurrentValueSubject<MastodonContent.Emojis, Never>([:]) - - var isEditing = false - var isLast = false - - static func == (lhs: ProfileFieldItem.FieldItemAttribute, rhs: ProfileFieldItem.FieldItemAttribute) -> Bool { - return lhs.isEditing == rhs.isEditing - && lhs.isLast == rhs.isLast - } - } - - class AddEntryItemAttribute: Equatable, ProfileFieldListSeparatorLineConfigurable { - var isLast = false - - static func == (lhs: ProfileFieldItem.AddEntryItemAttribute, rhs: ProfileFieldItem.AddEntryItemAttribute) -> Bool { - return lhs.isLast == rhs.isLast - } - } -} - -extension ProfileFieldItem: Equatable { - static func == (lhs: ProfileFieldItem, rhs: ProfileFieldItem) -> Bool { - switch (lhs, rhs) { - case (.field(let fieldLeft, let attributeLeft), .field(let fieldRight, let attributeRight)): - return fieldLeft.id == fieldRight.id - && attributeLeft == attributeRight - case (.addEntry(let attributeLeft), .addEntry(let attributeRight)): - return attributeLeft == attributeRight - default: - return false - } - } -} - -extension ProfileFieldItem: Hashable { - func hash(into hasher: inout Hasher) { - switch self { - case .field(let field, _): - hasher.combine(field.id) - case .addEntry: - hasher.combine(String(describing: ProfileFieldItem.addEntry.self)) - } - } -} diff --git a/Mastodon/Diffiable/Item/SearchHistoryItem.swift b/Mastodon/Diffiable/Item/SearchHistoryItem.swift deleted file mode 100644 index de97eae34..000000000 --- a/Mastodon/Diffiable/Item/SearchHistoryItem.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// SearchHistoryItem.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-7-15. -// - -import Foundation -import CoreData - -enum SearchHistoryItem { - case account(objectID: NSManagedObjectID) - case hashtag(objectID: NSManagedObjectID) - case status(objectID: NSManagedObjectID, attribute: Item.StatusAttribute) -} - -extension SearchHistoryItem: Hashable { - static func == (lhs: SearchHistoryItem, rhs: SearchHistoryItem) -> Bool { - switch (lhs, rhs) { - case (.account(let objectIDLeft), account(let objectIDRight)): - return objectIDLeft == objectIDRight - case (.hashtag(let objectIDLeft), hashtag(let objectIDRight)): - return objectIDLeft == objectIDRight - case (.status(let objectIDLeft, _), status(let objectIDRight, _)): - return objectIDLeft == objectIDRight - default: - return false - } - } - - func hash(into hasher: inout Hasher) { - switch self { - case .account(let objectID): - hasher.combine(objectID) - case .hashtag(let objectID): - hasher.combine(objectID) - case .status(let objectID, _): - hasher.combine(objectID) - } - } -} diff --git a/Mastodon/Diffiable/Item/SearchResultItem.swift b/Mastodon/Diffiable/Item/SearchResultItem.swift deleted file mode 100644 index 7f57c4355..000000000 --- a/Mastodon/Diffiable/Item/SearchResultItem.swift +++ /dev/null @@ -1,94 +0,0 @@ -// -// SearchResultItem.swift -// Mastodon -// -// Created by sxiaojian on 2021/4/6. -// - -import CoreData -import Foundation -import MastodonSDK - -enum SearchResultItem { - case hashtag(tag: Mastodon.Entity.Tag) - case account(account: Mastodon.Entity.Account) - case status(statusObjectID: NSManagedObjectID, attribute: Item.StatusAttribute) - case bottomLoader(attribute: BottomLoaderAttribute) -} - -extension SearchResultItem { - class BottomLoaderAttribute: Hashable { - let id = UUID() - - var isNoResult: Bool - - init(isEmptyResult: Bool) { - self.isNoResult = isEmptyResult - } - - static func == (lhs: SearchResultItem.BottomLoaderAttribute, rhs: SearchResultItem.BottomLoaderAttribute) -> Bool { - return lhs.id == rhs.id - } - - func hash(into hasher: inout Hasher) { - hasher.combine(id) - } - } -} - -extension SearchResultItem: Equatable { - static func == (lhs: SearchResultItem, rhs: SearchResultItem) -> Bool { - switch (lhs, rhs) { - case (.hashtag(let tagLeft), .hashtag(let tagRight)): - return tagLeft == tagRight - case (.account(let accountLeft), .account(let accountRight)): - return accountLeft == accountRight - case (.status(let idLeft, _), .status(let idRight, _)): - return idLeft == idRight - case (.bottomLoader(let attributeLeft), .bottomLoader(let attributeRight)): - return attributeLeft == attributeRight - default: - return false - } - } -} - -extension SearchResultItem: Hashable { - func hash(into hasher: inout Hasher) { - switch self { - case .account(let account): - hasher.combine(String(describing: SearchResultItem.account.self)) - hasher.combine(account.id) - case .hashtag(let tag): - hasher.combine(String(describing: SearchResultItem.hashtag.self)) - hasher.combine(tag.name) - case .status(let id, _): - hasher.combine(id) - case .bottomLoader(let attribute): - hasher.combine(attribute) - } - } -} - -extension SearchResultItem { - var sortKey: String? { - switch self { - case .account(let account): return account.displayName.lowercased() - case .hashtag(let hashtag): return hashtag.name.lowercased() - default: return nil - } - } -} - -extension SearchResultItem { - var statusObjectItem: StatusObjectItem? { - switch self { - case .status(let objectID, _): - return .status(objectID: objectID) - case .hashtag, - .account, - .bottomLoader: - return nil - } - } -} diff --git a/Mastodon/Diffiable/Item/SelectedAccountItem.swift b/Mastodon/Diffiable/Item/SelectedAccountItem.swift deleted file mode 100644 index dbfe25cea..000000000 --- a/Mastodon/Diffiable/Item/SelectedAccountItem.swift +++ /dev/null @@ -1,38 +0,0 @@ -// -// SelectedAccountItem.swift -// Mastodon -// -// Created by sxiaojian on 2021/4/22. -// - -import CoreData -import Foundation - -enum SelectedAccountItem { - case accountObjectID(accountObjectID: NSManagedObjectID) - case placeHolder(uuid: UUID) -} - -extension SelectedAccountItem: Equatable { - static func == (lhs: SelectedAccountItem, rhs: SelectedAccountItem) -> Bool { - switch (lhs, rhs) { - case (.accountObjectID(let idLeft), .accountObjectID(let idRight)): - return idLeft == idRight - case (.placeHolder(let uuidLeft), .placeHolder(let uuidRight)): - return uuidLeft == uuidRight - default: - return false - } - } -} - -extension SelectedAccountItem: Hashable { - func hash(into hasher: inout Hasher) { - switch self { - case .accountObjectID(let id): - hasher.combine(id) - case .placeHolder(let id): - hasher.combine(id.uuidString) - } - } -} diff --git a/Mastodon/Diffiable/Notification/NotificationItem.swift b/Mastodon/Diffiable/Notification/NotificationItem.swift new file mode 100644 index 000000000..b0fdddb7f --- /dev/null +++ b/Mastodon/Diffiable/Notification/NotificationItem.swift @@ -0,0 +1,16 @@ +// +// NotificationItem.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/13. +// + +import CoreData +import Foundation +import CoreDataStack + +enum NotificationItem: Hashable { + case feed(record: ManagedObjectRecord<Feed>) + case feedLoader(record: ManagedObjectRecord<Feed>) + case bottomLoader +} diff --git a/Mastodon/Diffiable/Notification/NotificationSection.swift b/Mastodon/Diffiable/Notification/NotificationSection.swift new file mode 100644 index 000000000..97cf8ada0 --- /dev/null +++ b/Mastodon/Diffiable/Notification/NotificationSection.swift @@ -0,0 +1,109 @@ +// +// NotificationSection.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/13. +// + +import Combine +import CoreData +import CoreDataStack +import Foundation +import MastodonSDK +import UIKit +import MetaTextKit +import MastodonMeta +import MastodonAsset +import MastodonLocalization + +enum NotificationSection: Equatable, Hashable { + case main +} + +extension NotificationSection { + + struct Configuration { + weak var notificationTableViewCellDelegate: NotificationTableViewCellDelegate? + let filterContext: Mastodon.Entity.Filter.Context? + let activeFilters: Published<[Mastodon.Entity.Filter]>.Publisher? + } + + static func diffableDataSource( + tableView: UITableView, + context: AppContext, + configuration: Configuration + ) -> UITableViewDiffableDataSource<NotificationSection, NotificationItem> { + tableView.register(NotificationTableViewCell.self, forCellReuseIdentifier: String(describing: NotificationTableViewCell.self)) + tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) + + return UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item -> UITableViewCell? in + switch item { + case .feed(let record): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: NotificationTableViewCell.self), for: indexPath) as! NotificationTableViewCell + context.managedObjectContext.performAndWait { + guard let feed = record.object(in: context.managedObjectContext) else { return } + configure( + context: context, + tableView: tableView, + cell: cell, + viewModel: NotificationTableViewCell.ViewModel(value: .feed(feed)), + configuration: configuration + ) + } + return cell + case .feedLoader: + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell + cell.activityIndicatorView.startAnimating() + return cell + case .bottomLoader: + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell + cell.activityIndicatorView.startAnimating() + return cell + } + } + } +} + +extension NotificationSection { + + static func configure( + context: AppContext, + tableView: UITableView, + cell: NotificationTableViewCell, + viewModel: NotificationTableViewCell.ViewModel, + configuration: Configuration + ) { + StatusSection.setupStatusPollDataSource( + context: context, + statusView: cell.notificationView.statusView + ) + + StatusSection.setupStatusPollDataSource( + context: context, + statusView: cell.notificationView.quoteStatusView + ) + + context.authenticationService.activeMastodonAuthenticationBox + .map { $0 as UserIdentifier? } + .assign(to: \.userIdentifier, on: cell.notificationView.viewModel) + .store(in: &cell.disposeBag) + + cell.configure( + tableView: tableView, + viewModel: viewModel, + delegate: configuration.notificationTableViewCellDelegate + ) + + cell.notificationView.statusView.viewModel.filterContext = configuration.filterContext + cell.notificationView.quoteStatusView.viewModel.filterContext = configuration.filterContext + + configuration.activeFilters? + .assign(to: \.activeFilters, on: cell.notificationView.statusView.viewModel) + .store(in: &cell.disposeBag) + configuration.activeFilters? + .assign(to: \.activeFilters, on: cell.notificationView.quoteStatusView.viewModel) + .store(in: &cell.disposeBag) + } + +} + diff --git a/Mastodon/Diffiable/Item/CategoryPickerItem.swift b/Mastodon/Diffiable/Onboarding/CategoryPickerItem.swift similarity index 69% rename from Mastodon/Diffiable/Item/CategoryPickerItem.swift rename to Mastodon/Diffiable/Onboarding/CategoryPickerItem.swift index 0f2cdcc21..f6f364108 100644 --- a/Mastodon/Diffiable/Item/CategoryPickerItem.swift +++ b/Mastodon/Diffiable/Onboarding/CategoryPickerItem.swift @@ -7,6 +7,8 @@ import Foundation import MastodonSDK +import MastodonAsset +import MastodonLocalization /// Note: update Equatable when change case enum CategoryPickerItem { @@ -15,10 +17,11 @@ enum CategoryPickerItem { } extension CategoryPickerItem { - var title: String { + + var emoji: String { switch self { case .all: - return L10n.Scene.ServerPicker.Button.Category.all + return "💬" case .category(let category): switch category.category { case .academia: @@ -32,7 +35,7 @@ extension CategoryPickerItem { case .games: return "🕹" case .general: - return "💬" + return "🐘" case .journalism: return "📰" case .lgbt: @@ -50,6 +53,41 @@ extension CategoryPickerItem { } } } + var title: String { + switch self { + case .all: + return L10n.Scene.ServerPicker.Button.Category.all + case .category(let category): + switch category.category { + case .academia: + return L10n.Scene.ServerPicker.Button.Category.academia + case .activism: + return L10n.Scene.ServerPicker.Button.Category.activism + case .food: + return L10n.Scene.ServerPicker.Button.Category.food + case .furry: + return L10n.Scene.ServerPicker.Button.Category.furry + case .games: + return L10n.Scene.ServerPicker.Button.Category.games + case .general: + return L10n.Scene.ServerPicker.Button.Category.general + case .journalism: + return L10n.Scene.ServerPicker.Button.Category.journalism + case .lgbt: + return L10n.Scene.ServerPicker.Button.Category.lgbt + case .regional: + return L10n.Scene.ServerPicker.Button.Category.regional + case .art: + return L10n.Scene.ServerPicker.Button.Category.art + case .music: + return L10n.Scene.ServerPicker.Button.Category.music + case .tech: + return L10n.Scene.ServerPicker.Button.Category.tech + case ._other: + return "-" // FIXME: + } + } + } var accessibilityDescription: String { switch self { @@ -82,7 +120,7 @@ extension CategoryPickerItem { case .tech: return L10n.Scene.ServerPicker.Button.Category.tech case ._other: - return "❓" // FIXME: + return "-" // FIXME: } } } diff --git a/Mastodon/Diffiable/Section/Onboarding/CategoryPickerSection.swift b/Mastodon/Diffiable/Onboarding/CategoryPickerSection.swift similarity index 52% rename from Mastodon/Diffiable/Section/Onboarding/CategoryPickerSection.swift rename to Mastodon/Diffiable/Onboarding/CategoryPickerSection.swift index 732813c0a..b53b378d6 100644 --- a/Mastodon/Diffiable/Section/Onboarding/CategoryPickerSection.swift +++ b/Mastodon/Diffiable/Onboarding/CategoryPickerSection.swift @@ -6,6 +6,8 @@ // import UIKit +import MastodonAsset +import MastodonLocalization enum CategoryPickerSection: Equatable, Hashable { case main @@ -19,27 +21,11 @@ extension CategoryPickerSection { UICollectionViewDiffableDataSource(collectionView: collectionView) { [weak dependency] collectionView, indexPath, item -> UICollectionViewCell? in guard let _ = dependency else { return nil } let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: PickServerCategoryCollectionViewCell.self), for: indexPath) as! PickServerCategoryCollectionViewCell - switch item { - case .all: - cell.categoryView.titleLabel.font = .systemFont(ofSize: 17) - case .category: - cell.categoryView.titleLabel.font = .systemFont(ofSize: 28) - } + cell.categoryView.emojiLabel.text = item.emoji cell.categoryView.titleLabel.text = item.title cell.observe(\.isSelected, options: [.initial, .new]) { cell, _ in - if cell.isSelected { - cell.categoryView.bgView.backgroundColor = Asset.Colors.brandBlue.color - cell.categoryView.bgView.applyShadow(color: Asset.Colors.brandBlue.color, alpha: 1, x: 0, y: 0, blur: 4.0) - if case .all = item { - cell.categoryView.titleLabel.textColor = .white - } - } else { - cell.categoryView.bgView.backgroundColor = Asset.Theme.Mastodon.secondaryGroupedSystemBackground.color - cell.categoryView.bgView.applyShadow(color: Asset.Colors.brandBlue.color, alpha: 0, x: 0, y: 0, blur: 0.0) - if case .all = item { - cell.categoryView.titleLabel.textColor = Asset.Colors.brandBlue.color - } - } + cell.categoryView.highlightedIndicatorView.alpha = cell.isSelected ? 1 : 0 + cell.categoryView.titleLabel.textColor = cell.isSelected ? Asset.Colors.Label.primary.color : Asset.Colors.Label.secondary.color } .store(in: &cell.observations) diff --git a/Mastodon/Diffiable/Item/PickServerItem.swift b/Mastodon/Diffiable/Onboarding/PickServerItem.swift similarity index 85% rename from Mastodon/Diffiable/Item/PickServerItem.swift rename to Mastodon/Diffiable/Onboarding/PickServerItem.swift index 7db2c958f..ba693ad78 100644 --- a/Mastodon/Diffiable/Item/PickServerItem.swift +++ b/Mastodon/Diffiable/Onboarding/PickServerItem.swift @@ -12,8 +12,6 @@ import MastodonSDK /// Note: update Equatable when change case enum PickServerItem { case header - case categoryPicker(items: [CategoryPickerItem]) - case search case server(server: Mastodon.Entity.Server, attribute: ServerItemAttribute) case loader(attribute: LoaderItemAttribute) } @@ -63,10 +61,6 @@ extension PickServerItem: Equatable { switch (lhs, rhs) { case (.header, .header): return true - case (.categoryPicker(let itemsLeft), .categoryPicker(let itemsRight)): - return itemsLeft == itemsRight - case (.search, .search): - return true case (.server(let serverLeft, _), .server(let serverRight, _)): return serverLeft.domain == serverRight.domain case (.loader(let attributeLeft), loader(let attributeRight)): @@ -82,10 +76,6 @@ extension PickServerItem: Hashable { switch self { case .header: hasher.combine(String(describing: PickServerItem.header.self)) - case .categoryPicker(let items): - hasher.combine(items) - case .search: - hasher.combine(String(describing: PickServerItem.search.self)) case .server(let server, _): hasher.combine(server.domain) case .loader(let attribute): diff --git a/Mastodon/Diffiable/Section/Onboarding/PickServerSection.swift b/Mastodon/Diffiable/Onboarding/PickServerSection.swift similarity index 50% rename from Mastodon/Diffiable/Section/Onboarding/PickServerSection.swift rename to Mastodon/Diffiable/Onboarding/PickServerSection.swift index 28b1ded3f..5faaefbcc 100644 --- a/Mastodon/Diffiable/Section/Onboarding/PickServerSection.swift +++ b/Mastodon/Diffiable/Onboarding/PickServerSection.swift @@ -12,8 +12,6 @@ import AlamofireImage enum PickServerSection: Equatable, Hashable { case header - case category - case search case servers } @@ -21,36 +19,20 @@ extension PickServerSection { static func tableViewDiffableDataSource( for tableView: UITableView, dependency: NeedsDependency, - pickServerCategoriesCellDelegate: PickServerCategoriesCellDelegate, - pickServerSearchCellDelegate: PickServerSearchCellDelegate, pickServerCellDelegate: PickServerCellDelegate ) -> UITableViewDiffableDataSource<PickServerSection, PickServerItem> { - UITableViewDiffableDataSource(tableView: tableView) { [ + tableView.register(OnboardingHeadlineTableViewCell.self, forCellReuseIdentifier: String(describing: OnboardingHeadlineTableViewCell.self)) + tableView.register(PickServerCell.self, forCellReuseIdentifier: String(describing: PickServerCell.self)) + tableView.register(PickServerLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: PickServerLoaderTableViewCell.self)) + + return UITableViewDiffableDataSource(tableView: tableView) { [ weak dependency, - weak pickServerCategoriesCellDelegate, - weak pickServerSearchCellDelegate, weak pickServerCellDelegate ] tableView, indexPath, item -> UITableViewCell? in guard let dependency = dependency else { return nil } switch item { case .header: - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerTitleCell.self), for: indexPath) as! PickServerTitleCell - return cell - case .categoryPicker(let items): - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerCategoriesCell.self), for: indexPath) as! PickServerCategoriesCell - cell.delegate = pickServerCategoriesCellDelegate - cell.diffableDataSource = CategoryPickerSection.collectionViewDiffableDataSource( - for: cell.collectionView, - dependency: dependency - ) - var snapshot = NSDiffableDataSourceSnapshot<CategoryPickerSection, CategoryPickerItem>() - snapshot.appendSections([.main]) - snapshot.appendItems(items, toSection: .main) - cell.diffableDataSource?.apply(snapshot, animatingDifferences: false, completion: nil) - return cell - case .search: - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerSearchCell.self), for: indexPath) as! PickServerSearchCell - cell.delegate = pickServerSearchCellDelegate + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: OnboardingHeadlineTableViewCell.self), for: indexPath) as! OnboardingHeadlineTableViewCell return cell case .server(let server, let attribute): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerCell.self), for: indexPath) as! PickServerCell @@ -70,19 +52,63 @@ extension PickServerSection { static func configure(cell: PickServerCell, server: Mastodon.Entity.Server, attribute: PickServerItem.ServerItemAttribute) { cell.domainLabel.text = server.domain - cell.descriptionLabel.text = { - guard let html = try? HTML(html: server.description, encoding: .utf8) else { - return server.description - } + cell.descriptionLabel.attributedText = { + let content: String = { + guard let html = try? HTML(html: server.description, encoding: .utf8) else { + return server.description + } + return html.text ?? server.description + }() - return html.text ?? server.description + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineHeightMultiple = 1.16 + + return NSAttributedString( + string: content, + attributes: [ + .paragraphStyle: paragraphStyle + ] + ) }() - cell.langValueLabel.text = server.language.uppercased() - cell.usersValueLabel.text = parseUsersCount(server.totalUsers) - cell.categoryValueLabel.text = server.category.uppercased() - - cell.updateExpandMode(mode: attribute.isExpand ? .expand : .collapse) - + cell.usersValueLabel.attributedText = { + let attributedString = NSMutableAttributedString() + let attachment = NSTextAttachment(image: UIImage(systemName: "person.2.fill")!) + let attachmentAttributedString = NSAttributedString(attachment: attachment) + attributedString.append(attachmentAttributedString) + attributedString.append(NSAttributedString(string: " ")) + + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineHeightMultiple = 1.12 + let valueAttributedString = NSAttributedString( + string: parseUsersCount(server.totalUsers), + attributes: [ + .paragraphStyle: paragraphStyle + ] + ) + attributedString.append(valueAttributedString) + + return attributedString + }() + cell.langValueLabel.attributedText = { + let attributedString = NSMutableAttributedString() + let attachment = NSTextAttachment(image: UIImage(systemName: "text.bubble.fill")!) + let attachmentAttributedString = NSAttributedString(attachment: attachment) + attributedString.append(attachmentAttributedString) + attributedString.append(NSAttributedString(string: " ")) + + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineHeightMultiple = 1.12 + let valueAttributedString = NSAttributedString( + string: server.language.uppercased(), + attributes: [ + .paragraphStyle: paragraphStyle + ] + ) + attributedString.append(valueAttributedString) + + return attributedString + }() + attribute.isLast .receive(on: DispatchQueue.main) .sink { [weak cell] isLast in @@ -101,41 +127,6 @@ extension PickServerSection { } } .store(in: &cell.disposeBag) - - cell.expandMode - .receive(on: DispatchQueue.main) - .sink { mode in - switch mode { - case .collapse: - // do nothing - break - case .expand: - let placeholderImage = UIImage.placeholder(size: cell.thumbnailImageView.frame.size, color: .systemFill) - .af.imageRounded(withCornerRadius: 3.0, divideRadiusByImageScale: false) - guard let proxiedThumbnail = server.proxiedThumbnail, - let url = URL(string: proxiedThumbnail) else { - cell.thumbnailImageView.image = placeholderImage - cell.thumbnailActivityIndicator.stopAnimating() - return - } - cell.thumbnailImageView.isHidden = false - cell.thumbnailActivityIndicator.startAnimating() - - cell.thumbnailImageView.af.setImage( - withURL: url, - placeholderImage: placeholderImage, - filter: AspectScaledToFillSizeWithRoundedCornersFilter(size: cell.thumbnailImageView.frame.size, radius: 3), - imageTransition: .crossDissolve(0.33), - completion: { [weak cell] response in - switch response.result { - case .success, .failure: - cell?.thumbnailActivityIndicator.stopAnimating() - } - } - ) - } - } - .store(in: &cell.disposeBag) } private static func parseUsersCount(_ usersCount: Int) -> String { diff --git a/Mastodon/Diffiable/Onboarding/RegisterItem.swift b/Mastodon/Diffiable/Onboarding/RegisterItem.swift new file mode 100644 index 000000000..d54981b67 --- /dev/null +++ b/Mastodon/Diffiable/Onboarding/RegisterItem.swift @@ -0,0 +1,19 @@ +// +// RegisterItem.swift +// Mastodon +// +// Created by MainasuK on 2022-1-5. +// + +import Foundation + +enum RegisterItem: Hashable { + case header(domain: String) + case avatar + case name + case username + case email + case password + case hint + case reason +} diff --git a/Mastodon/Diffiable/Onboarding/RegisterSection.swift b/Mastodon/Diffiable/Onboarding/RegisterSection.swift new file mode 100644 index 000000000..efb67f698 --- /dev/null +++ b/Mastodon/Diffiable/Onboarding/RegisterSection.swift @@ -0,0 +1,12 @@ +// +// RegisterSection.swift +// Mastodon +// +// Created by MainasuK on 2022-1-5. +// + +import UIKit + +enum RegisterSection: Hashable { + case main +} diff --git a/Mastodon/Diffiable/Onboarding/ServerRuleItem.swift b/Mastodon/Diffiable/Onboarding/ServerRuleItem.swift new file mode 100644 index 000000000..37d8b6ee7 --- /dev/null +++ b/Mastodon/Diffiable/Onboarding/ServerRuleItem.swift @@ -0,0 +1,21 @@ +// +// ServerRuleItem.swift +// Mastodon +// +// Created by MainasuK on 2022-1-5. +// + +import Foundation +import MastodonSDK + +enum ServerRuleItem: Hashable { + case header(domain: String) + case rule(RuleContext) +} + +extension ServerRuleItem { + struct RuleContext: Hashable { + let index: Int + let rule: Mastodon.Entity.Instance.Rule + } +} diff --git a/Mastodon/Diffiable/Onboarding/ServerRuleSection.swift b/Mastodon/Diffiable/Onboarding/ServerRuleSection.swift new file mode 100644 index 000000000..c13e4ab2c --- /dev/null +++ b/Mastodon/Diffiable/Onboarding/ServerRuleSection.swift @@ -0,0 +1,36 @@ +// +// ServerRuleSection.swift +// Mastodon +// +// Created by MainasuK on 2022-1-5. +// + +import UIKit +import MastodonAsset +import MastodonLocalization + +enum ServerRuleSection: Hashable { + case header + case rules +} + +extension ServerRuleSection { + static func tableViewDiffableDataSource( + tableView: UITableView + ) -> UITableViewDiffableDataSource<ServerRuleSection, ServerRuleItem> { + return UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item in + switch item { + case .header(let domain): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: OnboardingHeadlineTableViewCell.self), for: indexPath) as! OnboardingHeadlineTableViewCell + cell.titleLabel.text = L10n.Scene.ServerRules.title + cell.subTitleLabel.text = L10n.Scene.ServerRules.subtitle(domain) + return cell + case .rule(let ruleContext): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ServerRulesTableViewCell.self), for: indexPath) as! ServerRulesTableViewCell + cell.indexImageView.image = UIImage(systemName: "\(ruleContext.index + 1).circle.fill") ?? UIImage(systemName: "questionmark.circle.fill") + cell.ruleLabel.text = ruleContext.rule.text + return cell + } + } + } +} diff --git a/Mastodon/Diffiable/Profile/ProfileFieldItem.swift b/Mastodon/Diffiable/Profile/ProfileFieldItem.swift new file mode 100644 index 000000000..47848cc01 --- /dev/null +++ b/Mastodon/Diffiable/Profile/ProfileFieldItem.swift @@ -0,0 +1,55 @@ +// +// ProfileFieldItem.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-5-25. +// + +import Foundation +import Combine +import MastodonSDK +import MastodonMeta + +enum ProfileFieldItem: Hashable { + case field(field: FieldValue) + case editField(field: FieldValue) + case addEntry + case noResult +} + +extension ProfileFieldItem { + struct FieldValue: Equatable, Hashable { + let id: UUID + + var name: CurrentValueSubject<String, Never> + var value: CurrentValueSubject<String, Never> + + let emojiMeta: MastodonContent.Emojis + + init( + id: UUID = UUID(), + name: String, + value: String, + emojiMeta: MastodonContent.Emojis + ) { + self.id = id + self.name = CurrentValueSubject(name) + self.value = CurrentValueSubject(value) + self.emojiMeta = emojiMeta + } + + static func == ( + lhs: ProfileFieldItem.FieldValue, + rhs: ProfileFieldItem.FieldValue + ) -> Bool { + return lhs.id == rhs.id + && lhs.name.value == rhs.name.value + && lhs.value.value == rhs.value.value + && lhs.emojiMeta == rhs.emojiMeta + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + } +} diff --git a/Mastodon/Diffiable/Profile/ProfileFieldSection.swift b/Mastodon/Diffiable/Profile/ProfileFieldSection.swift new file mode 100644 index 000000000..e1b0d649f --- /dev/null +++ b/Mastodon/Diffiable/Profile/ProfileFieldSection.swift @@ -0,0 +1,171 @@ +// +// ProfileFieldSection.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-5-25. +// + +import os +import UIKit +import Combine +import MastodonMeta +import MastodonLocalization + +enum ProfileFieldSection: Equatable, Hashable { + case main +} + +extension ProfileFieldSection { + + struct Configuration { + weak var profileFieldCollectionViewCellDelegate: ProfileFieldCollectionViewCellDelegate? + weak var profileFieldEditCollectionViewCellDelegate: ProfileFieldEditCollectionViewCellDelegate? + } + + static func diffableDataSource( + collectionView: UICollectionView, + context: AppContext, + configuration: Configuration + ) -> UICollectionViewDiffableDataSource<ProfileFieldSection, ProfileFieldItem> { + collectionView.register(ProfileFieldCollectionViewHeaderFooterView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: ProfileFieldCollectionViewHeaderFooterView.headerReuseIdentifer) + collectionView.register(ProfileFieldCollectionViewHeaderFooterView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter, withReuseIdentifier: ProfileFieldCollectionViewHeaderFooterView.footerReuseIdentifer) + + let fieldCellRegistration = UICollectionView.CellRegistration<ProfileFieldCollectionViewCell, ProfileFieldItem> { cell, indexPath, item in + guard case let .field(field) = item else { return } + + // set key + do { + let mastodonContent = MastodonContent(content: field.name.value, emojis: field.emojiMeta) + let metaContent = try MastodonMetaContent.convert(document: mastodonContent) + cell.keyMetaLabel.configure(content: metaContent) + } catch { + let content = PlaintextMetaContent(string: field.name.value) + cell.keyMetaLabel.configure(content: content) + } + + // set value + do { + let mastodonContent = MastodonContent(content: field.value.value, emojis: field.emojiMeta) + let metaContent = try MastodonMetaContent.convert(document: mastodonContent) + cell.valueMetaLabel.configure(content: metaContent) + } catch { + let content = PlaintextMetaContent(string: field.value.value) + cell.valueMetaLabel.configure(content: content) + } + + // set background + var backgroundConfiguration = UIBackgroundConfiguration.listPlainCell() + backgroundConfiguration.backgroundColor = UIColor.secondarySystemBackground + cell.backgroundConfiguration = backgroundConfiguration + + cell.delegate = configuration.profileFieldCollectionViewCellDelegate + } + + let editFieldCellRegistration = UICollectionView.CellRegistration<ProfileFieldEditCollectionViewCell, ProfileFieldItem> { cell, indexPath, item in + guard case let .editField(field) = item else { return } + + cell.keyTextField.text = field.name.value + cell.valueTextField.text = field.value.value + + NotificationCenter.default.publisher(for: UITextField.textDidChangeNotification, object: cell.keyTextField) + .compactMap { $0.object as? UITextField } + .map { $0.text ?? "" } + .removeDuplicates() + .assign(to: \.value, on: field.name) + .store(in: &cell.disposeBag) + + NotificationCenter.default.publisher(for: UITextField.textDidChangeNotification, object: cell.valueTextField) + .compactMap { $0.object as? UITextField } + .map { $0.text ?? "" } + .removeDuplicates() + .assign(to: \.value, on: field.value) + .store(in: &cell.disposeBag) + + // set background + var backgroundConfiguration = UIBackgroundConfiguration.listPlainCell() + backgroundConfiguration.backgroundColor = UIColor.secondarySystemBackground + cell.backgroundConfiguration = backgroundConfiguration + + cell.delegate = configuration.profileFieldEditCollectionViewCellDelegate + } + + let addEntryCellRegistration = UICollectionView.CellRegistration<ProfileFieldAddEntryCollectionViewCell, ProfileFieldItem> { cell, indexPath, item in + guard case .addEntry = item else { return } + + var backgroundConfiguration = UIBackgroundConfiguration.listPlainCell() + backgroundConfiguration.backgroundColorTransformer = .init { [weak cell] _ in + guard let cell = cell else { + return .secondarySystemBackground + } + let state = cell.configurationState + if state.isHighlighted || state.isSelected { + return .secondarySystemBackground.withAlphaComponent(0.5) + } else { + return .secondarySystemBackground + } + } + cell.backgroundConfiguration = backgroundConfiguration + } + + let noResultCellRegistration = UICollectionView.CellRegistration<UICollectionViewListCell, ProfileFieldItem> { cell, indexPath, item in + guard case .noResult = item else { return } + + var contentConfiguration = cell.defaultContentConfiguration() + contentConfiguration.text = L10n.Scene.Search.Searching.EmptyState.noResults // FIXME: + contentConfiguration.textProperties.alignment = .center + cell.contentConfiguration = contentConfiguration + + + var backgroundConfiguration = UIBackgroundConfiguration.listPlainCell() + backgroundConfiguration.backgroundColorTransformer = .init { _ in + return .secondarySystemBackground + } + cell.backgroundConfiguration = backgroundConfiguration + } + + let dataSource = UICollectionViewDiffableDataSource<ProfileFieldSection, ProfileFieldItem>(collectionView: collectionView) { collectionView, indexPath, item in + switch item { + case .field: + return collectionView.dequeueConfiguredReusableCell( + using: fieldCellRegistration, + for: indexPath, + item: item + ) + case .editField: + return collectionView.dequeueConfiguredReusableCell( + using: editFieldCellRegistration, + for: indexPath, + item: item + ) + case .addEntry: + return collectionView.dequeueConfiguredReusableCell( + using: addEntryCellRegistration, + for: indexPath, + item: item + ) + case .noResult: + return collectionView.dequeueConfiguredReusableCell( + using: noResultCellRegistration, + for: indexPath, + item: item + ) + } + } + + dataSource.supplementaryViewProvider = { collectionView, kind, indexPath in + switch kind { + case UICollectionView.elementKindSectionHeader: + let reusableView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: ProfileFieldCollectionViewHeaderFooterView.headerReuseIdentifer, for: indexPath) as! ProfileFieldCollectionViewHeaderFooterView + reusableView.frame.size.height = 20 + return reusableView + case UICollectionView.elementKindSectionFooter: + let reusableView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: ProfileFieldCollectionViewHeaderFooterView.footerReuseIdentifer, for: indexPath) as! ProfileFieldCollectionViewHeaderFooterView + return reusableView + default: + return nil + } + } + + return dataSource + } +} diff --git a/Mastodon/Diffiable/RecommandAccount/RecommendAccountItem.swift b/Mastodon/Diffiable/RecommandAccount/RecommendAccountItem.swift new file mode 100644 index 000000000..998f2f3e9 --- /dev/null +++ b/Mastodon/Diffiable/RecommandAccount/RecommendAccountItem.swift @@ -0,0 +1,13 @@ +// +// RecommendAccountItem.swift +// Mastodon +// +// Created by MainasuK on 2022-2-10. +// + +import Foundation +import CoreDataStack + +enum RecommendAccountItem: Hashable { + case account(ManagedObjectRecord<MastodonUser>) +} diff --git a/Mastodon/Diffiable/RecommandAccount/RecommendAccountSection.swift b/Mastodon/Diffiable/RecommandAccount/RecommendAccountSection.swift new file mode 100644 index 000000000..f59164f35 --- /dev/null +++ b/Mastodon/Diffiable/RecommandAccount/RecommendAccountSection.swift @@ -0,0 +1,162 @@ +// +// RecommendAccountSection.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/1. +// + +import CoreData +import CoreDataStack +import Foundation +import MastodonSDK +import UIKit +import MetaTextKit +import MastodonMeta +import Combine + +enum RecommendAccountSection: Equatable, Hashable { + case main +} + +//extension RecommendAccountSection { +// static func collectionViewDiffableDataSource( +// for collectionView: UICollectionView, +// dependency: NeedsDependency, +// delegate: SearchRecommendAccountsCollectionViewCellDelegate, +// managedObjectContext: NSManagedObjectContext +// ) -> UICollectionViewDiffableDataSource<RecommendAccountSection, NSManagedObjectID> { +// UICollectionViewDiffableDataSource(collectionView: collectionView) { [weak delegate] collectionView, indexPath, objectID -> UICollectionViewCell? in +// let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: SearchRecommendAccountsCollectionViewCell.self), for: indexPath) as! SearchRecommendAccountsCollectionViewCell +// managedObjectContext.performAndWait { +// let user = managedObjectContext.object(with: objectID) as! MastodonUser +// configure(cell: cell, user: user, dependency: dependency) +// } +// cell.delegate = delegate +// return cell +// } +// } +// +// static func configure( +// cell: SearchRecommendAccountsCollectionViewCell, +// user: MastodonUser, +// dependency: NeedsDependency +// ) { +// configureContent(cell: cell, user: user) +// +// if let currentMastodonUser = dependency.context.authenticationService.activeMastodonAuthentication.value?.user { +// configureFollowButton(with: user, currentMastodonUser: currentMastodonUser, followButton: cell.followButton) +// } +// +// Publishers.CombineLatest( +// ManagedObjectObserver.observe(object: user).eraseToAnyPublisher().mapError { $0 as Error }, +// dependency.context.authenticationService.activeMastodonAuthentication.setFailureType(to: Error.self) +// ) +// .receive(on: DispatchQueue.main) +// .sink { _ in +// // do nothing +// } receiveValue: { [weak cell] change, authentication in +// guard let cell = cell else { return } +// guard case .update(let object) = change.changeType, +// let user = object as? MastodonUser else { return } +// guard let currentMastodonUser = authentication?.user else { return } +// +// configureFollowButton(with: user, currentMastodonUser: currentMastodonUser, followButton: cell.followButton) +// } +// .store(in: &cell.disposeBag) +// +// } +// +// static func configureContent( +// cell: SearchRecommendAccountsCollectionViewCell, +// user: MastodonUser +// ) { +// do { +// let mastodonContent = MastodonContent(content: user.displayNameWithFallback, emojis: user.emojis.asDictionary) +// let metaContent = try MastodonMetaContent.convert(document: mastodonContent) +// cell.displayNameLabel.configure(content: metaContent) +// } catch { +// let metaContent = PlaintextMetaContent(string: user.displayNameWithFallback) +// cell.displayNameLabel.configure(content: metaContent) +// } +// cell.acctLabel.text = "@" + user.acct +// cell.avatarImageView.af.setImage( +// withURL: user.avatarImageURLWithFallback(domain: user.domain), +// placeholderImage: UIImage.placeholder(color: .systemFill), +// imageTransition: .crossDissolve(0.2) +// ) +// cell.headerImageView.af.setImage( +// withURL: URL(string: user.header)!, +// placeholderImage: UIImage.placeholder(color: .systemFill), +// imageTransition: .crossDissolve(0.2) +// ) +// } +// +// static func configureFollowButton( +// with mastodonUser: MastodonUser, +// currentMastodonUser: MastodonUser, +// followButton: HighlightDimmableButton +// ) { +// let relationshipActionSet = relationShipActionSet(mastodonUser: mastodonUser, currentMastodonUser: currentMastodonUser) +// followButton.setTitle(relationshipActionSet.title, for: .normal) +// } +// +// static func relationShipActionSet( +// mastodonUser: MastodonUser, +// currentMastodonUser: MastodonUser +// ) -> ProfileViewModel.RelationshipActionOptionSet { +// var relationshipActionSet = ProfileViewModel.RelationshipActionOptionSet([.follow]) +// let isFollowing = mastodonUser.followingBy.flatMap { $0.contains(currentMastodonUser) } ?? false +// if isFollowing { +// relationshipActionSet.insert(.following) +// } +// +// let isPending = mastodonUser.followRequestedBy.flatMap { $0.contains(currentMastodonUser) } ?? false +// if isPending { +// relationshipActionSet.insert(.pending) +// } +// +// let isBlocking = mastodonUser.blockingBy.flatMap { $0.contains(currentMastodonUser) } ?? false +// if isBlocking { +// relationshipActionSet.insert(.blocking) +// } +// +// let isBlockedBy = currentMastodonUser.blockingBy.flatMap { $0.contains(mastodonUser) } ?? false +// if isBlockedBy { +// relationshipActionSet.insert(.blocked) +// } +// return relationshipActionSet +// } +// +//} +// +extension RecommendAccountSection { + + struct Configuration { + weak var suggestionAccountTableViewCellDelegate: SuggestionAccountTableViewCellDelegate? + } + + static func tableViewDiffableDataSource( + tableView: UITableView, + context: AppContext, + configuration: Configuration + ) -> UITableViewDiffableDataSource<RecommendAccountSection, RecommendAccountItem> { + UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item -> UITableViewCell? in + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SuggestionAccountTableViewCell.self)) as! SuggestionAccountTableViewCell + switch item { + case .account(let record): + context.managedObjectContext.performAndWait { + guard let user = record.object(in: context.managedObjectContext) else { return } + cell.configure(user: user) + } + + context.authenticationService.activeMastodonAuthenticationBox + .map { $0 as UserIdentifier? } + .assign(to: \.userIdentifier, on: cell.viewModel) + .store(in: &cell.disposeBag) + cell.delegate = configuration.suggestionAccountTableViewCellDelegate + } + return cell + } + } + +} diff --git a/Mastodon/Diffiable/Report/ReportItem.swift b/Mastodon/Diffiable/Report/ReportItem.swift new file mode 100644 index 000000000..f5ea387b6 --- /dev/null +++ b/Mastodon/Diffiable/Report/ReportItem.swift @@ -0,0 +1,40 @@ +// +// ReportItem.swift +// Mastodon +// +// Created by MainasuK on 2022-1-27. +// + +import Foundation +import CoreDataStack + +enum ReportItem: Hashable { + case header(context: HeaderContext) + case status(record: ManagedObjectRecord<Status>) + case comment(context: CommentContext) + case result(record: ManagedObjectRecord<MastodonUser>) + case bottomLoader +} + +extension ReportItem { + struct HeaderContext: Hashable { + let primaryLabelText: String + let secondaryLabelText: String + } + + class CommentContext: Hashable { + let id = UUID() + @Published var comment: String = "" + + static func == ( + lhs: ReportItem.CommentContext, + rhs: ReportItem.CommentContext + ) -> Bool { + lhs.comment == rhs.comment + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + } +} diff --git a/Mastodon/Diffiable/Report/ReportSection.swift b/Mastodon/Diffiable/Report/ReportSection.swift new file mode 100644 index 000000000..69b9da234 --- /dev/null +++ b/Mastodon/Diffiable/Report/ReportSection.swift @@ -0,0 +1,117 @@ +// +// ReportSection.swift +// Mastodon +// +// Created by ihugo on 2021/4/20. +// + +import Combine +import CoreData +import CoreDataStack +import Foundation +import MastodonSDK +import UIKit +import os.log +import MastodonAsset +import MastodonLocalization + +enum ReportSection: Equatable, Hashable { + case main +} + +extension ReportSection { + + struct Configuration { + } + + static func diffableDataSource( + tableView: UITableView, + context: AppContext, + configuration: Configuration + ) -> UITableViewDiffableDataSource<ReportSection, ReportItem> { + + tableView.register(ReportHeadlineTableViewCell.self, forCellReuseIdentifier: String(describing: ReportHeadlineTableViewCell.self)) + tableView.register(ReportStatusTableViewCell.self, forCellReuseIdentifier: String(describing: ReportStatusTableViewCell.self)) + tableView.register(ReportCommentTableViewCell.self, forCellReuseIdentifier: String(describing: ReportCommentTableViewCell.self)) + tableView.register(ReportResultActionTableViewCell.self, forCellReuseIdentifier: String(describing: ReportResultActionTableViewCell.self)) + tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) + + return UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item -> UITableViewCell? in + switch item { + case .header(let headerContext): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ReportHeadlineTableViewCell.self), for: indexPath) as! ReportHeadlineTableViewCell + cell.primaryLabel.text = headerContext.primaryLabelText + cell.secondaryLabel.text = headerContext.secondaryLabelText + return cell + case .status(let record): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ReportStatusTableViewCell.self), for: indexPath) as! ReportStatusTableViewCell + context.managedObjectContext.performAndWait { + guard let status = record.object(in: context.managedObjectContext) else { return } + configure( + context: context, + tableView: tableView, + cell: cell, + viewModel: .init(value: status), + configuration: configuration + ) + } + return cell + case .comment(let commentContext): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ReportCommentTableViewCell.self), for: indexPath) as! ReportCommentTableViewCell + cell.commentTextView.text = commentContext.comment + NotificationCenter.default.publisher(for: UITextView.textDidChangeNotification, object: cell.commentTextView) + .receive(on: DispatchQueue.main) + .sink { [weak cell] notification in + guard let cell = cell else { return } + commentContext.comment = cell.commentTextView.text + + // fix shadow get animation issue when cell height changes + UIView.performWithoutAnimation { + tableView.beginUpdates() + tableView.endUpdates() + } + } + .store(in: &cell.disposeBag) + return cell + case .result(let record): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ReportResultActionTableViewCell.self), for: indexPath) as! ReportResultActionTableViewCell + context.managedObjectContext.performAndWait { + guard let user = record.object(in: context.managedObjectContext) else { return } + cell.avatarImageView.configure(configuration: .init(url: user.avatarImageURL())) + } + return cell + case .bottomLoader: + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell + cell.activityIndicatorView.startAnimating() + return cell + } + } + } +} + +extension ReportSection { + + static func configure( + context: AppContext, + tableView: UITableView, + cell: ReportStatusTableViewCell, + viewModel: ReportStatusTableViewCell.ViewModel, + configuration: Configuration + ) { + StatusSection.setupStatusPollDataSource( + context: context, + statusView: cell.statusView + ) + + context.authenticationService.activeMastodonAuthenticationBox + .map { $0 as UserIdentifier? } + .assign(to: \.userIdentifier, on: cell.statusView.viewModel) + .store(in: &cell.disposeBag) + + cell.configure( + tableView: tableView, + viewModel: viewModel + ) + } + +} diff --git a/Mastodon/Diffiable/Search/SearchHistoryItem.swift b/Mastodon/Diffiable/Search/SearchHistoryItem.swift new file mode 100644 index 000000000..ae156a81f --- /dev/null +++ b/Mastodon/Diffiable/Search/SearchHistoryItem.swift @@ -0,0 +1,15 @@ +// +// SearchHistoryItem.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-7-15. +// + +import Foundation +import CoreData +import CoreDataStack + +enum SearchHistoryItem: Hashable { + case hashtag(ManagedObjectRecord<Tag>) + case user(ManagedObjectRecord<MastodonUser>) +} diff --git a/Mastodon/Diffiable/Search/SearchHistorySection.swift b/Mastodon/Diffiable/Search/SearchHistorySection.swift new file mode 100644 index 000000000..dba1dc18a --- /dev/null +++ b/Mastodon/Diffiable/Search/SearchHistorySection.swift @@ -0,0 +1,92 @@ +// +// SearchHistorySection.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-7-15. +// + +import UIKit +import CoreDataStack + +enum SearchHistorySection: Hashable { + case main +} + +extension SearchHistorySection { + + struct Configuration { + weak var searchHistorySectionHeaderCollectionReusableViewDelegate: SearchHistorySectionHeaderCollectionReusableViewDelegate? + } + + static func diffableDataSource( + collectionView: UICollectionView, + context: AppContext, + configuration: Configuration + ) -> UICollectionViewDiffableDataSource<SearchHistorySection, SearchHistoryItem> { + + let userCellRegister = UICollectionView.CellRegistration<SearchHistoryUserCollectionViewCell, ManagedObjectRecord<MastodonUser>> { cell, indexPath, item in + context.managedObjectContext.performAndWait { + guard let user = item.object(in: context.managedObjectContext) else { return } + cell.configure(viewModel: .init(value: user)) + } + } + + let hashtagCellRegister = UICollectionView.CellRegistration<UICollectionViewListCell, ManagedObjectRecord<Tag>> { cell, indexPath, item in + context.managedObjectContext.performAndWait { + guard let hashtag = item.object(in: context.managedObjectContext) else { return } + var contentConfiguration = cell.defaultContentConfiguration() + contentConfiguration.text = "#" + hashtag.name + cell.contentConfiguration = contentConfiguration + } + + var backgroundConfiguration = UIBackgroundConfiguration.listGroupedCell() + backgroundConfiguration.backgroundColorTransformer = .init { [weak cell] _ in + guard let state = cell?.configurationState else { + return ThemeService.shared.currentTheme.value.secondarySystemGroupedBackgroundColor + } + + if state.isHighlighted || state.isSelected { + return ThemeService.shared.currentTheme.value.tableViewCellSelectionBackgroundColor + } + return ThemeService.shared.currentTheme.value.secondarySystemGroupedBackgroundColor + } + cell.backgroundConfiguration = backgroundConfiguration + } + + let dataSource = UICollectionViewDiffableDataSource<SearchHistorySection, SearchHistoryItem>(collectionView: collectionView) { collectionView, indexPath, item in + switch item { + case .user(let record): + return collectionView.dequeueConfiguredReusableCell( + using: userCellRegister, + for: indexPath, item: record) + case .hashtag(let record): + return collectionView.dequeueConfiguredReusableCell( + using: hashtagCellRegister, + for: indexPath, item: record) + } + } + + let trendHeaderRegister = UICollectionView.SupplementaryRegistration<SearchHistorySectionHeaderCollectionReusableView>(elementKind: UICollectionView.elementKindSectionHeader) { [weak dataSource] supplementaryView, elementKind, indexPath in + supplementaryView.delegate = configuration.searchHistorySectionHeaderCollectionReusableViewDelegate + + guard let dataSource = dataSource else { return } + let sections = dataSource.snapshot().sectionIdentifiers + guard indexPath.section < sections.count else { return } + let section = sections[indexPath.section] + } + + dataSource.supplementaryViewProvider = { (collectionView: UICollectionView, elementKind: String, indexPath: IndexPath) in + let fallback = UICollectionReusableView() + + switch elementKind { + case UICollectionView.elementKindSectionHeader: + return collectionView.dequeueConfiguredReusableSupplementary(using: trendHeaderRegister, for: indexPath) + default: + assertionFailure() + return fallback + } + } + + return dataSource + } // end func +} diff --git a/Mastodon/Diffiable/Search/SearchItem.swift b/Mastodon/Diffiable/Search/SearchItem.swift new file mode 100644 index 000000000..35d951130 --- /dev/null +++ b/Mastodon/Diffiable/Search/SearchItem.swift @@ -0,0 +1,13 @@ +// +// SearchItem.swift +// Mastodon +// +// Created by MainasuK on 2022-1-18. +// + +import Foundation +import MastodonSDK + +enum SearchItem: Hashable { + case trend(Mastodon.Entity.Tag) +} diff --git a/Mastodon/Diffiable/Search/SearchResultItem.swift b/Mastodon/Diffiable/Search/SearchResultItem.swift new file mode 100644 index 000000000..813836925 --- /dev/null +++ b/Mastodon/Diffiable/Search/SearchResultItem.swift @@ -0,0 +1,41 @@ +// +// SearchResultItem.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/6. +// + +import Foundation +import CoreData +import CoreDataStack +import MastodonSDK + +enum SearchResultItem: Hashable { + case user(ManagedObjectRecord<MastodonUser>) + case status(ManagedObjectRecord<Status>) + case hashtag(tag: Mastodon.Entity.Tag) + case bottomLoader(attribute: BottomLoaderAttribute) +} + +extension SearchResultItem { + class BottomLoaderAttribute: Hashable { + let id = UUID() + + var isNoResult: Bool + + init(isEmptyResult: Bool) { + self.isNoResult = isEmptyResult + } + + static func == ( + lhs: SearchResultItem.BottomLoaderAttribute, + rhs: SearchResultItem.BottomLoaderAttribute + ) -> Bool { + return lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + } +} diff --git a/Mastodon/Diffiable/Search/SearchResultSection.swift b/Mastodon/Diffiable/Search/SearchResultSection.swift new file mode 100644 index 000000000..1b1ac3ec9 --- /dev/null +++ b/Mastodon/Diffiable/Search/SearchResultSection.swift @@ -0,0 +1,130 @@ +// +// SearchResultSection.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/6. +// + +import os.log +import Foundation +import MastodonSDK +import UIKit +import CoreData +import CoreDataStack +import MastodonAsset +import MastodonLocalization +import MastodonUI + +enum SearchResultSection: Hashable { + case main +} + +extension SearchResultSection { + + static let logger = Logger(subsystem: "SearchResultSection", category: "logic") + + struct Configuration { + weak var statusViewTableViewCellDelegate: StatusTableViewCellDelegate? + weak var userTableViewCellDelegate: UserTableViewCellDelegate? + } + + static func tableViewDiffableDataSource( + tableView: UITableView, + context: AppContext, + configuration: Configuration + ) -> UITableViewDiffableDataSource<SearchResultSection, SearchResultItem> { + tableView.register(UserTableViewCell.self, forCellReuseIdentifier: String(describing: UserTableViewCell.self)) + tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.self)) + tableView.register(HashtagTableViewCell.self, forCellReuseIdentifier: String(describing: HashtagTableViewCell.self)) + tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) + + return UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item -> UITableViewCell? in + switch item { + case .user(let record): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: UserTableViewCell.self), for: indexPath) as! UserTableViewCell + context.managedObjectContext.performAndWait { + guard let user = record.object(in: context.managedObjectContext) else { return } + configure( + context: context, + tableView: tableView, + cell: cell, + viewModel: .init(value: .user(user)), + configuration: configuration + ) + } + return cell + case .status(let record): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusTableViewCell.self), for: indexPath) as! StatusTableViewCell + context.managedObjectContext.performAndWait { + guard let status = record.object(in: context.managedObjectContext) else { return } + configure( + context: context, + tableView: tableView, + cell: cell, + viewModel: StatusTableViewCell.ViewModel(value: .status(status)), + configuration: configuration + ) + } + return cell + case .hashtag(let tag): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: HashtagTableViewCell.self)) as! HashtagTableViewCell + cell.primaryLabel.configure(content: PlaintextMetaContent(string: "#" + tag.name)) + return cell + case .bottomLoader(let attribute): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) as! TimelineBottomLoaderTableViewCell + if attribute.isNoResult { + cell.stopAnimating() + cell.loadMoreLabel.text = L10n.Scene.Search.Searching.EmptyState.noResults + cell.loadMoreLabel.textColor = Asset.Colors.Label.secondary.color + cell.loadMoreLabel.isHidden = false + } else { + cell.startAnimating() + cell.loadMoreLabel.isHidden = true + } + return cell + } + } // end UITableViewDiffableDataSource + } // end func +} + +extension SearchResultSection { + + static func configure( + context: AppContext, + tableView: UITableView, + cell: StatusTableViewCell, + viewModel: StatusTableViewCell.ViewModel, + configuration: Configuration + ) { + StatusSection.setupStatusPollDataSource( + context: context, + statusView: cell.statusView + ) + + context.authenticationService.activeMastodonAuthenticationBox + .map { $0 as UserIdentifier? } + .assign(to: \.userIdentifier, on: cell.statusView.viewModel) + .store(in: &cell.disposeBag) + + cell.configure( + tableView: tableView, + viewModel: viewModel, + delegate: configuration.statusViewTableViewCellDelegate + ) + } + + static func configure( + context: AppContext, + tableView: UITableView, + cell: UserTableViewCell, + viewModel: UserTableViewCell.ViewModel, + configuration: Configuration + ) { + cell.configure( + tableView: tableView, + viewModel: viewModel, + delegate: configuration.userTableViewCellDelegate + ) + } + +} diff --git a/Mastodon/Diffiable/Search/SearchSection.swift b/Mastodon/Diffiable/Search/SearchSection.swift new file mode 100644 index 000000000..21f1d479c --- /dev/null +++ b/Mastodon/Diffiable/Search/SearchSection.swift @@ -0,0 +1,85 @@ +// +// SearchSection.swift +// Mastodon +// +// Created by MainasuK on 2022-1-18. +// + +import UIKit +import MastodonSDK +import MastodonLocalization + +enum SearchSection: Hashable { + case trend +} + +extension SearchSection { + + static func diffableDataSource( + collectionView: UICollectionView, + context: AppContext + ) -> UICollectionViewDiffableDataSource<SearchSection, SearchItem> { + + let trendCellRegister = UICollectionView.CellRegistration<TrendCollectionViewCell, Mastodon.Entity.Tag> { cell, indexPath, item in + let primaryLabelText = "#" + item.name + let secondaryLabelText = L10n.Scene.Search.Recommend.HashTag.peopleTalking(item.talkingPeopleCount ?? 0) + + cell.primaryLabel.text = primaryLabelText + cell.secondaryLabel.text = secondaryLabelText + + cell.lineChartView.data = (item.history ?? []) + .sorted(by: { $0.day < $1.day }) // latest last + .map { entry in + guard let point = Int(entry.accounts) else { + return .zero + } + return CGFloat(point) + } + + cell.isAccessibilityElement = true + cell.accessibilityLabel = [ + primaryLabelText, + secondaryLabelText + ].joined(separator: ", ") + } + + let dataSource = UICollectionViewDiffableDataSource<SearchSection, SearchItem>( + collectionView: collectionView + ) { collectionView, indexPath, item in + switch item { + case .trend(let hashtag): + let cell = collectionView.dequeueConfiguredReusableCell( + using: trendCellRegister, + for: indexPath, + item: hashtag + ) + return cell + } + } + + let trendHeaderRegister = UICollectionView.SupplementaryRegistration<TrendSectionHeaderCollectionReusableView>(elementKind: UICollectionView.elementKindSectionHeader) { supplementaryView, elementKind, indexPath in + // do nothing + } + + dataSource.supplementaryViewProvider = { [weak dataSource] (collectionView: UICollectionView, elementKind: String, indexPath: IndexPath) in + let fallback = UICollectionReusableView() + guard let dataSource = dataSource else { return fallback } + let sections = dataSource.snapshot().sectionIdentifiers + guard indexPath.section < sections.count else { return fallback } + let section = sections[indexPath.section] + + switch elementKind { + case UICollectionView.elementKindSectionHeader: + switch section { + case .trend: + return collectionView.dequeueConfiguredReusableSupplementary(using: trendHeaderRegister, for: indexPath) + } + default: + assertionFailure() + return fallback + } + } + + return dataSource + } // end func +} diff --git a/Mastodon/Diffiable/Section/ProfileFieldSection.swift b/Mastodon/Diffiable/Section/ProfileFieldSection.swift deleted file mode 100644 index e96a16e9e..000000000 --- a/Mastodon/Diffiable/Section/ProfileFieldSection.swift +++ /dev/null @@ -1,149 +0,0 @@ -// -// ProfileFieldSection.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-5-25. -// - -import os -import UIKit -import Combine -import MastodonMeta - -enum ProfileFieldSection: Equatable, Hashable { - case main -} - -extension ProfileFieldSection { - static func collectionViewDiffableDataSource( - for collectionView: UICollectionView, - profileFieldCollectionViewCellDelegate: ProfileFieldCollectionViewCellDelegate, - profileFieldAddEntryCollectionViewCellDelegate: ProfileFieldAddEntryCollectionViewCellDelegate - ) -> UICollectionViewDiffableDataSource<ProfileFieldSection, ProfileFieldItem> { - let dataSource = UICollectionViewDiffableDataSource<ProfileFieldSection, ProfileFieldItem>(collectionView: collectionView) { - [ - weak profileFieldCollectionViewCellDelegate, - weak profileFieldAddEntryCollectionViewCellDelegate - ] collectionView, indexPath, item in - switch item { - case .field(let field, let attribute): - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ProfileFieldCollectionViewCell.self), for: indexPath) as! ProfileFieldCollectionViewCell - - // set key - do { - let mastodonContent = MastodonContent(content: field.name.value, emojis: attribute.emojiMeta.value) - let metaContent = try MastodonMetaContent.convert(document: mastodonContent) - cell.fieldView.titleMetaLabel.configure(content: metaContent) - } catch { - let content = PlaintextMetaContent(string: field.name.value) - cell.fieldView.titleMetaLabel.configure(content: content) - } - cell.fieldView.titleTextField.text = field.name.value - Publishers.CombineLatest( - field.name.removeDuplicates(), - attribute.emojiMeta.removeDuplicates() - ) - .receive(on: RunLoop.main) - .sink { [weak cell] name, emojiMeta in - guard let cell = cell else { return } - do { - let mastodonContent = MastodonContent(content: name, emojis: emojiMeta) - let metaContent = try MastodonMetaContent.convert(document: mastodonContent) - cell.fieldView.titleMetaLabel.configure(content: metaContent) - } catch { - let content = PlaintextMetaContent(string: name) - cell.fieldView.titleMetaLabel.configure(content: content) - } - // only bind label. The text field should only set once - } - .store(in: &cell.disposeBag) - - - // set value - do { - let mastodonContent = MastodonContent(content: field.value.value, emojis: attribute.emojiMeta.value) - let metaContent = try MastodonMetaContent.convert(document: mastodonContent) - cell.fieldView.valueMetaLabel.configure(content: metaContent) - } catch { - let content = PlaintextMetaContent(string: field.value.value) - cell.fieldView.valueMetaLabel.configure(content: content) - } - cell.fieldView.valueTextField.text = field.value.value - Publishers.CombineLatest( - field.value.removeDuplicates(), - attribute.emojiMeta.removeDuplicates() - ) - .receive(on: RunLoop.main) - .sink { [weak cell] value, emojiMeta in - guard let cell = cell else { return } - do { - let mastodonContent = MastodonContent(content: value, emojis: emojiMeta) - let metaContent = try MastodonMetaContent.convert(document: mastodonContent) - cell.fieldView.valueMetaLabel.configure(content: metaContent) - } catch { - let content = PlaintextMetaContent(string: value) - cell.fieldView.valueMetaLabel.configure(content: content) - } - // only bind label. The text field should only set once - } - .store(in: &cell.disposeBag) - - // bind editing - if attribute.isEditing { - cell.fieldView.name - .removeDuplicates() - .receive(on: RunLoop.main) - .assign(to: \.value, on: field.name) - .store(in: &cell.disposeBag) - cell.fieldView.value - .removeDuplicates() - .receive(on: RunLoop.main) - .assign(to: \.value, on: field.value) - .store(in: &cell.disposeBag) - } - - // setup editing state - cell.fieldView.titleTextField.isHidden = !attribute.isEditing - cell.fieldView.valueTextField.isHidden = !attribute.isEditing - cell.fieldView.titleMetaLabel.isHidden = attribute.isEditing - cell.fieldView.valueMetaLabel.isHidden = attribute.isEditing - - // set control hidden - let isHidden = !attribute.isEditing - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: update editing state: %s", ((#file as NSString).lastPathComponent), #line, #function, isHidden ? "true" : "false") - cell.editButton.isHidden = isHidden - cell.reorderBarImageView.isHidden = isHidden - - // update separator line - cell.bottomSeparatorLine.isHidden = attribute.isLast - - cell.delegate = profileFieldCollectionViewCellDelegate - - return cell - - case .addEntry(let attribute): - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ProfileFieldAddEntryCollectionViewCell.self), for: indexPath) as! ProfileFieldAddEntryCollectionViewCell - - cell.bottomSeparatorLine.isHidden = attribute.isLast - cell.delegate = profileFieldAddEntryCollectionViewCellDelegate - - return cell - } - } - - dataSource.supplementaryViewProvider = { collectionView, kind, indexPath in - switch kind { - case UICollectionView.elementKindSectionHeader: - let reusableView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: ProfileFieldCollectionViewHeaderFooterView.headerReuseIdentifer, for: indexPath) as! ProfileFieldCollectionViewHeaderFooterView - return reusableView - case UICollectionView.elementKindSectionFooter: - let reusableView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: ProfileFieldCollectionViewHeaderFooterView.footerReuseIdentifer, for: indexPath) as! ProfileFieldCollectionViewHeaderFooterView - return reusableView - default: - return nil - } - } - - return dataSource - } -} diff --git a/Mastodon/Diffiable/Section/Search/RecommendAccountSection.swift b/Mastodon/Diffiable/Section/Search/RecommendAccountSection.swift deleted file mode 100644 index 3d6cff19e..000000000 --- a/Mastodon/Diffiable/Section/Search/RecommendAccountSection.swift +++ /dev/null @@ -1,150 +0,0 @@ -// -// RecommendAccountSection.swift -// Mastodon -// -// Created by sxiaojian on 2021/4/1. -// - -import CoreData -import CoreDataStack -import Foundation -import MastodonSDK -import UIKit -import MetaTextKit -import MastodonMeta -import Combine - -enum RecommendAccountSection: Equatable, Hashable { - case main -} - -extension RecommendAccountSection { - static func collectionViewDiffableDataSource( - for collectionView: UICollectionView, - dependency: NeedsDependency, - delegate: SearchRecommendAccountsCollectionViewCellDelegate, - managedObjectContext: NSManagedObjectContext - ) -> UICollectionViewDiffableDataSource<RecommendAccountSection, NSManagedObjectID> { - UICollectionViewDiffableDataSource(collectionView: collectionView) { [weak delegate] collectionView, indexPath, objectID -> UICollectionViewCell? in - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: SearchRecommendAccountsCollectionViewCell.self), for: indexPath) as! SearchRecommendAccountsCollectionViewCell - managedObjectContext.performAndWait { - let user = managedObjectContext.object(with: objectID) as! MastodonUser - configure(cell: cell, user: user, dependency: dependency) - } - cell.delegate = delegate - return cell - } - } - - static func configure( - cell: SearchRecommendAccountsCollectionViewCell, - user: MastodonUser, - dependency: NeedsDependency - ) { - configureContent(cell: cell, user: user) - - if let currentMastodonUser = dependency.context.authenticationService.activeMastodonAuthentication.value?.user { - configureFollowButton(with: user, currentMastodonUser: currentMastodonUser, followButton: cell.followButton) - } - - Publishers.CombineLatest( - ManagedObjectObserver.observe(object: user).eraseToAnyPublisher().mapError { $0 as Error }, - dependency.context.authenticationService.activeMastodonAuthentication.setFailureType(to: Error.self) - ) - .receive(on: DispatchQueue.main) - .sink { _ in - // do nothing - } receiveValue: { [weak cell] change, authentication in - guard let cell = cell else { return } - guard case .update(let object) = change.changeType, - let user = object as? MastodonUser else { return } - guard let currentMastodonUser = authentication?.user else { return } - - configureFollowButton(with: user, currentMastodonUser: currentMastodonUser, followButton: cell.followButton) - } - .store(in: &cell.disposeBag) - - } - - static func configureContent( - cell: SearchRecommendAccountsCollectionViewCell, - user: MastodonUser - ) { - do { - let mastodonContent = MastodonContent(content: user.displayNameWithFallback, emojis: user.emojiMeta) - let metaContent = try MastodonMetaContent.convert(document: mastodonContent) - cell.displayNameLabel.configure(content: metaContent) - } catch { - let metaContent = PlaintextMetaContent(string: user.displayNameWithFallback) - cell.displayNameLabel.configure(content: metaContent) - } - cell.acctLabel.text = "@" + user.acct - cell.avatarImageView.af.setImage( - withURL: user.avatarImageURLWithFallback(domain: user.domain), - placeholderImage: UIImage.placeholder(color: .systemFill), - imageTransition: .crossDissolve(0.2) - ) - cell.headerImageView.af.setImage( - withURL: URL(string: user.header)!, - placeholderImage: UIImage.placeholder(color: .systemFill), - imageTransition: .crossDissolve(0.2) - ) - } - - static func configureFollowButton( - with mastodonUser: MastodonUser, - currentMastodonUser: MastodonUser, - followButton: HighlightDimmableButton - ) { - let relationshipActionSet = relationShipActionSet(mastodonUser: mastodonUser, currentMastodonUser: currentMastodonUser) - followButton.setTitle(relationshipActionSet.title, for: .normal) - } - - static func relationShipActionSet( - mastodonUser: MastodonUser, - currentMastodonUser: MastodonUser - ) -> ProfileViewModel.RelationshipActionOptionSet { - var relationshipActionSet = ProfileViewModel.RelationshipActionOptionSet([.follow]) - let isFollowing = mastodonUser.followingBy.flatMap { $0.contains(currentMastodonUser) } ?? false - if isFollowing { - relationshipActionSet.insert(.following) - } - - let isPending = mastodonUser.followRequestedBy.flatMap { $0.contains(currentMastodonUser) } ?? false - if isPending { - relationshipActionSet.insert(.pending) - } - - let isBlocking = mastodonUser.blockingBy.flatMap { $0.contains(currentMastodonUser) } ?? false - if isBlocking { - relationshipActionSet.insert(.blocking) - } - - let isBlockedBy = currentMastodonUser.blockingBy.flatMap { $0.contains(mastodonUser) } ?? false - if isBlockedBy { - relationshipActionSet.insert(.blocked) - } - return relationshipActionSet - } - -} - -extension RecommendAccountSection { - - static func tableViewDiffableDataSource( - for tableView: UITableView, - managedObjectContext: NSManagedObjectContext, - viewModel: SuggestionAccountViewModel, - delegate: SuggestionAccountTableViewCellDelegate - ) -> UITableViewDiffableDataSource<RecommendAccountSection, NSManagedObjectID> { - UITableViewDiffableDataSource(tableView: tableView) { [weak viewModel, weak delegate] (tableView, indexPath, objectID) -> UITableViewCell? in - guard let viewModel = viewModel else { return nil } - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SuggestionAccountTableViewCell.self)) as! SuggestionAccountTableViewCell - let user = managedObjectContext.object(with: objectID) as! MastodonUser - let isSelected = viewModel.selectedAccounts.value.contains(objectID) - cell.delegate = delegate - cell.config(with: user, isSelected: isSelected) - return cell - } - } -} diff --git a/Mastodon/Diffiable/Section/Search/RecommendHashTagSection.swift b/Mastodon/Diffiable/Section/Search/RecommendHashTagSection.swift deleted file mode 100644 index 502086910..000000000 --- a/Mastodon/Diffiable/Section/Search/RecommendHashTagSection.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// RecommendHashTagSection.swift -// Mastodon -// -// Created by sxiaojian on 2021/4/1. -// - -import Foundation -import MastodonSDK -import UIKit - -enum RecommendHashTagSection: Equatable, Hashable { - case main -} - -extension RecommendHashTagSection { - static func collectionViewDiffableDataSource( - for collectionView: UICollectionView - ) -> UICollectionViewDiffableDataSource<RecommendHashTagSection, Mastodon.Entity.Tag> { - UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, tag -> UICollectionViewCell? in - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: SearchRecommendTagsCollectionViewCell.self), for: indexPath) as! SearchRecommendTagsCollectionViewCell - cell.config(with: tag) - return cell - } - } -} diff --git a/Mastodon/Diffiable/Section/Search/SearchHistorySection.swift b/Mastodon/Diffiable/Section/Search/SearchHistorySection.swift deleted file mode 100644 index b5c5cd8cc..000000000 --- a/Mastodon/Diffiable/Section/Search/SearchHistorySection.swift +++ /dev/null @@ -1,40 +0,0 @@ -// -// SearchHistorySection.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-7-15. -// - -import UIKit -import CoreDataStack - -enum SearchHistorySection: Hashable { - case main -} - -extension SearchHistorySection { - static func tableViewDiffableDataSource( - for tableView: UITableView, - dependency: NeedsDependency - ) -> UITableViewDiffableDataSource<SearchHistorySection, SearchHistoryItem> { - UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item -> UITableViewCell? in - switch item { - case .account(let objectID): - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchResultTableViewCell.self), for: indexPath) as! SearchResultTableViewCell - if let user = try? dependency.context.managedObjectContext.existingObject(with: objectID) as? MastodonUser { - cell.config(with: user) - } - return cell - case .hashtag(let objectID): - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchResultTableViewCell.self), for: indexPath) as! SearchResultTableViewCell - if let hashtag = try? dependency.context.managedObjectContext.existingObject(with: objectID) as? Tag { - cell.config(with: hashtag) - } - return cell - case .status: - // Should not show status in the history list - return UITableViewCell() - } // end switch - } // end UITableViewDiffableDataSource - } // end func -} diff --git a/Mastodon/Diffiable/Section/Search/SearchResultSection.swift b/Mastodon/Diffiable/Section/Search/SearchResultSection.swift deleted file mode 100644 index dcc52e15b..000000000 --- a/Mastodon/Diffiable/Section/Search/SearchResultSection.swift +++ /dev/null @@ -1,69 +0,0 @@ -// -// SearchResultSection.swift -// Mastodon -// -// Created by sxiaojian on 2021/4/6. -// - -import Foundation -import MastodonSDK -import UIKit -import CoreData -import CoreDataStack - -enum SearchResultSection: Equatable, Hashable { - case main -} - -extension SearchResultSection { - static func tableViewDiffableDataSource( - for tableView: UITableView, - dependency: NeedsDependency, - statusTableViewCellDelegate: StatusTableViewCellDelegate - ) -> UITableViewDiffableDataSource<SearchResultSection, SearchResultItem> { - UITableViewDiffableDataSource(tableView: tableView) { [ - weak statusTableViewCellDelegate - ] tableView, indexPath, item -> UITableViewCell? in - switch item { - case .account(let account): - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchResultTableViewCell.self), for: indexPath) as! SearchResultTableViewCell - cell.config(with: account) - return cell - case .hashtag(let tag): - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchResultTableViewCell.self), for: indexPath) as! SearchResultTableViewCell - cell.config(with: tag) - return cell - case .status(let statusObjectID, let attribute): - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusTableViewCell.self), for: indexPath) as! StatusTableViewCell - if let status = try? dependency.context.managedObjectContext.existingObject(with: statusObjectID) as? Status { - let activeMastodonAuthenticationBox = dependency.context.authenticationService.activeMastodonAuthenticationBox.value - let requestUserID = activeMastodonAuthenticationBox?.userID ?? "" - StatusSection.configure( - cell: cell, - tableView: tableView, - timelineContext: .search, - dependency: dependency, - readableLayoutFrame: tableView.readableContentGuide.layoutFrame, - status: status, - requestUserID: requestUserID, - statusItemAttribute: attribute - ) - } - cell.delegate = statusTableViewCellDelegate - return cell - case .bottomLoader(let attribute): - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) as! TimelineBottomLoaderTableViewCell - if attribute.isNoResult { - cell.stopAnimating() - cell.loadMoreLabel.text = L10n.Scene.Search.Searching.EmptyState.noResults - cell.loadMoreLabel.textColor = Asset.Colors.Label.secondary.color - cell.loadMoreLabel.isHidden = false - } else { - cell.startAnimating() - cell.loadMoreLabel.isHidden = true - } - return cell - } // end switch - } // end UITableViewDiffableDataSource - } // end func -} diff --git a/Mastodon/Diffiable/Section/Status/NotificationSection.swift b/Mastodon/Diffiable/Section/Status/NotificationSection.swift deleted file mode 100644 index 864785007..000000000 --- a/Mastodon/Diffiable/Section/Status/NotificationSection.swift +++ /dev/null @@ -1,246 +0,0 @@ -// -// NotificationSection.swift -// Mastodon -// -// Created by sxiaojian on 2021/4/13. -// - -import Combine -import CoreData -import CoreDataStack -import Foundation -import MastodonSDK -import UIKit -import MetaTextKit -import MastodonMeta - -enum NotificationSection: Equatable, Hashable { - case main -} - -extension NotificationSection { - static func tableViewDiffableDataSource( - for tableView: UITableView, - dependency: NeedsDependency, - managedObjectContext: NSManagedObjectContext, - delegate: NotificationTableViewCellDelegate, - statusTableViewCellDelegate: StatusTableViewCellDelegate - ) -> UITableViewDiffableDataSource<NotificationSection, NotificationItem> { - UITableViewDiffableDataSource(tableView: tableView) { - [weak delegate, weak dependency] - (tableView, indexPath, notificationItem) -> UITableViewCell? in - guard let dependency = dependency else { return nil } - switch notificationItem { - case .notification(let objectID, let attribute): - guard let notification = try? managedObjectContext.existingObject(with: objectID) as? MastodonNotification, - !notification.isDeleted - else { return UITableViewCell() } - - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: NotificationStatusTableViewCell.self), for: indexPath) as! NotificationStatusTableViewCell - configure( - tableView: tableView, - cell: cell, - notification: notification, - dependency: dependency, - attribute: attribute - ) - cell.delegate = delegate - cell.isAccessibilityElement = true - NotificationSection.configureStatusAccessibilityLabel(cell: cell) - return cell - - 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 - - // 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: - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) as! TimelineBottomLoaderTableViewCell - cell.startAnimating() - return cell - } - } - } -} - -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 deleted file mode 100644 index 682a2abc0..000000000 --- a/Mastodon/Diffiable/Section/Status/PollSection.swift +++ /dev/null @@ -1,120 +0,0 @@ -// -// PollSection.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-3-2. -// - -import UIKit -import CoreData -import CoreDataStack - -import MastodonSDK - -extension Mastodon.Entity.Attachment: Hashable { - public static func == (lhs: Mastodon.Entity.Attachment, rhs: Mastodon.Entity.Attachment) -> Bool { - return lhs.id == rhs.id - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(id) - } -} - -enum PollSection: Equatable, Hashable { - case main -} - -extension PollSection { - static func tableViewDiffableDataSource( - for tableView: UITableView, - managedObjectContext: NSManagedObjectContext - ) -> UITableViewDiffableDataSource<PollSection, PollItem> { - return UITableViewDiffableDataSource<PollSection, PollItem>(tableView: tableView) { tableView, indexPath, item -> UITableViewCell? in - switch item { - case .option(let objectID, let attribute): - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PollOptionTableViewCell.self), for: indexPath) as! PollOptionTableViewCell - 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 - } - } - } -} - -extension PollSection { - static func configure( - cell: PollOptionTableViewCell, - pollOption option: PollOption, - pollItemAttribute attribute: PollItem.Attribute - ) { - cell.pollOptionView.optionTextField.text = option.title - configure(cell: cell, selectState: attribute.selectState) - configure(cell: cell, voteState: attribute.voteState) - cell.attribute = attribute - cell.layoutIfNeeded() - cell.updateTextAppearance() - } -} - -extension PollSection { - - static func configure(cell: PollOptionTableViewCell, selectState state: PollItem.Attribute.SelectState) { - switch state { - case .none: - cell.pollOptionView.checkmarkBackgroundView.isHidden = true - cell.pollOptionView.checkmarkImageView.isHidden = true - case .off: - ThemeService.shared.currentTheme - .receive(on: DispatchQueue.main) - .sink { [weak cell] theme in - guard let cell = cell else { return } - cell.pollOptionView.checkmarkBackgroundView.backgroundColor = theme.tertiarySystemBackgroundColor - cell.pollOptionView.checkmarkBackgroundView.layer.borderColor = theme.tableViewCellSelectionBackgroundColor.withAlphaComponent(0.3).cgColor - } - .store(in: &cell.disposeBag) - cell.pollOptionView.checkmarkBackgroundView.layer.borderWidth = 1 - cell.pollOptionView.checkmarkBackgroundView.isHidden = false - cell.pollOptionView.checkmarkImageView.isHidden = true - case .on: - ThemeService.shared.currentTheme - .receive(on: DispatchQueue.main) - .sink { [weak cell] theme in - guard let cell = cell else { return } - cell.pollOptionView.checkmarkBackgroundView.backgroundColor = theme.tertiarySystemBackgroundColor - } - .store(in: &cell.disposeBag) - cell.pollOptionView.checkmarkBackgroundView.layer.borderColor = UIColor.clear.cgColor - cell.pollOptionView.checkmarkBackgroundView.layer.borderWidth = 0 - cell.pollOptionView.checkmarkBackgroundView.isHidden = false - cell.pollOptionView.checkmarkImageView.isHidden = false - } - } - - static func configure(cell: PollOptionTableViewCell, voteState state: PollItem.Attribute.VoteState) { - switch state { - case .hidden: - cell.pollOptionView.optionPercentageLabel.isHidden = true - cell.pollOptionView.voteProgressStripView.isHidden = true - cell.pollOptionView.voteProgressStripView.setProgress(0.0, animated: false) - case .reveal(let voted, let percentage, let animated): - cell.pollOptionView.optionPercentageLabel.isHidden = false - cell.pollOptionView.optionPercentageLabel.text = String(Int(100 * percentage)) + "%" - cell.pollOptionView.voteProgressStripView.isHidden = false - cell.pollOptionView.voteProgressStripView.tintColor = voted ? Asset.Colors.brandBlue.color : Asset.Colors.Poll.disabled.color - cell.pollOptionView.voteProgressStripView.setProgress(CGFloat(percentage), animated: animated) - } - } - -} diff --git a/Mastodon/Diffiable/Section/Status/ReportSection.swift b/Mastodon/Diffiable/Section/Status/ReportSection.swift deleted file mode 100644 index 5da10c399..000000000 --- a/Mastodon/Diffiable/Section/Status/ReportSection.swift +++ /dev/null @@ -1,67 +0,0 @@ -// -// ReportSection.swift -// Mastodon -// -// Created by ihugo on 2021/4/20. -// - -import Combine -import CoreData -import CoreDataStack -import Foundation -import MastodonSDK -import UIKit -import AVKit -import os.log - -enum ReportSection: Equatable, Hashable { - case main -} - -extension ReportSection { - static func tableViewDiffableDataSource( - for tableView: UITableView, - dependency: ReportViewController, - managedObjectContext: NSManagedObjectContext, - timestampUpdatePublisher: AnyPublisher<Date, Never> - ) -> UITableViewDiffableDataSource<ReportSection, Item> { - UITableViewDiffableDataSource(tableView: tableView) {[ - weak dependency - ] tableView, indexPath, item -> UITableViewCell? in - guard let dependency = dependency else { return UITableViewCell() } - - switch item { - case .reportStatus(let objectID, let attribute): - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ReportedStatusTableViewCell.self), for: indexPath) as! ReportedStatusTableViewCell - cell.dependency = dependency - let activeMastodonAuthenticationBox = dependency.context.authenticationService.activeMastodonAuthenticationBox.value - let requestUserID = activeMastodonAuthenticationBox?.userID ?? "" - managedObjectContext.performAndWait { [weak dependency] in - guard let dependency = dependency else { return } - let status = managedObjectContext.object(with: objectID) as! Status - StatusSection.configure( - cell: cell, - tableView: tableView, - timelineContext: .report, - dependency: dependency, - readableLayoutFrame: tableView.readableContentGuide.layoutFrame, - status: status, - requestUserID: requestUserID, - statusItemAttribute: attribute - ) - } - - // defalut to select the report status - if attribute.isSelected { - tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none) - } else { - tableView.deselectRow(at: indexPath, animated: false) - } - - return cell - default: - return nil - } - } - } -} diff --git a/Mastodon/Diffiable/Section/Status/StatusSection.swift b/Mastodon/Diffiable/Section/Status/StatusSection.swift deleted file mode 100644 index 61217c790..000000000 --- a/Mastodon/Diffiable/Section/Status/StatusSection.swift +++ /dev/null @@ -1,1239 +0,0 @@ -// -// TimelineSection.swift -// Mastodon -// -// Created by sxiaojian on 2021/1/27. -// - -import Combine -import CoreData -import CoreDataStack -import os.log -import UIKit -import AVKit -import AlamofireImage -import MastodonMeta -import MastodonSDK -import NaturalLanguage - -// import LinkPresentation - -#if ASDK -import AsyncDisplayKit -#endif - -protocol StatusCell: DisposeBagCollectable { - var statusView: StatusView { get } - var isFiltered: Bool { get set } -} - -enum StatusSection: Equatable, Hashable { - case main -} - -extension StatusSection { - #if ASDK - static func tableNodeDiffableDataSource( - tableNode: ASTableNode, - managedObjectContext: NSManagedObjectContext - ) -> TableNodeDiffableDataSource<StatusSection, Item> { - TableNodeDiffableDataSource(tableNode: tableNode) { tableNode, indexPath, item in - switch item { - case .homeTimelineIndex(let objectID, let attribute): - guard let homeTimelineIndex = try? managedObjectContext.existingObject(with: objectID) as? HomeTimelineIndex else { - return { ASCellNode() } - } - let status = homeTimelineIndex.status - - return { () -> ASCellNode in - let cellNode = StatusNode(status: status) - return cellNode - } - case .homeMiddleLoader: - return { TimelineMiddleLoaderNode() } - case .bottomLoader: - return { TimelineBottomLoaderNode() } - default: - return { ASCellNode() } - } - } - } - #endif - - static let logger = Logger(subsystem: "StatusSection", category: "logic") - - static func tableViewDiffableDataSource( - for tableView: UITableView, - timelineContext: TimelineContext, - dependency: NeedsDependency, - managedObjectContext: NSManagedObjectContext, - statusTableViewCellDelegate: StatusTableViewCellDelegate, - timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate?, - threadReplyLoaderTableViewCellDelegate: ThreadReplyLoaderTableViewCellDelegate? - ) -> UITableViewDiffableDataSource<StatusSection, Item> { - UITableViewDiffableDataSource(tableView: tableView) { [ - weak dependency, - weak statusTableViewCellDelegate, - weak timelineMiddleLoaderTableViewCellDelegate, - weak threadReplyLoaderTableViewCellDelegate - ] tableView, indexPath, item -> UITableViewCell? in - guard let dependency = dependency else { return UITableViewCell() } - guard let statusTableViewCellDelegate = statusTableViewCellDelegate else { return UITableViewCell() } - - switch item { - case .homeTimelineIndex(objectID: let objectID, let attribute): - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusTableViewCell.self), for: indexPath) as! StatusTableViewCell - let timelineIndex = managedObjectContext.object(with: objectID) as? HomeTimelineIndex - - // note: force check optional for status - // status maybe <uninitialized> here when delete in thread scene - guard let status = timelineIndex?.status, - let userID = timelineIndex?.userID else { - return cell - } - - // configure cell - configureStatusTableViewCell( - cell: cell, - tableView: tableView, - timelineContext: timelineContext, - dependency: dependency, - readableLayoutFrame: tableView.readableContentGuide.layoutFrame, - status: status, - requestUserID: userID, - statusItemAttribute: attribute - ) - cell.delegate = statusTableViewCellDelegate - cell.isAccessibilityElement = true - StatusSection.configureStatusAccessibilityLabel(cell: cell) - return cell - case .status(let objectID, let attribute), - .root(let objectID, let attribute), - .reply(let objectID, let attribute), - .leaf(let objectID, let attribute): - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusTableViewCell.self), for: indexPath) as! StatusTableViewCell - let activeMastodonAuthenticationBox = dependency.context.authenticationService.activeMastodonAuthenticationBox.value - let requestUserID = activeMastodonAuthenticationBox?.userID ?? "" - // configure cell - managedObjectContext.performAndWait { - let status = managedObjectContext.object(with: objectID) as! Status - StatusSection.configure( - cell: cell, - tableView: tableView, - timelineContext: timelineContext, - dependency: dependency, - readableLayoutFrame: tableView.readableContentGuide.layoutFrame, - status: status, - requestUserID: requestUserID, - statusItemAttribute: attribute - ) - - switch item { - case .root: - // allow select content - cell.statusView.contentMetaText.textView.isSelectable = true - // configure thread meta - StatusSection.configureThreadMeta(cell: cell, status: status) - ManagedObjectObserver.observe(object: status.reblog ?? status) - .receive(on: RunLoop.main) - .sink { _ in - // do nothing - } receiveValue: { change in - guard case .update(let object) = change.changeType, - let status = object as? Status else { return } - StatusSection.configureThreadMeta(cell: cell, status: status) - } - .store(in: &cell.disposeBag) - default: - break - } - } - cell.delegate = statusTableViewCellDelegate - switch item { - case .root: - // enable selection only for root - cell.statusView.contentMetaText.textView.isSelectable = true - cell.statusView.contentMetaText.textView.isAccessibilityElement = false - var accessibilityElements: [Any] = [] - 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) - accessibilityElements.append(cell.statusView.playerContainerView) - accessibilityElements.append(cell.statusView.actionToolbarContainer) - accessibilityElements.append(cell.threadMetaView) - cell.accessibilityElements = accessibilityElements - default: - cell.isAccessibilityElement = true - StatusSection.configureStatusAccessibilityLabel(cell: cell) - } - return cell - case .leafBottomLoader: - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ThreadReplyLoaderTableViewCell.self), for: indexPath) as! ThreadReplyLoaderTableViewCell - cell.delegate = threadReplyLoaderTableViewCellDelegate - return cell - case .publicMiddleLoader(let upperTimelineStatusID): - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineMiddleLoaderTableViewCell.self), for: indexPath) as! TimelineMiddleLoaderTableViewCell - cell.delegate = timelineMiddleLoaderTableViewCellDelegate - timelineMiddleLoaderTableViewCellDelegate?.configure(cell: cell, upperTimelineStatusID: upperTimelineStatusID, timelineIndexobjectID: nil) - return cell - case .homeMiddleLoader(let upperTimelineIndexObjectID): - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineMiddleLoaderTableViewCell.self), for: indexPath) as! TimelineMiddleLoaderTableViewCell - cell.delegate = timelineMiddleLoaderTableViewCellDelegate - timelineMiddleLoaderTableViewCellDelegate?.configure(cell: cell, upperTimelineStatusID: nil, timelineIndexobjectID: upperTimelineIndexObjectID) - return cell - case .topLoader: - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell - cell.startAnimating() - return cell - case .bottomLoader: - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell - cell.startAnimating() - return cell - case .emptyBottomLoader: - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell - cell.stopAnimating() - cell.loadMoreLabel.text = " " - cell.loadMoreLabel.isHidden = false - return cell - case .emptyStateHeader(let attribute): - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineHeaderTableViewCell.self), for: indexPath) as! TimelineHeaderTableViewCell - StatusSection.configureEmptyStateHeader(cell: cell, attribute: attribute) - return cell - case .reportStatus: - return UITableViewCell() - } - } - } -} - -extension StatusSection { - - enum TimelineContext { - case home - case notifications - case `public` - case thread - case account - - case favorite - case hashtag - case report - case search - - var filterContext: Mastodon.Entity.Filter.Context? { - switch self { - case .home: return .home - case .notifications: return .notifications - case .public: return .public - case .thread: return .thread - case .account: return .account - default: return nil - } - } - } - - private static func needsFilterStatus( - content: MastodonMetaContent?, - filters: [Mastodon.Entity.Filter], - timelineContext: TimelineContext - ) -> AnyPublisher<Bool, Never> { - guard let content = content, - let currentFilterContext = timelineContext.filterContext, - !filters.isEmpty else { - return Just(false).eraseToAnyPublisher() - } - - return Future<Bool, Never> { promise in - DispatchQueue.global(qos: .userInteractive).async { - var wordFilters: [Mastodon.Entity.Filter] = [] - var nonWordFilters: [Mastodon.Entity.Filter] = [] - for filter in filters { - guard filter.context.contains(where: { $0 == currentFilterContext }) else { continue } - if filter.wholeWord { - wordFilters.append(filter) - } else { - nonWordFilters.append(filter) - } - } - - let text = content.original.lowercased() - - var needsFilter = false - for filter in nonWordFilters { - guard text.contains(filter.phrase.lowercased()) else { continue } - needsFilter = true - break - } - - if needsFilter { - DispatchQueue.main.async { - promise(.success(true)) - } - return - } - - let tokenizer = NLTokenizer(unit: .word) - tokenizer.string = text - let phraseWords = wordFilters.map { $0.phrase.lowercased() } - tokenizer.enumerateTokens(in: text.startIndex..<text.endIndex) { range, _ in - let word = String(text[range]) - if phraseWords.contains(word) { - needsFilter = true - return false - } else { - return true - } - } - - DispatchQueue.main.async { - promise(.success(needsFilter)) - } - } - } - .eraseToAnyPublisher() - } - -} - -extension StatusSection { - - static func configureStatusTableViewCell( - cell: StatusTableViewCell, - tableView: UITableView, - timelineContext: TimelineContext, - dependency: NeedsDependency, - readableLayoutFrame: CGRect?, - status: Status, - requestUserID: String, - statusItemAttribute: Item.StatusAttribute - ) { - configure( - cell: cell, - tableView: tableView, - timelineContext: timelineContext, - dependency: dependency, - readableLayoutFrame: readableLayoutFrame, - status: status, - requestUserID: requestUserID, - statusItemAttribute: statusItemAttribute - ) - } - - static func configure( - cell: StatusCell, - tableView: UITableView, - timelineContext: TimelineContext, - dependency: NeedsDependency, - readableLayoutFrame: CGRect?, - status: Status, - requestUserID: String, - statusItemAttribute: Item.StatusAttribute - ) { - // safely cancel the listener when deleted - ManagedObjectObserver.observe(object: status.reblog ?? status) - .receive(on: RunLoop.main) - .sink { _ in - // do nothing - } receiveValue: { [weak cell] change in - guard let cell = cell else { return } - guard let changeType = change.changeType else { return } - if case .delete = changeType { - cell.disposeBag.removeAll() - } - } - .store(in: &cell.disposeBag) - - let content: MastodonMetaContent? = { - if let operation = dependency.context.statusPrefetchingService.statusContentOperations.removeValue(forKey: status.objectID), - let result = operation.result { - switch result { - case .success(let content): return content - case .failure: return nil - } - } else { - let document = MastodonContent( - content: (status.reblog ?? status).content, - emojis: (status.reblog ?? status).emojiMeta - ) - return try? MastodonMetaContent.convert(document: document) - } - }() - - if status.author.id == requestUserID || status.reblog?.author.id == requestUserID { - // do not filter myself - } else { - let needsFilter = StatusSection.needsFilterStatus( - content: content, - filters: AppContext.shared.statusFilterService.activeFilters.value, - timelineContext: timelineContext - ) - needsFilter - .receive(on: DispatchQueue.main) - .sink { [weak cell] needsFilter in - guard let cell = cell else { return } - cell.isFiltered = needsFilter - if needsFilter { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: filter out status: %s", ((#file as NSString).lastPathComponent), #line, #function, content?.original ?? "<nil>") - } - } - .store(in: &cell.disposeBag) - } - - // set header - StatusSection.configureStatusViewHeader(cell: cell, status: status) - // set author: name + username + avatar - StatusSection.configureStatusViewAuthor(cell: cell, status: status) - // set timestamp - let createdAt = (status.reblog ?? status).createdAt - cell.statusView.dateLabel.text = createdAt.localizedSlowedTimeAgoSinceNow - cell.statusView.dateLabel.accessibilityLabel = createdAt.timeAgoSinceNow - AppContext.shared.timestampUpdatePublisher - .receive(on: RunLoop.main) // will be paused when scrolling (on purpose) - .sink { [weak cell] _ in - guard let cell = cell else { return } - cell.statusView.dateLabel.text = createdAt.localizedSlowedTimeAgoSinceNow - cell.statusView.dateLabel.accessibilityLabel = createdAt.localizedSlowedTimeAgoSinceNow - } - .store(in: &cell.disposeBag) - // set content - StatusSection.configureStatusContent( - cell: cell, - status: status, - content: content, - readableLayoutFrame: readableLayoutFrame, - statusItemAttribute: statusItemAttribute - ) - // set content warning - StatusSection.configureContentWarningOverlay( - statusView: cell.statusView, - status: status, - tableView: tableView, - attribute: statusItemAttribute, - documentStore: dependency.context.documentStore, - animated: false - ) - // set poll - StatusSection.configurePoll( - cell: cell, - poll: (status.reblog ?? status).poll, - requestUserID: requestUserID, - updateProgressAnimated: false - ) - if let poll = (status.reblog ?? status).poll { - ManagedObjectObserver.observe(object: poll) - .sink { _ in - // do nothing - } receiveValue: { [weak cell] change in - guard let cell = cell else { return } - guard case .update(let object) = change.changeType, - let newPoll = object as? Poll else { return } - StatusSection.configurePoll( - cell: cell, - poll: newPoll, - requestUserID: requestUserID, - updateProgressAnimated: true - ) - } - .store(in: &cell.disposeBag) - } - // set action toolbar - if let cell = cell as? StatusTableViewCell { - StatusSection.configureActionToolBar( - cell: cell, - dependency: dependency, - status: status, - requestUserID: requestUserID - ) - - // separator line - cell.separatorLine.isHidden = statusItemAttribute.isSeparatorLineHidden - } - - // listen model changed - ManagedObjectObserver.observe(object: status) - .receive(on: RunLoop.main) - .sink { _ in - // do nothing - } receiveValue: { [weak cell] change in - guard let cell = cell else { return } - guard case .update(let object) = change.changeType, - let status = object as? Status, !status.isDeleted else { - return - } - // update header - StatusSection.configureStatusViewHeader(cell: cell, status: status) - } - .store(in: &cell.disposeBag) - ManagedObjectObserver.observe(object: status.reblog ?? status) - .receive(on: RunLoop.main) - .sink { _ in - // do nothing - } 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 - } - // update content warning overlay - StatusSection.configureContentWarningOverlay( - statusView: cell.statusView, - status: status, - tableView: tableView, - attribute: statusItemAttribute, - documentStore: dependency.context.documentStore, - animated: true - ) - // update action toolbar - if let cell = cell as? StatusTableViewCell { - StatusSection.configureActionToolBar( - cell: cell, - dependency: dependency, - status: status, - requestUserID: requestUserID - ) - } - } - .store(in: &cell.disposeBag) - } - - static func configureContentWarningOverlay( - statusView: StatusView, - status: Status, - tableView: UITableView, - attribute: Item.StatusAttribute, - documentStore: DocumentStore, - animated: Bool - ) { - statusView.contentWarningOverlayView.blurContentWarningTitleLabel.text = { - let spoilerText = (status.reblog ?? status).spoilerText ?? "" - if spoilerText.isEmpty { - return L10n.Common.Controls.Status.contentWarning - } else { - return spoilerText - } - }() - let appStartUpTimestamp = documentStore.appStartUpTimestamp - - switch (status.reblog ?? status).sensitiveType { - case .none: - statusView.revealContentWarningButton.isHidden = true - statusView.contentWarningOverlayView.isHidden = true - statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isHidden = true - statusView.updateContentWarningDisplay(isHidden: true, animated: false) - case .all: - statusView.revealContentWarningButton.isHidden = false - statusView.contentWarningOverlayView.isHidden = false - statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isHidden = true - statusView.playerContainerView.contentWarningOverlayView.isHidden = true - - if let revealedAt = status.revealedAt, revealedAt > appStartUpTimestamp { - attribute.isRevealing.value = true - statusView.updateRevealContentWarningButton(isRevealing: true) - statusView.updateContentWarningDisplay(isHidden: true, animated: animated) { [weak tableView] in - guard animated else { return } - DispatchQueue.main.async { - tableView?.beginUpdates() - tableView?.endUpdates() - } - } - } else { - attribute.isRevealing.value = false - statusView.updateRevealContentWarningButton(isRevealing: false) - statusView.updateContentWarningDisplay(isHidden: false, animated: animated) { [weak tableView] in - guard animated else { return } - DispatchQueue.main.async { - tableView?.beginUpdates() - tableView?.endUpdates() - } - } - } - case .media(let isSensitive): - if !isSensitive, documentStore.defaultRevealStatusDict[status.id] == nil { - documentStore.defaultRevealStatusDict[status.id] = true - } - statusView.revealContentWarningButton.isHidden = false - statusView.contentWarningOverlayView.isHidden = true - statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isHidden = false - statusView.playerContainerView.contentWarningOverlayView.isHidden = false - statusView.updateContentWarningDisplay(isHidden: true, animated: false) - - func updateContentOverlay() { - let needsReveal: Bool = { - if documentStore.defaultRevealStatusDict[status.id] == true { - return true - } - if let revealedAt = status.revealedAt, revealedAt > appStartUpTimestamp { - return true - } - - return false - }() - attribute.isRevealing.value = needsReveal - if needsReveal { - statusView.updateRevealContentWarningButton(isRevealing: true) - statusView.statusMosaicImageViewContainer.contentWarningOverlayView.update(isRevealing: true, style: .media) - statusView.playerContainerView.contentWarningOverlayView.update(isRevealing: true, style: .media) - } else { - statusView.updateRevealContentWarningButton(isRevealing: false) - statusView.statusMosaicImageViewContainer.contentWarningOverlayView.update(isRevealing: false, style: .media) - statusView.playerContainerView.contentWarningOverlayView.update(isRevealing: false, style: .media) - } - } - - if animated { - UIView.animate(withDuration: 0.33, delay: 0, options: .curveEaseInOut) { - updateContentOverlay() - } completion: { _ in - // do nothing - } - } else { - updateContentOverlay() - } - } - } - - static func configureThreadMeta( - cell: StatusTableViewCell, - status: Status - ) { - cell.selectionStyle = .none - - // set reblog count - let reblogCountTitle: String = { - let count = status.reblogsCount.intValue - return L10n.Plural.Count.reblog(count) - }() - cell.threadMetaView.reblogButton.setTitle(reblogCountTitle, for: .normal) - // set favorite count - let favoriteCountTitle: String = { - let count = status.favouritesCount.intValue - return L10n.Plural.Count.favorite(count) - }() - cell.threadMetaView.favoriteButton.setTitle(favoriteCountTitle, for: .normal) - // set date - cell.threadMetaView.dateLabel.text = { - let formatter = DateFormatter() - // make adaptive UI - if UIView.isZoomedMode || (reblogCountTitle.count + favoriteCountTitle.count > 20) { - formatter.dateStyle = .short - formatter.timeStyle = .short - } else { - formatter.dateStyle = .medium - formatter.timeStyle = .short - } - return formatter.string(from: status.createdAt) - }() - cell.threadMetaView.dateLabel.accessibilityLabel = DateFormatter.localizedString(from: status.createdAt, dateStyle: .medium, timeStyle: .short) - - cell.threadMetaView.isHidden = false - } - - static func configureStatusViewHeader( - cell: StatusCell, - status: Status - ) { - if status.reblog != nil { - cell.statusView.headerContainerView.isHidden = false - 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 - return L10n.Common.Controls.Status.userReblogged(name) - }() - // sync set display name to avoid layout issue - do { - let mastodonContent = MastodonContent(content: headerText, emojis: status.author.emojiMeta) - let metaContent = try MastodonMetaContent.convert(document: mastodonContent) - cell.statusView.headerInfoLabel.configure(content: metaContent) - } catch { - cell.statusView.headerInfoLabel.reset() - } - cell.statusView.headerInfoLabel.accessibilityLabel = headerText - cell.statusView.headerInfoLabel.isAccessibilityElement = true - } else if status.inReplyToID != nil { - cell.statusView.headerContainerView.isHidden = false - 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("-") - } - let author = replyTo.author - let name = author.displayName.isEmpty ? author.username : author.displayName - return L10n.Common.Controls.Status.userRepliedTo(name) - }() - do { - let mastodonContent = MastodonContent(content: headerText, emojis: status.replyTo?.author.emojiMeta ?? [:]) - let metaContent = try MastodonMetaContent.convert(document: mastodonContent) - cell.statusView.headerInfoLabel.configure(content: metaContent) - } catch { - cell.statusView.headerInfoLabel.reset() - } - cell.statusView.headerInfoLabel.accessibilityLabel = headerText - cell.statusView.headerInfoLabel.isAccessibilityElement = status.replyTo != nil - } else { - cell.statusView.headerContainerView.isHidden = true - cell.statusView.headerInfoLabel.isAccessibilityElement = false - } - } - - static func configureStatusViewAuthor( - cell: StatusCell, - status: Status - ) { - // name - let author = (status.reblog ?? status).author - let nameContent = author.displayNameWithFallback - do { - let mastodonContent = MastodonContent(content: nameContent, emojis: author.emojiMeta) - let metaContent = try MastodonMetaContent.convert(document: mastodonContent) - cell.statusView.nameMetaLabel.configure(content: metaContent) - cell.statusView.nameMetaLabel.accessibilityLabel = metaContent.trimmed - } catch { - cell.statusView.nameMetaLabel.reset() - cell.statusView.nameMetaLabel.accessibilityLabel = "" - } - // username - cell.statusView.usernameLabel.text = "@" + author.acct - // avatar - if let reblog = status.reblog { - cell.statusView.avatarButton.isHidden = true - cell.statusView.avatarStackedContainerButton.isHidden = false - cell.statusView.avatarStackedContainerButton.topLeadingAvatarStackedImageView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: reblog.author.avatarImageURL())) - cell.statusView.avatarStackedContainerButton.bottomTrailingAvatarStackedImageView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: status.author.avatarImageURL())) - } else { - cell.statusView.avatarButton.isHidden = false - cell.statusView.avatarStackedContainerButton.isHidden = true - cell.statusView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: status.author.avatarImageURL())) - } - } - - static func configureStatusContent( - cell: StatusCell, - status: Status, - content: MastodonMetaContent?, - readableLayoutFrame: CGRect?, - 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 - } else { - cell.statusView.contentMetaText.textView.text = " " - cell.statusView.contentMetaText.textView.accessibilityLabel = "" - assertionFailure() - } - - cell.statusView.contentMetaText.textView.accessibilityTraits = [.staticText] - cell.statusView.contentMetaText.textView.accessibilityElementsHidden = false - cell.statusView.contentMetaText.textView.accessibilityLanguage = (status.reblog ?? status).language - - // set visibility - if let visibility = (status.reblog ?? status).visibilityEnum { - cell.statusView.updateVisibility(visibility: visibility) - cell.statusView.revealContentWarningButton.publisher(for: \.isHidden) - .receive(on: DispatchQueue.main) - .sink { [weak cell] isHidden in - cell?.statusView.visibilityImageView.isHidden = !isHidden - } - .store(in: &cell.disposeBag) - } else { - cell.statusView.visibilityImageView.isHidden = true - } - - // prepare media attachments - let mediaAttachments = Array((status.reblog ?? status).mediaAttachments ?? []).sorted { $0.index.compare($1.index) == .orderedAscending } - - // set image - let mosaicImageViewModel = MosaicImageViewModel(mediaAttachments: mediaAttachments) - let imageViewMaxSize: CGSize = { - let maxWidth: CGFloat = { - // use timelinePostView width as container width - // that width follows readable width and keep constant width after rotate - let containerFrame = readableLayoutFrame ?? cell.statusView.frame - var containerWidth = containerFrame.width - containerWidth -= 10 - containerWidth -= StatusView.avatarImageSize.width - return containerWidth - }() - let scale: CGFloat = { - switch mosaicImageViewModel.metas.count { - case 1: return 1.3 - default: return 0.7 - } - }() - return CGSize(width: maxWidth, height: floor(maxWidth * scale)) - }() - let mosaics: [MosaicImageViewContainer.ConfigurableMosaic] = { - if mosaicImageViewModel.metas.count == 1 { - let meta = mosaicImageViewModel.metas[0] - let mosaic = cell.statusView.statusMosaicImageViewContainer.setupImageView(aspectRatio: meta.size, maxSize: imageViewMaxSize) - return [mosaic] - } else { - let mosaics = cell.statusView.statusMosaicImageViewContainer.setupImageViews(count: mosaicImageViewModel.metas.count, maxSize: imageViewMaxSize) - return mosaics - } - }() - for (i, mosaic) in mosaics.enumerated() { - let imageView = mosaic.imageView - let blurhashOverlayImageView = mosaic.blurhashOverlayImageView - let meta = mosaicImageViewModel.metas[i] - - // set blurhash image - meta.blurhashImagePublisher() - .sink { image in - blurhashOverlayImageView.image = image - } - .store(in: &cell.disposeBag) - - // set image - let url: URL = { - if UIDevice.current.userInterfaceIdiom == .phone { - return meta.previewURL ?? meta.url - } - return meta.url - }() - - // let imageSize = CGSize( - // width: mosaic.imageViewSize.width * imageView.traitCollection.displayScale, - // height: mosaic.imageViewSize.height * imageView.traitCollection.displayScale - // ) - // let imageFilter = AspectScaledToFillSizeFilter(size: imageSize) - - imageView.af.setImage( - withURL: url, - placeholderImage: UIImage.placeholder(color: .systemFill), - imageTransition: .crossDissolve(0.2) - ) { response in - switch response.result { - case .success: - statusItemAttribute.isImageLoaded.value = true - case .failure: - break - } - } - - imageView.accessibilityLabel = meta.altText - - // setup media content overlay trigger - Publishers.CombineLatest( - statusItemAttribute.isImageLoaded, - statusItemAttribute.isRevealing - ) - .receive(on: DispatchQueue.main) // needs call immediately - .sink { [weak cell] isImageLoaded, isMediaRevealing in - guard let _ = cell else { return } - guard isImageLoaded else { - // always display blurhash image when before image loaded - blurhashOverlayImageView.alpha = 1 - blurhashOverlayImageView.isHidden = false - return - } - - // display blurhash image depends on revealing state - let animator = UIViewPropertyAnimator(duration: 0.33, curve: .easeInOut) - animator.addAnimations { - blurhashOverlayImageView.alpha = isMediaRevealing ? 0 : 1 - } - animator.startAnimation() - } - .store(in: &cell.disposeBag) - } - cell.statusView.statusMosaicImageViewContainer.isHidden = mosaicImageViewModel.metas.isEmpty - - // set audio - if let audioAttachment = mediaAttachments.filter({ $0.type == .audio }).first { - cell.statusView.audioView.isHidden = false - AudioContainerViewModel.configure(cell: cell, audioAttachment: audioAttachment, audioService: AppContext.shared.audioPlaybackService) - } else { - cell.statusView.audioView.isHidden = true - } - - // set GIF & video - let playerViewMaxSize: CGSize = { - let maxWidth: CGFloat = { - // use statusView width as container width - // that width follows readable width and keep constant width after rotate - let containerFrame = readableLayoutFrame ?? cell.statusView.frame - return containerFrame.width - }() - let scale: CGFloat = 1.3 - return CGSize(width: maxWidth, height: floor(maxWidth * scale)) - }() - - if let videoAttachment = mediaAttachments.filter({ $0.type == .gifv || $0.type == .video }).first, - let videoPlayerViewModel = AppContext.shared.videoPlaybackService.dequeueVideoPlayerViewModel(for: videoAttachment) { - var parent: UIViewController? - var playerViewControllerDelegate: AVPlayerViewControllerDelegate? = nil - switch cell { - case is StatusTableViewCell: - let statusTableViewCell = cell as! StatusTableViewCell - parent = statusTableViewCell.delegate?.parent() - playerViewControllerDelegate = statusTableViewCell.delegate?.playerViewControllerDelegate - case is NotificationStatusTableViewCell: - let notificationTableViewCell = cell as! NotificationStatusTableViewCell - parent = notificationTableViewCell.delegate?.parent() - case is ReportedStatusTableViewCell: - let reportTableViewCell = cell as! ReportedStatusTableViewCell - parent = reportTableViewCell.dependency - default: - parent = nil - assertionFailure("unknown cell") - } - let playerContainerView = cell.statusView.playerContainerView - let playerViewController = playerContainerView.setupPlayer( - aspectRatio: videoPlayerViewModel.videoSize, - maxSize: playerViewMaxSize, - parent: parent - ) - playerViewController.delegate = playerViewControllerDelegate - playerViewController.player = videoPlayerViewModel.player - playerViewController.showsPlaybackControls = videoPlayerViewModel.videoKind != .gif - playerContainerView.setMediaKind(kind: videoPlayerViewModel.videoKind) - switch videoPlayerViewModel.videoKind { - case .gif: - playerContainerView.setMediaIndicator(isHidden: false) - case .video: - playerContainerView.setMediaIndicator(isHidden: true) - } - playerContainerView.isHidden = false - - // set blurhash overlay - playerContainerView.isReadyForDisplay - .receive(on: DispatchQueue.main) - .sink { [weak playerContainerView] isReadyForDisplay in - guard let playerContainerView = playerContainerView else { return } - playerContainerView.blurhashOverlayImageView.alpha = isReadyForDisplay ? 0 : 1 - } - .store(in: &cell.disposeBag) - - if let blurhash = videoAttachment.blurhash, - let url = URL(string: videoAttachment.url) { - AppContext.shared.blurhashImageCacheService.image( - blurhash: blurhash, - size: playerContainerView.playerViewController.view.frame.size, - url: url - ) - .sink { image in - playerContainerView.blurhashOverlayImageView.image = image - } - .store(in: &cell.disposeBag) - } - - } else { - cell.statusView.playerContainerView.playerViewController.player?.pause() - cell.statusView.playerContainerView.playerViewController.player = nil - } - } - - static func configurePoll( - cell: StatusCell, - poll: Poll?, - requestUserID: String, - updateProgressAnimated: Bool - ) { - guard let poll = poll, - let managedObjectContext = poll.managedObjectContext - else { - cell.statusView.pollTableView.isHidden = true - cell.statusView.pollStatusStackView.isHidden = true - cell.statusView.pollVoteButton.isHidden = true - return - } - - cell.statusView.pollTableView.isHidden = false - cell.statusView.pollStatusStackView.isHidden = false - cell.statusView.pollVoteCountLabel.text = { - if poll.multiple { - let count = poll.votersCount?.intValue ?? 0 - return L10n.Plural.Count.voter(count) - } else { - let count = poll.votesCount.intValue - return L10n.Plural.Count.vote(count) - } - }() - if poll.expired { - cell.statusView.pollCountdownSubscription = nil - cell.statusView.pollCountdownLabel.text = L10n.Common.Controls.Status.Poll.closed - } else if let expiresAt = poll.expiresAt { - cell.statusView.pollCountdownLabel.text = expiresAt.localizedTimeLeft() - cell.statusView.pollCountdownSubscription = AppContext.shared.timestampUpdatePublisher - .sink { _ in cell.statusView.pollCountdownLabel.text = expiresAt.localizedTimeLeft() } - } else { - cell.statusView.pollCountdownSubscription = nil - 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 - (option.votedBy ?? Set()).map(\.id).contains(requestUserID) - } - let didVotedLocal = !votedOptions.isEmpty - let didVotedRemote = (poll.votedBy ?? Set()).map(\.id).contains(requestUserID) - cell.statusView.pollVoteButton.isEnabled = didVotedLocal - cell.statusView.pollVoteButton.isHidden = !poll.multiple ? true : (didVotedRemote || poll.expired) - - cell.statusView.pollTableViewDataSource = PollSection.tableViewDiffableDataSource( - for: cell.statusView.pollTableView, - managedObjectContext: managedObjectContext - ) - - var snapshot = NSDiffableDataSourceSnapshot<PollSection, PollItem>() - snapshot.appendSections([.main]) - - let pollItems = poll.options - .sorted(by: { $0.index.intValue < $1.index.intValue }) - .map { option -> PollItem in - let attribute: PollItem.Attribute = { - let selectState: PollItem.Attribute.SelectState = { - // check didVotedRemote later to make the local change possible - if !votedOptions.isEmpty { - return votedOptions.contains(option) ? .on : .off - } else if poll.expired { - return .none - } else if didVotedRemote, votedOptions.isEmpty { - return .none - } else { - return .off - } - }() - let voteState: PollItem.Attribute.VoteState = { - var needsReveal: Bool - if poll.expired { - needsReveal = true - } else if didVotedRemote { - needsReveal = true - } else { - needsReveal = false - } - guard needsReveal else { return .hidden } - let percentage: Double = { - guard poll.votesCount.intValue > 0 else { return 0.0 } - return Double(option.votesCount?.intValue ?? 0) / Double(poll.votesCount.intValue) - }() - let voted = votedOptions.isEmpty ? true : votedOptions.contains(option) - return .reveal(voted: voted, percentage: percentage, animated: updateProgressAnimated) - }() - return PollItem.Attribute(selectState: selectState, voteState: voteState) - }() - let option = PollItem.option(objectID: option.objectID, attribute: attribute) - return option - } - snapshot.appendItems(pollItems, toSection: .main) - cell.statusView.pollTableViewDataSource?.apply(snapshot, animatingDifferences: false, completion: nil) - cell.statusView.pollTableViewHeightLayoutConstraint.constant = PollOptionTableViewCell.height * CGFloat(poll.options.count) - } - - static func configureActionToolBar( - cell: StatusTableViewCell, - dependency: NeedsDependency, - status: Status, - requestUserID: String - ) { - let status = status.reblog ?? status - - // set reply - let replyCountTitle: String = { - let count = status.repliesCount?.intValue ?? 0 - return StatusSection.formattedNumberTitleForActionButton(count) - }() - cell.statusView.actionToolbarContainer.replyButton.setTitle(replyCountTitle, for: .normal) - cell.statusView.actionToolbarContainer.replyButton.accessibilityValue = status.repliesCount.flatMap { - L10n.Plural.Count.reblog($0.intValue) - } ?? nil - // set reblog - let isReblogged = status.rebloggedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false - let reblogCountTitle: String = { - let count = status.reblogsCount.intValue - return StatusSection.formattedNumberTitleForActionButton(count) - }() - cell.statusView.actionToolbarContainer.reblogButton.setTitle(reblogCountTitle, for: .normal) - cell.statusView.actionToolbarContainer.isReblogButtonHighlight = isReblogged - cell.statusView.actionToolbarContainer.reblogButton.accessibilityLabel = isReblogged ? L10n.Common.Controls.Status.Actions.unreblog : L10n.Common.Controls.Status.Actions.reblog - cell.statusView.actionToolbarContainer.reblogButton.accessibilityValue = { - guard status.reblogsCount.intValue > 0 else { return nil } - return L10n.Plural.Count.reblog(status.reblogsCount.intValue) - }() - - // disable reblog if needs (except self) - cell.statusView.actionToolbarContainer.reblogButton.isEnabled = true - if let visibility = status.visibilityEnum, status.author.id != requestUserID { - switch visibility { - case .public, .unlisted: - break - default: - cell.statusView.actionToolbarContainer.reblogButton.isEnabled = false - } - } - - // set like - let isLike = status.favouritedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false - let favoriteCountTitle: String = { - let count = status.favouritesCount.intValue - return StatusSection.formattedNumberTitleForActionButton(count) - }() - cell.statusView.actionToolbarContainer.favoriteButton.setTitle(favoriteCountTitle, for: .normal) - cell.statusView.actionToolbarContainer.isFavoriteButtonHighlight = isLike - cell.statusView.actionToolbarContainer.favoriteButton.accessibilityLabel = isLike ? L10n.Common.Controls.Status.Actions.unfavorite : L10n.Common.Controls.Status.Actions.favorite - cell.statusView.actionToolbarContainer.favoriteButton.accessibilityValue = { - guard status.favouritesCount.intValue > 0 else { return nil } - return L10n.Plural.Count.favorite(status.favouritesCount.intValue) - }() - Publishers.CombineLatest( - dependency.context.blockDomainService.blockedDomains.setFailureType(to: ManagedObjectObserver.Error.self), - ManagedObjectObserver.observe(object: status.authorForUserProvider) - ) - .receive(on: RunLoop.main) - .sink(receiveCompletion: { _ in - // do nothing - }, receiveValue: { [weak dependency, weak cell] _, change in - guard let cell = cell else { return } - guard let dependency = dependency else { return } - switch change.changeType { - case .delete: - return - case .update(_): - break - case .none: - break - } - StatusSection.setupStatusMoreButtonMenu(cell: cell, dependency: dependency, status: status) - }) - .store(in: &cell.disposeBag) - setupStatusMoreButtonMenu(cell: cell, dependency: dependency, status: status) - } - - static func configureStatusAccessibilityLabel(cell: StatusTableViewCell) { - // FIXME: - cell.accessibilityLabel = { - var accessibilityViews: [UIView?] = [] - 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: " ") - }() - cell.statusView.actionToolbarContainer.isUserInteractionEnabled = !UIAccessibility.isVoiceOverRunning - } - -} - - -extension StatusSection { - static func configureEmptyStateHeader( - cell: TimelineHeaderTableViewCell, - attribute: Item.EmptyStateHeaderAttribute - ) { - cell.timelineHeaderView.iconImageView.image = attribute.reason.iconImage - cell.timelineHeaderView.messageLabel.text = attribute.reason.message - } -} - -extension StatusSection { - private static func formattedNumberTitleForActionButton(_ number: Int?) -> String { - guard let number = number, number > 0 else { return "" } - return String(number) - } - - private static func setupStatusMoreButtonMenu( - cell: StatusTableViewCell, - dependency: NeedsDependency, - status: Status - ) { - - guard let userProvider = dependency as? UserProvider else { fatalError() } - - guard let authenticationBox = dependency.context.authenticationService.activeMastodonAuthenticationBox.value else { - return - } - let author = status.authorForUserProvider - let isMyself = authenticationBox.userID == author.id - let isInSameDomain = authenticationBox.domain == author.domainFromAcct - let isMuting = (author.mutingBy ?? Set()).map(\.id).contains(authenticationBox.userID) - let isBlocking = (author.blockingBy ?? Set()).map(\.id).contains(authenticationBox.userID) - let isDomainBlocking = dependency.context.blockDomainService.blockedDomains.value.contains(author.domainFromAcct) - cell.statusView.actionToolbarContainer.moreButton.showsMenuAsPrimaryAction = true - cell.statusView.actionToolbarContainer.moreButton.menu = UserProviderFacade.createProfileActionMenu( - for: author, - isMyself: isMyself, - isMuting: isMuting, - isBlocking: isBlocking, - isInSameDomain: isInSameDomain, - isDomainBlocking: isDomainBlocking, - provider: userProvider, - cell: cell, - sourceView: cell.statusView.actionToolbarContainer.moreButton, - barButtonItem: nil, - shareUser: nil, - shareStatus: status - ) - } -} - -class StatusContentOperation: Operation { - - let logger = Logger(subsystem: "StatusContentOperation", category: "logic") - - // input - let statusObjectID: NSManagedObjectID - let mastodonContent: MastodonContent - - // output - var result: Result<MastodonMetaContent, Error>? - - init( - statusObjectID: NSManagedObjectID, - mastodonContent: MastodonContent - ) { - self.statusObjectID = statusObjectID - self.mastodonContent = mastodonContent - super.init() - } - - override func main() { - guard !isCancelled else { return } - // logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): prcoess \(self.statusObjectID)…") - - do { - let content = try MastodonMetaContent.convert(document: mastodonContent) - result = .success(content) - // logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): process success \(self.statusObjectID)") - } catch { - result = .failure(error) - // logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): process fail \(self.statusObjectID)") - } - - } - - override func cancel() { - // logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): cancel \(self.statusObjectID.debugDescription)") - super.cancel() - } -} diff --git a/Mastodon/Diffiable/Item/SettingsItem.swift b/Mastodon/Diffiable/Settings/SettingsItem.swift similarity index 73% rename from Mastodon/Diffiable/Item/SettingsItem.swift rename to Mastodon/Diffiable/Settings/SettingsItem.swift index ed472808a..00c88d167 100644 --- a/Mastodon/Diffiable/Item/SettingsItem.swift +++ b/Mastodon/Diffiable/Settings/SettingsItem.swift @@ -7,11 +7,15 @@ import UIKit import CoreData +import CoreDataStack +import MastodonAsset +import MastodonLocalization enum SettingsItem { - case appearance(settingObjectID: NSManagedObjectID) - case notification(settingObjectID: NSManagedObjectID, switchMode: NotificationSwitchMode) - case preference(settingObjectID: NSManagedObjectID, preferenceType: PreferenceType) + case appearance(record: ManagedObjectRecord<Setting>) + case appearancePreference(record: ManagedObjectRecord<Setting>, appearanceType: AppearanceType) + case preference(settingRecord: ManagedObjectRecord<Setting>, preferenceType: PreferenceType) + case notification(settingRecord: ManagedObjectRecord<Setting>, switchMode: NotificationSwitchMode) case boringZone(item: Link) case spicyZone(item: Link) } @@ -19,9 +23,17 @@ enum SettingsItem { extension SettingsItem { enum AppearanceMode: String { - case automatic - case light + case system case dark + case light + } + + enum AppearanceType: Hashable { + case preferredTrueDarkMode + + var title: String { + return L10n.Scene.Settings.Section.Preference.trueBlackDarkMode + } } enum NotificationSwitchMode: CaseIterable, Hashable { @@ -41,14 +53,12 @@ extension SettingsItem { } enum PreferenceType: CaseIterable { - case darkMode case disableAvatarAnimation case disableEmojiAnimation case useDefaultBrowser var title: String { switch self { - case .darkMode: return L10n.Scene.Settings.Section.Preference.trueBlackDarkMode case .disableAvatarAnimation: return L10n.Scene.Settings.Section.Preference.disableAvatarAnimation case .disableEmojiAnimation: return L10n.Scene.Settings.Section.Preference.disableEmojiAnimation case .useDefaultBrowser: return L10n.Scene.Settings.Section.Preference.usingDefaultBrowser @@ -75,12 +85,12 @@ extension SettingsItem { } } - var textColor: UIColor { + var textColor: UIColor? { switch self { - case .accountSettings: return Asset.Colors.brandBlue.color - case .github: return Asset.Colors.brandBlue.color - case .termsOfService: return Asset.Colors.brandBlue.color - case .privacyPolicy: return Asset.Colors.brandBlue.color + case .accountSettings: return nil // tintColor + case .github: return nil + case .termsOfService: return nil + case .privacyPolicy: return nil case .clearMediaCache: return .systemRed case .signOut: return .systemRed } @@ -92,9 +102,13 @@ extension SettingsItem { extension SettingsItem: Hashable { func hash(into hasher: inout Hasher) { switch self { - case .appearance(let settingObjectID): + case .appearance(let record): hasher.combine(String(describing: SettingsItem.AppearanceMode.self)) - hasher.combine(settingObjectID) + hasher.combine(record) + case .appearancePreference(let record, let appearanceType): + hasher.combine(String(describing: SettingsItem.AppearanceType.self)) + hasher.combine(record) + hasher.combine(appearanceType) case .notification(let settingObjectID, let switchMode): hasher.combine(String(describing: SettingsItem.notification.self)) hasher.combine(settingObjectID) diff --git a/Mastodon/Diffiable/Section/SettingsSection.swift b/Mastodon/Diffiable/Settings/SettingsSection.swift similarity index 63% rename from Mastodon/Diffiable/Section/SettingsSection.swift rename to Mastodon/Diffiable/Settings/SettingsSection.swift index f59c13587..adc7140be 100644 --- a/Mastodon/Diffiable/Section/SettingsSection.swift +++ b/Mastodon/Diffiable/Settings/SettingsSection.swift @@ -8,19 +8,23 @@ import UIKit import CoreData import CoreDataStack +import MastodonAsset +import MastodonLocalization enum SettingsSection: Hashable { case appearance - case notifications + case appearancePreference case preference + case notifications case boringZone case spicyZone var title: String { switch self { - case .appearance: return L10n.Scene.Settings.Section.Appearance.title + case .appearance: return L10n.Scene.Settings.Section.LookAndFeel.title + case .appearancePreference: return "" + case .preference: return "" case .notifications: return L10n.Scene.Settings.Section.Notifications.title - case .preference: return L10n.Scene.Settings.Section.Preference.title case .boringZone: return L10n.Scene.Settings.Section.BoringZone.title case .spicyZone: return L10n.Scene.Settings.Section.SpicyZone.title } @@ -39,25 +43,58 @@ extension SettingsSection { weak settingsToggleCellDelegate ] tableView, indexPath, item -> UITableViewCell? in switch item { - case .appearance(let objectID): + case .appearance(let record): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SettingsAppearanceTableViewCell.self), for: indexPath) as! SettingsAppearanceTableViewCell - UserDefaults.shared.observe(\.customUserInterfaceStyle, options: [.initial, .new]) { [weak cell] defaults, _ in - guard let cell = cell else { return } - switch defaults.customUserInterfaceStyle { - case .unspecified: cell.update(with: .automatic) - case .dark: cell.update(with: .dark) - case .light: cell.update(with: .light) - @unknown default: - assertionFailure() - } + managedObjectContext.performAndWait { + guard let setting = record.object(in: managedObjectContext) else { return } + cell.configure(setting: setting) } - .store(in: &cell.observations) cell.delegate = settingsAppearanceTableViewCellDelegate return cell - case .notification(let objectID, let switchMode): + case .appearancePreference(let record, let appearanceType): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SettingsToggleTableViewCell.self), for: indexPath) as! SettingsToggleTableViewCell + cell.delegate = settingsToggleCellDelegate + managedObjectContext.performAndWait { + guard let setting = record.object(in: managedObjectContext) else { return } + SettingsSection.configureSettingToggle(cell: cell, item: item, setting: setting) + + ManagedObjectObserver.observe(object: setting) + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in + // do nothing + }, receiveValue: { [weak cell] change in + guard let cell = cell else { return } + guard case .update(let object) = change.changeType, + let setting = object as? Setting else { return } + SettingsSection.configureSettingToggle(cell: cell, item: item, setting: setting) + }) + .store(in: &cell.disposeBag) + } + return cell + case .preference(let record, _): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SettingsToggleTableViewCell.self), for: indexPath) as! SettingsToggleTableViewCell + cell.delegate = settingsToggleCellDelegate + managedObjectContext.performAndWait { + guard let setting = record.object(in: managedObjectContext) else { return } + SettingsSection.configureSettingToggle(cell: cell, item: item, setting: setting) + + ManagedObjectObserver.observe(object: setting) + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in + // do nothing + }, receiveValue: { [weak cell] change in + guard let cell = cell else { return } + guard case .update(let object) = change.changeType, + let setting = object as? Setting else { return } + SettingsSection.configureSettingToggle(cell: cell, item: item, setting: setting) + }) + .store(in: &cell.disposeBag) + } + return cell + case .notification(let record, let switchMode): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SettingsToggleTableViewCell.self), for: indexPath) as! SettingsToggleTableViewCell managedObjectContext.performAndWait { - let setting = managedObjectContext.object(with: objectID) as! Setting + guard let setting = record.object(in: managedObjectContext) else { return } if let subscription = setting.activeSubscription { SettingsSection.configureSettingToggle(cell: cell, switchMode: switchMode, subscription: subscription) } @@ -75,32 +112,12 @@ extension SettingsSection { } cell.delegate = settingsToggleCellDelegate return cell - case .preference(let objectID, _): - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SettingsToggleTableViewCell.self), for: indexPath) as! SettingsToggleTableViewCell - cell.delegate = settingsToggleCellDelegate - managedObjectContext.performAndWait { - let setting = managedObjectContext.object(with: objectID) as! Setting - SettingsSection.configureSettingToggle(cell: cell, item: item, setting: setting) - - ManagedObjectObserver.observe(object: setting) - .receive(on: DispatchQueue.main) - .sink(receiveCompletion: { _ in - // do nothing - }, receiveValue: { [weak cell] change in - guard let cell = cell else { return } - guard case .update(let object) = change.changeType, - let setting = object as? Setting else { return } - SettingsSection.configureSettingToggle(cell: cell, item: item, setting: setting) - }) - .store(in: &cell.disposeBag) - } - return cell case .boringZone(let item), .spicyZone(let item): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SettingsLinkTableViewCell.self), for: indexPath) as! SettingsLinkTableViewCell cell.update(with: item) return cell - } + } // end switch } } } @@ -112,19 +129,29 @@ extension SettingsSection { item: SettingsItem, setting: Setting ) { - guard case let .preference(_, preferenceType) = item else { return } - - cell.textLabel?.text = preferenceType.title - - switch preferenceType { - case .darkMode: - cell.switchButton.isOn = setting.preferredTrueBlackDarkMode - case .disableAvatarAnimation: - cell.switchButton.isOn = setting.preferredStaticAvatar - case .disableEmojiAnimation: - cell.switchButton.isOn = setting.preferredStaticEmoji - case .useDefaultBrowser: - cell.switchButton.isOn = setting.preferredUsingDefaultBrowser + switch item { + case .appearancePreference(_, let appearanceType): + cell.textLabel?.text = appearanceType.title + + switch appearanceType { + case .preferredTrueDarkMode: + cell.switchButton.isOn = setting.preferredTrueBlackDarkMode + } + + case .preference(_, let preferenceType): + cell.textLabel?.text = preferenceType.title + + switch preferenceType { + case .disableAvatarAnimation: + cell.switchButton.isOn = setting.preferredStaticAvatar + case .disableEmojiAnimation: + cell.switchButton.isOn = setting.preferredStaticEmoji + case .useDefaultBrowser: + cell.switchButton.isOn = setting.preferredUsingDefaultBrowser + } + + default: + assertionFailure() } } diff --git a/Mastodon/Diffiable/Status/StatusItem.swift b/Mastodon/Diffiable/Status/StatusItem.swift new file mode 100644 index 000000000..1d08ea41d --- /dev/null +++ b/Mastodon/Diffiable/Status/StatusItem.swift @@ -0,0 +1,66 @@ +// +// StatusItem.swift +// Mastodon +// +// Created by MainasuK on 2022-1-11. +// + +import Foundation +import CoreDataStack +import MastodonUI + +enum StatusItem: Hashable { + case feed(record: ManagedObjectRecord<Feed>) + case feedLoader(record: ManagedObjectRecord<Feed>) + case status(record: ManagedObjectRecord<Status>) + case thread(Thread) + case topLoader + case bottomLoader +} + +extension StatusItem { + enum Thread: Hashable { + case root(context: Context) + case reply(context: Context) + case leaf(context: Context) + + public var record: ManagedObjectRecord<Status> { + switch self { + case .root(let threadContext), + .reply(let threadContext), + .leaf(let threadContext): + return threadContext.status + } + } + } +} + +extension StatusItem.Thread { + class Context: Hashable { + let status: ManagedObjectRecord<Status> + var displayUpperConversationLink: Bool + var displayBottomConversationLink: Bool + + init( + status: ManagedObjectRecord<Status>, + displayUpperConversationLink: Bool = false, + displayBottomConversationLink: Bool = false + ) { + self.status = status + self.displayUpperConversationLink = displayUpperConversationLink + self.displayBottomConversationLink = displayBottomConversationLink + } + + static func == (lhs: StatusItem.Thread.Context, rhs: StatusItem.Thread.Context) -> Bool { + return lhs.status == rhs.status + && lhs.displayUpperConversationLink == rhs.displayUpperConversationLink + && lhs.displayBottomConversationLink == rhs.displayBottomConversationLink + } + + func hash(into hasher: inout Hasher) { + hasher.combine(status) + hasher.combine(displayUpperConversationLink) + hasher.combine(displayBottomConversationLink) + } + } +} diff --git a/Mastodon/Diffiable/Status/StatusSection.swift b/Mastodon/Diffiable/Status/StatusSection.swift new file mode 100644 index 000000000..40b7e5351 --- /dev/null +++ b/Mastodon/Diffiable/Status/StatusSection.swift @@ -0,0 +1,310 @@ +// +// TimelineSection.swift +// Mastodon +// +// Created by sxiaojian on 2021/1/27. +// + +import Combine +import CoreData +import CoreDataStack +import os.log +import UIKit +import AVKit +import AlamofireImage +import MastodonMeta +import MastodonSDK +import NaturalLanguage +import MastodonUI + +enum StatusSection: Equatable, Hashable { + case main +} + +extension StatusSection { + + static let logger = Logger(subsystem: "StatusSection", category: "logic") + + struct Configuration { + weak var statusTableViewCellDelegate: StatusTableViewCellDelegate? + weak var timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate? + let filterContext: Mastodon.Entity.Filter.Context? + let activeFilters: Published<[Mastodon.Entity.Filter]>.Publisher? + } + + static func diffableDataSource( + tableView: UITableView, + context: AppContext, + configuration: Configuration + ) -> UITableViewDiffableDataSource<StatusSection, StatusItem> { + tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.self)) + tableView.register(TimelineMiddleLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineMiddleLoaderTableViewCell.self)) + tableView.register(StatusThreadRootTableViewCell.self, forCellReuseIdentifier: String(describing: StatusThreadRootTableViewCell.self)) + tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) + + return UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item -> UITableViewCell? in + switch item { + case .feed(let record): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusTableViewCell.self), for: indexPath) as! StatusTableViewCell + context.managedObjectContext.performAndWait { + guard let feed = record.object(in: context.managedObjectContext) else { return } + configure( + context: context, + tableView: tableView, + cell: cell, + viewModel: StatusTableViewCell.ViewModel(value: .feed(feed)), + configuration: configuration + ) + } + return cell + case .feedLoader(let record): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineMiddleLoaderTableViewCell.self), for: indexPath) as! TimelineMiddleLoaderTableViewCell + context.managedObjectContext.performAndWait { + guard let feed = record.object(in: context.managedObjectContext) else { return } + configure( + cell: cell, + feed: feed, + configuration: configuration + ) + } + return cell + case .status(let record): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusTableViewCell.self), for: indexPath) as! StatusTableViewCell + context.managedObjectContext.performAndWait { + guard let status = record.object(in: context.managedObjectContext) else { return } + configure( + context: context, + tableView: tableView, + cell: cell, + viewModel: StatusTableViewCell.ViewModel(value: .status(status)), + configuration: configuration + ) + } + return cell + case .thread(let thread): + let cell = dequeueConfiguredReusableCell( + context: context, + tableView: tableView, + indexPath: indexPath, + configuration: ThreadCellRegistrationConfiguration( + thread: thread, + configuration: configuration + ) + ) + return cell + case .topLoader: + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell + cell.activityIndicatorView.startAnimating() + return cell + case .bottomLoader: + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell + cell.activityIndicatorView.startAnimating() + return cell + } + } + } // end func + +} + +extension StatusSection { + + struct ThreadCellRegistrationConfiguration { + let thread: StatusItem.Thread + let configuration: Configuration + } + + static func dequeueConfiguredReusableCell( + context: AppContext, + tableView: UITableView, + indexPath: IndexPath, + configuration: ThreadCellRegistrationConfiguration + ) -> UITableViewCell { + let managedObjectContext = context.managedObjectContext + + switch configuration.thread { + case .root(let threadContext): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusThreadRootTableViewCell.self), for: indexPath) as! StatusThreadRootTableViewCell + managedObjectContext.performAndWait { + guard let status = threadContext.status.object(in: managedObjectContext) else { return } + StatusSection.configure( + context: context, + tableView: tableView, + cell: cell, + viewModel: StatusThreadRootTableViewCell.ViewModel(value: .status(status)), + configuration: configuration.configuration + ) + } + return cell + case .reply(let threadContext), + .leaf(let threadContext): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusTableViewCell.self), for: indexPath) as! StatusTableViewCell + managedObjectContext.performAndWait { + guard let status = threadContext.status.object(in: managedObjectContext) else { return } + StatusSection.configure( + context: context, + tableView: tableView, + cell: cell, + viewModel: StatusTableViewCell.ViewModel(value: .status(status)), + configuration: configuration.configuration + ) + } + return cell + } + } + +} + +extension StatusSection { + + public static func setupStatusPollDataSource( + context: AppContext, + statusView: StatusView + ) { + let managedObjectContext = context.managedObjectContext + statusView.pollTableViewDiffableDataSource = UITableViewDiffableDataSource<PollSection, PollItem>(tableView: statusView.pollTableView) { tableView, indexPath, item in + switch item { + case .option(let record): + // Fix cell reuse animation issue + let cell: PollOptionTableViewCell = { + let _cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PollOptionTableViewCell.self) + "@\(indexPath.row)#\(indexPath.section)") as? PollOptionTableViewCell + _cell?.prepareForReuse() + return _cell ?? PollOptionTableViewCell() + }() + + context.authenticationService.activeMastodonAuthenticationBox + .map { $0 as UserIdentifier? } + .assign(to: \.userIdentifier, on: cell.pollOptionView.viewModel) + .store(in: &cell.disposeBag) + + managedObjectContext.performAndWait { + guard let option = record.object(in: managedObjectContext) else { + assertionFailure() + return + } + + cell.pollOptionView.configure(pollOption: option) + + // trigger update if needs + let needsUpdatePoll: Bool = { + // check first option in poll to trigger update poll only once + guard option.index == 0 else { return false } + + let poll = option.poll + guard !poll.expired else { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): poll expired. Skip update poll \(poll.id)") + return false + } + + let now = Date() + let timeIntervalSinceUpdate = now.timeIntervalSince(poll.updatedAt) + #if DEBUG + let autoRefreshTimeInterval: TimeInterval = 3 // speedup testing + #else + let autoRefreshTimeInterval: TimeInterval = 30 + #endif + + guard timeIntervalSinceUpdate > autoRefreshTimeInterval else { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): skip update poll \(poll.id) due to recent updated") + return false + } + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): update poll \(poll.id)…") + return true + }() + + if needsUpdatePoll, let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value + { + let pollRecord: ManagedObjectRecord<Poll> = .init(objectID: option.poll.objectID) + Task { [weak context] in + guard let context = context else { return } + _ = try await context.apiService.poll( + poll: pollRecord, + authenticationBox: authenticationBox + ) + } + } + } // end managedObjectContext.performAndWait + return cell + } + } + var _snapshot = NSDiffableDataSourceSnapshot<PollSection, PollItem>() + _snapshot.appendSections([.main]) + if #available(iOS 15.0, *) { + statusView.pollTableViewDiffableDataSource?.applySnapshotUsingReloadData(_snapshot) + } else { + statusView.pollTableViewDiffableDataSource?.apply(_snapshot, animatingDifferences: false) + } + } +} + +extension StatusSection { + + static func configure( + context: AppContext, + tableView: UITableView, + cell: StatusTableViewCell, + viewModel: StatusTableViewCell.ViewModel, + configuration: Configuration + ) { + setupStatusPollDataSource( + context: context, + statusView: cell.statusView + ) + + context.authenticationService.activeMastodonAuthenticationBox + .map { $0 as UserIdentifier? } + .assign(to: \.userIdentifier, on: cell.statusView.viewModel) + .store(in: &cell.disposeBag) + + cell.configure( + tableView: tableView, + viewModel: viewModel, + delegate: configuration.statusTableViewCellDelegate + ) + + cell.statusView.viewModel.filterContext = configuration.filterContext + configuration.activeFilters? + .assign(to: \.activeFilters, on: cell.statusView.viewModel) + .store(in: &cell.disposeBag) + } + + static func configure( + context: AppContext, + tableView: UITableView, + cell: StatusThreadRootTableViewCell, + viewModel: StatusThreadRootTableViewCell.ViewModel, + configuration: Configuration + ) { + setupStatusPollDataSource( + context: context, + statusView: cell.statusView + ) + + context.authenticationService.activeMastodonAuthenticationBox + .map { $0 as UserIdentifier? } + .assign(to: \.userIdentifier, on: cell.statusView.viewModel) + .store(in: &cell.disposeBag) + + cell.configure( + tableView: tableView, + viewModel: viewModel, + delegate: configuration.statusTableViewCellDelegate + ) + + cell.statusView.viewModel.filterContext = configuration.filterContext + configuration.activeFilters? + .assign(to: \.activeFilters, on: cell.statusView.viewModel) + .store(in: &cell.disposeBag) + } + + static func configure( + cell: TimelineMiddleLoaderTableViewCell, + feed: Feed, + configuration: Configuration + ) { + cell.configure( + feed: feed, + delegate: configuration.timelineMiddleLoaderTableViewCellDelegate + ) + } + +} diff --git a/Mastodon/Diffiable/Item/UserItem.swift b/Mastodon/Diffiable/User/UserItem.swift similarity index 68% rename from Mastodon/Diffiable/Item/UserItem.swift rename to Mastodon/Diffiable/User/UserItem.swift index bd15f35ea..ff533d897 100644 --- a/Mastodon/Diffiable/Item/UserItem.swift +++ b/Mastodon/Diffiable/User/UserItem.swift @@ -7,10 +7,10 @@ import Foundation import CoreData +import CoreDataStack enum UserItem: Hashable { - case follower(objectID: NSManagedObjectID) - case following(objectID: NSManagedObjectID) + case user(record: ManagedObjectRecord<MastodonUser>) case bottomLoader case bottomHeader(text: String) } diff --git a/Mastodon/Diffiable/Section/UserSection.swift b/Mastodon/Diffiable/User/UserSection.swift similarity index 54% rename from Mastodon/Diffiable/Section/UserSection.swift rename to Mastodon/Diffiable/User/UserSection.swift index 9c7e2f212..a42110d7a 100644 --- a/Mastodon/Diffiable/Section/UserSection.swift +++ b/Mastodon/Diffiable/User/UserSection.swift @@ -19,23 +19,30 @@ enum UserSection: Hashable { extension UserSection { static let logger = Logger(subsystem: "StatusSection", category: "logic") + + struct Configuration { + weak var userTableViewCellDelegate: UserTableViewCellDelegate? + } - static func tableViewDiffableDataSource( - for tableView: UITableView, - dependency: NeedsDependency, - managedObjectContext: NSManagedObjectContext + static func diffableDataSource( + tableView: UITableView, + context: AppContext, + configuration: Configuration ) -> UITableViewDiffableDataSource<UserSection, UserItem> { - UITableViewDiffableDataSource(tableView: tableView) { [ - weak dependency - ] tableView, indexPath, item -> UITableViewCell? in - guard let dependency = dependency else { return UITableViewCell() } + tableView.register(UserTableViewCell.self, forCellReuseIdentifier: String(describing: UserTableViewCell.self)) + + return UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item -> UITableViewCell? in switch item { - case .follower(let objectID), - .following(let objectID): + case .user(let record): 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) + context.managedObjectContext.performAndWait { + guard let user = record.object(in: context.managedObjectContext) else { return } + configure( + tableView: tableView, + cell: cell, + viewModel: .init(value: .user(user)), + configuration: configuration + ) } return cell case .bottomLoader: @@ -55,10 +62,17 @@ extension UserSection { extension UserSection { static func configure( + tableView: UITableView, cell: UserTableViewCell, - user: MastodonUser + viewModel: UserTableViewCell.ViewModel, + configuration: Configuration ) { - cell.configure(user: user) + + cell.configure( + tableView: tableView, + viewModel: viewModel, + delegate: configuration.userTableViewCellDelegate + ) } } diff --git a/Mastodon/Extension/CoreDataStack/Attachment.swift b/Mastodon/Extension/CoreDataStack/Attachment.swift deleted file mode 100644 index e17f9bfef..000000000 --- a/Mastodon/Extension/CoreDataStack/Attachment.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// Attachment.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-2-23. -// - -import Foundation -import CoreDataStack -import MastodonSDK - -extension Attachment { - - var type: Mastodon.Entity.Attachment.AttachmentType { - return Mastodon.Entity.Attachment.AttachmentType(rawValue: typeRaw) ?? ._other(typeRaw) - } - - var meta: Mastodon.Entity.Attachment.Meta? { - let decoder = JSONDecoder() - return metaData.flatMap { try? decoder.decode(Mastodon.Entity.Attachment.Meta.self, from: $0) } - } - -} diff --git a/Mastodon/Extension/CoreDataStack/Emojis.swift b/Mastodon/Extension/CoreDataStack/Emojis.swift deleted file mode 100644 index c318e8ed9..000000000 --- a/Mastodon/Extension/CoreDataStack/Emojis.swift +++ /dev/null @@ -1,40 +0,0 @@ -// -// Emojis.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-5-7. -// - -import Foundation -import MastodonSDK -import MastodonMeta - -protocol EmojiContainer { - var emojisData: Data? { get } -} - -// FIXME: `Mastodon.Entity.Account` extension - -extension EmojiContainer { - - static func encode(emojis: [Mastodon.Entity.Emoji]) -> Data? { - return try? JSONEncoder().encode(emojis) - } - - var emojis: [Mastodon.Entity.Emoji]? { - let decoder = JSONDecoder() - return emojisData.flatMap { try? decoder.decode([Mastodon.Entity.Emoji].self, from: $0) } - } - - var emojiMeta: MastodonContent.Emojis { - let isAnimated = !UserDefaults.shared.preferredStaticEmoji - - var dict = MastodonContent.Emojis() - for emoji in emojis ?? [] { - dict[emoji.shortcode] = isAnimated ? emoji.url : emoji.staticURL - } - return dict - } - -} - diff --git a/Mastodon/Extension/CoreDataStack/Fields.swift b/Mastodon/Extension/CoreDataStack/Fields.swift deleted file mode 100644 index 5674c08b2..000000000 --- a/Mastodon/Extension/CoreDataStack/Fields.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// Fields.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-5-25. -// - -import Foundation -import MastodonSDK - -protocol FieldContainer { - var fieldsData: Data? { get } -} - -extension FieldContainer { - - static func encode(fields: [Mastodon.Entity.Field]) -> Data? { - return try? JSONEncoder().encode(fields) - } - - var fields: [Mastodon.Entity.Field]? { - let decoder = JSONDecoder() - return fieldsData.flatMap { try? decoder.decode([Mastodon.Entity.Field].self, from: $0) } - } - -} - diff --git a/Mastodon/Extension/CoreDataStack/MastodonUser+Property.swift b/Mastodon/Extension/CoreDataStack/MastodonUser+Property.swift deleted file mode 100644 index 1e4e542f8..000000000 --- a/Mastodon/Extension/CoreDataStack/MastodonUser+Property.swift +++ /dev/null @@ -1,74 +0,0 @@ -// -// MastodonUser+Property.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-7-20. -// - -import Foundation -import CoreDataStack - -extension MastodonUser { - - var displayNameWithFallback: String { - return !displayName.isEmpty ? displayName : username - } - - var acctWithDomain: String { - if !acct.contains("@") { - // Safe concat due to username cannot contains "@" - return username + "@" + domain - } else { - return acct - } - } - - var domainFromAcct: String { - if !acct.contains("@") { - return domain - } else { - let domain = acct.split(separator: "@").last - return String(domain!) - } - } - -} - -extension MastodonUser { - - public func headerImageURL() -> URL? { - return URL(string: header) - } - - public func headerImageURLWithFallback(domain: String) -> URL { - return URL(string: header) ?? URL(string: "https://\(domain)/headers/original/missing.png")! - } - - public func avatarImageURL() -> URL? { - let string = UserDefaults.shared.preferredStaticAvatar ? avatarStatic ?? avatar : avatar - return URL(string: string) - } - - public func avatarImageURLWithFallback(domain: String) -> URL { - return avatarImageURL() ?? URL(string: "https://\(domain)/avatars/original/missing.png")! - } - -} - -extension MastodonUser { - - var profileURL: URL { - if let urlString = self.url, - let url = URL(string: urlString) { - return url - } else { - return URL(string: "https://\(self.domain)/@\(username)")! - } - } - - var activityItems: [Any] { - var items: [Any] = [] - items.append(profileURL) - return items - } -} diff --git a/Mastodon/Extension/CoreDataStack/MastodonUser.swift b/Mastodon/Extension/CoreDataStack/MastodonUser.swift index f914c8649..02a983680 100644 --- a/Mastodon/Extension/CoreDataStack/MastodonUser.swift +++ b/Mastodon/Extension/CoreDataStack/MastodonUser.swift @@ -9,33 +9,67 @@ import Foundation import CoreDataStack import MastodonSDK -extension MastodonUser.Property { - init(entity: Mastodon.Entity.Account, domain: String, networkDate: Date) { - self.init( - id: entity.id, - domain: domain, - acct: entity.acct, - username: entity.username, - displayName: entity.displayName, - avatar: entity.avatar, - avatarStatic: entity.avatarStatic, - header: entity.header, - headerStatic: entity.headerStatic, - note: entity.note, - url: entity.url, - emojisData: entity.emojis.flatMap { MastodonUser.encode(emojis: $0) }, - fieldsData: entity.fields.flatMap { MastodonUser.encode(fields: $0) }, - statusesCount: entity.statusesCount, - followingCount: entity.followingCount, - followersCount: entity.followersCount, - locked: entity.locked, - bot: entity.bot, - suspended: entity.suspended, - createdAt: entity.createdAt, - networkDate: networkDate - ) +extension MastodonUser { + + public var displayNameWithFallback: String { + return !displayName.isEmpty ? displayName : username } + + public var acctWithDomain: String { + if !acct.contains("@") { + // Safe concat due to username cannot contains "@" + return username + "@" + domain + } else { + return acct + } + } + + public var domainFromAcct: String { + if !acct.contains("@") { + return domain + } else { + let domain = acct.split(separator: "@").last + return String(domain!) + } + } + } -extension MastodonUser: EmojiContainer { } -extension MastodonUser: FieldContainer { } +extension MastodonUser { + + public func headerImageURL() -> URL? { + return URL(string: header) + } + + public func headerImageURLWithFallback(domain: String) -> URL { + return URL(string: header) ?? URL(string: "https://\(domain)/headers/original/missing.png")! + } + + public func avatarImageURL() -> URL? { + let string = UserDefaults.shared.preferredStaticAvatar ? avatarStatic ?? avatar : avatar + return URL(string: string) + } + + public func avatarImageURLWithFallback(domain: String) -> URL { + return avatarImageURL() ?? URL(string: "https://\(domain)/avatars/original/missing.png")! + } + +} + +extension MastodonUser { + + public var profileURL: URL { + if let urlString = self.url, + let url = URL(string: urlString) { + return url + } else { + return URL(string: "https://\(self.domain)/@\(username)")! + } + } + + public var activityItems: [Any] { + var items: [Any] = [] + items.append(profileURL) + return items + } +} diff --git a/Mastodon/Extension/CoreDataStack/NotificationType.swift b/Mastodon/Extension/CoreDataStack/NotificationType.swift deleted file mode 100644 index d954563ab..000000000 --- a/Mastodon/Extension/CoreDataStack/NotificationType.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// NotificationType.swift -// Mastodon -// -// Created by Cirno MainasuK on 2021-7-3. -// - -import Foundation -import CoreDataStack -import MastodonSDK - -extension MastodonNotification { - var notificationType: Mastodon.Entity.Notification.NotificationType { - return Mastodon.Entity.Notification.NotificationType(rawValue: typeRaw) ?? ._other(typeRaw) - } -} diff --git a/Mastodon/Extension/CoreDataStack/Status.swift b/Mastodon/Extension/CoreDataStack/Status.swift index 1c329c852..2e0cf516a 100644 --- a/Mastodon/Extension/CoreDataStack/Status.swift +++ b/Mastodon/Extension/CoreDataStack/Status.swift @@ -9,67 +9,42 @@ import CoreDataStack import Foundation import MastodonSDK -extension Status.Property { - init(entity: Mastodon.Entity.Status, domain: String, networkDate: Date) { - self.init( - domain: domain, - id: entity.id, - uri: entity.uri, - createdAt: entity.createdAt, - content: entity.content!, - visibility: entity.visibility?.rawValue, - sensitive: entity.sensitive ?? false, - spoilerText: entity.spoilerText, - emojisData: entity.emojis.flatMap { Status.encode(emojis: $0) }, - reblogsCount: NSNumber(value: entity.reblogsCount), - favouritesCount: NSNumber(value: entity.favouritesCount), - repliesCount: entity.repliesCount.flatMap { NSNumber(value: $0) }, - url: entity.url ?? entity.uri, - inReplyToID: entity.inReplyToID, - inReplyToAccountID: entity.inReplyToAccountID, - language: entity.language, - text: entity.text, - networkDate: networkDate - ) - } -} - extension Status { enum SensitiveType { case none case all case media(isSensitive: Bool) } - + var sensitiveType: SensitiveType { let spoilerText = self.spoilerText ?? "" - + // cast .all sensitive when has spoiter text if !spoilerText.isEmpty { return .all } - - if let firstAttachment = mediaAttachments?.first { + + if let firstAttachment = attachments.first { // cast .media when has non audio media - if firstAttachment.type != .audio { + if firstAttachment.kind != .audio { return .media(isSensitive: sensitive) } else { return .none } } - + // not sensitive return .none } } -extension Status { - var authorForUserProvider: MastodonUser { - let author = (reblog ?? self).author - return author - } -} - +//extension Status { +// var authorForUserProvider: MastodonUser { +// let author = (reblog ?? self).author +// return author +// } +//} +// extension Status { var statusURL: URL { if let urlString = self.url, @@ -80,7 +55,7 @@ extension Status { return URL(string: "https://\(self.domain)/web/statuses/\(self.id)")! } } - + var activityItems: [Any] { var items: [Any] = [] items.append(self.statusURL) @@ -88,11 +63,15 @@ extension Status { } } -extension Status: EmojiContainer { } +//extension Status { +// var visibilityEnum: Mastodon.Entity.Status.Visibility? { +// return visibility.flatMap { Mastodon.Entity.Status.Visibility(rawValue: $0) } +// } +//} extension Status { - var visibilityEnum: Mastodon.Entity.Status.Visibility? { - return visibility.flatMap { Mastodon.Entity.Status.Visibility(rawValue: $0) } + var asRecord: ManagedObjectRecord<Status> { + return .init(objectID: self.objectID) } } diff --git a/Mastodon/Extension/FLAnimatedImageView.swift b/Mastodon/Extension/FLAnimatedImageView.swift index 1e6e62ad8..c913cd2a6 100644 --- a/Mastodon/Extension/FLAnimatedImageView.swift +++ b/Mastodon/Extension/FLAnimatedImageView.swift @@ -10,6 +10,7 @@ import Combine import Alamofire import AlamofireImage import FLAnimatedImage +import UIKit private enum FLAnimatedImageViewAssociatedKeys { static var activeAvatarRequestURL = "FLAnimatedImageViewAssociatedKeys.activeAvatarRequestURL" @@ -36,7 +37,12 @@ extension FLAnimatedImageView { } } - func setImage(url: URL?, placeholder: UIImage?, scaleToSize: CGSize?) { + func setImage( + url: URL?, + placeholder: UIImage?, + scaleToSize: CGSize?, + completion: ((UIImage?) -> Void)? = nil + ) { // cancel task activeAvatarRequestURL = nil avatarRequestCancellable?.cancel() @@ -64,17 +70,17 @@ extension FLAnimatedImageView { DispatchQueue.main.async { [weak self] in guard let self = self else { return } - if self.activeAvatarRequestURL == url { - if let animatedImage = animatedImage { - self.animatedImage = animatedImage - } else { - self.image = image - } + guard self.activeAvatarRequestURL == url else { return } + if let animatedImage = animatedImage { + self.animatedImage = animatedImage + } else { + self.image = image } + completion?(image) } } case .failure: - break + completion?(nil) } } } diff --git a/Mastodon/Extension/MastodonSDK/Mastodon+API+Subscriptions+Policy.swift b/Mastodon/Extension/MastodonSDK/Mastodon+API+Subscriptions+Policy.swift index 24bbfdace..e85c8263e 100644 --- a/Mastodon/Extension/MastodonSDK/Mastodon+API+Subscriptions+Policy.swift +++ b/Mastodon/Extension/MastodonSDK/Mastodon+API+Subscriptions+Policy.swift @@ -7,6 +7,8 @@ import Foundation import MastodonSDK +import MastodonAsset +import MastodonLocalization extension Mastodon.API.Subscriptions.Policy { var title: String { diff --git a/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Error+Detail.swift b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Error+Detail.swift index 312e4e3f0..b3771632c 100644 --- a/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Error+Detail.swift +++ b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Error+Detail.swift @@ -7,6 +7,8 @@ import Foundation import MastodonSDK +import MastodonAsset +import MastodonLocalization extension Mastodon.Entity.Error.Detail: LocalizedError { diff --git a/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Notification+Type.swift b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Notification+Type.swift index 2bddd9e97..2c5a2e46e 100644 --- a/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Notification+Type.swift +++ b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Notification+Type.swift @@ -8,6 +8,8 @@ import Foundation import MastodonSDK import UIKit +import MastodonAsset +import MastodonLocalization extension Mastodon.Entity.Notification.NotificationType { public var color: UIColor { diff --git a/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Tag.swift b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Tag.swift index caf819b38..2d0be6965 100644 --- a/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Tag.swift +++ b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Tag.swift @@ -16,3 +16,15 @@ extension Mastodon.Entity.Tag: Hashable { return lhs.name == rhs.name } } + +extension Mastodon.Entity.Tag { + + /// the sum of recent 2 days + public var talkingPeopleCount: Int? { + return history? + .prefix(2) + .compactMap { Int($0.accounts) } + .reduce(0, +) + } + +} diff --git a/Mastodon/Extension/UITableView.swift b/Mastodon/Extension/UITableView.swift index 3d96f97cd..74bdd2ed4 100644 --- a/Mastodon/Extension/UITableView.swift +++ b/Mastodon/Extension/UITableView.swift @@ -6,6 +6,8 @@ // import UIKit +import MastodonAsset +import MastodonLocalization extension UITableView { diff --git a/Mastodon/Extension/UIView.swift b/Mastodon/Extension/UIView.swift index 0f43dcedb..d4814b7ec 100644 --- a/Mastodon/Extension/UIView.swift +++ b/Mastodon/Extension/UIView.swift @@ -68,8 +68,3 @@ extension UIView { } } -extension UIView { - static var isZoomedMode: Bool { - return UIScreen.main.scale != UIScreen.main.nativeScale - } -} diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift deleted file mode 100644 index 906dd74e2..000000000 --- a/Mastodon/Generated/Assets.swift +++ /dev/null @@ -1,258 +0,0 @@ -// swiftlint:disable all -// Generated using SwiftGen — https://github.com/SwiftGen/SwiftGen - -#if os(macOS) - import AppKit -#elseif os(iOS) - import UIKit -#elseif os(tvOS) || os(watchOS) - import UIKit -#endif - -// Deprecated typealiases -@available(*, deprecated, renamed: "ColorAsset.Color", message: "This typealias will be removed in SwiftGen 7.0") -internal typealias AssetColorTypeAlias = ColorAsset.Color -@available(*, deprecated, renamed: "ImageAsset.Image", message: "This typealias will be removed in SwiftGen 7.0") -internal typealias AssetImageTypeAlias = ImageAsset.Image - -// swiftlint:disable superfluous_disable_command file_length implicit_return - -// MARK: - Asset Catalogs - -// swiftlint:disable identifier_name line_length nesting type_body_length type_name -internal enum Asset { - internal static let accentColor = ColorAsset(name: "AccentColor") - internal enum Asset { - internal static let email = ImageAsset(name: "Asset/email") - internal static let friends = ImageAsset(name: "Asset/friends") - internal static let mastodonTextLogo = ImageAsset(name: "Asset/mastodon.text.logo") - } - internal enum Circles { - internal static let plusCircleFill = ImageAsset(name: "Circles/plus.circle.fill") - internal static let plusCircle = ImageAsset(name: "Circles/plus.circle") - } - internal enum Colors { - internal enum Border { - internal static let composePoll = ColorAsset(name: "Colors/Border/compose.poll") - internal static let searchCard = ColorAsset(name: "Colors/Border/searchCard") - internal static let status = ColorAsset(name: "Colors/Border/status") - } - internal enum Button { - internal static let actionToolbar = ColorAsset(name: "Colors/Button/action.toolbar") - internal static let disabled = ColorAsset(name: "Colors/Button/disabled") - internal static let inactive = ColorAsset(name: "Colors/Button/inactive") - } - internal enum Icon { - internal static let plus = ColorAsset(name: "Colors/Icon/plus") - } - internal enum Label { - internal static let primary = ColorAsset(name: "Colors/Label/primary") - internal static let secondary = ColorAsset(name: "Colors/Label/secondary") - internal static let tertiary = ColorAsset(name: "Colors/Label/tertiary") - } - internal enum Notification { - internal static let favourite = ColorAsset(name: "Colors/Notification/favourite") - internal static let mention = ColorAsset(name: "Colors/Notification/mention") - internal static let reblog = ColorAsset(name: "Colors/Notification/reblog") - } - internal enum Poll { - internal static let disabled = ColorAsset(name: "Colors/Poll/disabled") - } - internal enum Shadow { - internal static let searchCard = ColorAsset(name: "Colors/Shadow/SearchCard") - } - internal enum Slider { - internal static let track = ColorAsset(name: "Colors/Slider/track") - } - internal enum TextField { - internal static let background = ColorAsset(name: "Colors/TextField/background") - internal static let invalid = ColorAsset(name: "Colors/TextField/invalid") - 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") - internal static let dangerBorder = ColorAsset(name: "Colors/danger.border") - internal static let danger = ColorAsset(name: "Colors/danger") - internal static let disabled = ColorAsset(name: "Colors/disabled") - internal static let inactive = ColorAsset(name: "Colors/inactive") - internal static let mediaTypeIndicotor = ColorAsset(name: "Colors/media.type.indicotor") - internal static let successGreen = ColorAsset(name: "Colors/success.green") - internal static let systemOrange = ColorAsset(name: "Colors/system.orange") - } - internal enum Connectivity { - internal static let photoFillSplit = ImageAsset(name: "Connectivity/photo.fill.split") - } - internal enum Human { - internal static let faceSmilingAdaptive = ImageAsset(name: "Human/face.smiling.adaptive") - } - internal enum Scene { - internal enum Profile { - internal enum Banner { - internal static let bioEditBackgroundGray = ColorAsset(name: "Scene/Profile/Banner/bio.edit.background.gray") - internal static let nameEditBackgroundGray = ColorAsset(name: "Scene/Profile/Banner/name.edit.background.gray") - 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") - internal static let cloudBase = ImageAsset(name: "Scene/Welcome/illustration/cloud.base") - internal static let elephantOnAirplaneWithContrail = ImageAsset(name: "Scene/Welcome/illustration/elephant.on.airplane.with.contrail") - internal static let elephantThreeOnGrass = ImageAsset(name: "Scene/Welcome/illustration/elephant.three.on.grass") - internal static let elephantThreeOnGrassWithTreeThree = ImageAsset(name: "Scene/Welcome/illustration/elephant.three.on.grass.with.tree.three") - internal static let elephantThreeOnGrassWithTreeTwo = ImageAsset(name: "Scene/Welcome/illustration/elephant.three.on.grass.with.tree.two") - } - internal static let mastodonLogoBlack = ImageAsset(name: "Scene/Welcome/mastodon.logo.black") - internal static let mastodonLogoBlackLarge = ImageAsset(name: "Scene/Welcome/mastodon.logo.black.large") - internal static let mastodonLogo = ImageAsset(name: "Scene/Welcome/mastodon.logo") - internal static let mastodonLogoLarge = ImageAsset(name: "Scene/Welcome/mastodon.logo.large") - } - } - internal enum Settings { - internal static let blackAuto = ImageAsset(name: "Settings/black.auto") - internal static let black = ImageAsset(name: "Settings/black") - internal static let darkAuto = ImageAsset(name: "Settings/dark.auto") - internal static let dark = ImageAsset(name: "Settings/dark") - internal static let light = ImageAsset(name: "Settings/light") - } - internal enum Theme { - internal enum Mastodon { - internal static let composeToolbarBackground = ColorAsset(name: "Theme/Mastodon/compose.toolbar.background") - internal static let contentWarningOverlayBackground = ColorAsset(name: "Theme/Mastodon/content.warning.overlay.background") - internal static let navigationBarBackground = ColorAsset(name: "Theme/Mastodon/navigation.bar.background") - 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") - internal static let tabBarBackground = ColorAsset(name: "Theme/Mastodon/tab.bar.background") - internal static let tableViewCellBackground = ColorAsset(name: "Theme/Mastodon/table.view.cell.background") - internal static let tableViewCellSelectionBackground = ColorAsset(name: "Theme/Mastodon/table.view.cell.selection.background") - internal static let tertiarySystemBackground = ColorAsset(name: "Theme/Mastodon/tertiary.system.background") - internal static let tertiarySystemGroupedBackground = ColorAsset(name: "Theme/Mastodon/tertiary.system.grouped.background") - internal static let notificationStatusBorderColor = ColorAsset(name: "Theme/Mastodon/notification.status.border.color") - internal static let separator = ColorAsset(name: "Theme/Mastodon/separator") - internal static let tabBarItemInactiveIconColor = ColorAsset(name: "Theme/Mastodon/tab.bar.item.inactive.icon.color") - } - internal enum System { - internal static let composeToolbarBackground = ColorAsset(name: "Theme/system/compose.toolbar.background") - internal static let contentWarningOverlayBackground = ColorAsset(name: "Theme/system/content.warning.overlay.background") - internal static let navigationBarBackground = ColorAsset(name: "Theme/system/navigation.bar.background") - 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") - internal static let tabBarBackground = ColorAsset(name: "Theme/system/tab.bar.background") - internal static let tableViewCellBackground = ColorAsset(name: "Theme/system/table.view.cell.background") - internal static let tableViewCellSelectionBackground = ColorAsset(name: "Theme/system/table.view.cell.selection.background") - internal static let tertiarySystemBackground = ColorAsset(name: "Theme/system/tertiary.system.background") - internal static let tertiarySystemGroupedBackground = ColorAsset(name: "Theme/system/tertiary.system.grouped.background") - internal static let notificationStatusBorderColor = ColorAsset(name: "Theme/system/notification.status.border.color") - internal static let separator = ColorAsset(name: "Theme/system/separator") - internal static let tabBarItemInactiveIconColor = ColorAsset(name: "Theme/system/tab.bar.item.inactive.icon.color") - } - } -} -// swiftlint:enable identifier_name line_length nesting type_body_length type_name - -// MARK: - Implementation Details - -internal final class ColorAsset { - internal fileprivate(set) var name: String - - #if os(macOS) - internal typealias Color = NSColor - #elseif os(iOS) || os(tvOS) || os(watchOS) - internal typealias Color = UIColor - #endif - - @available(iOS 11.0, tvOS 11.0, watchOS 4.0, macOS 10.13, *) - internal private(set) lazy var color: Color = { - guard let color = Color(asset: self) else { - fatalError("Unable to load color asset named \(name).") - } - return color - }() - - fileprivate init(name: String) { - self.name = name - } -} - -internal extension ColorAsset.Color { - @available(iOS 11.0, tvOS 11.0, watchOS 4.0, macOS 10.13, *) - convenience init?(asset: ColorAsset) { - let bundle = BundleToken.bundle - #if os(iOS) || os(tvOS) - self.init(named: asset.name, in: bundle, compatibleWith: nil) - #elseif os(macOS) - self.init(named: NSColor.Name(asset.name), bundle: bundle) - #elseif os(watchOS) - self.init(named: asset.name) - #endif - } -} - -internal struct ImageAsset { - internal fileprivate(set) var name: String - - #if os(macOS) - internal typealias Image = NSImage - #elseif os(iOS) || os(tvOS) || os(watchOS) - internal typealias Image = UIImage - #endif - - internal var image: Image { - let bundle = BundleToken.bundle - #if os(iOS) || os(tvOS) - let image = Image(named: name, in: bundle, compatibleWith: nil) - #elseif os(macOS) - let name = NSImage.Name(self.name) - let image = (bundle == .main) ? NSImage(named: name) : bundle.image(forResource: name) - #elseif os(watchOS) - let image = Image(named: name) - #endif - guard let result = image else { - fatalError("Unable to load image asset named \(name).") - } - return result - } -} - -internal extension ImageAsset.Image { - @available(macOS, deprecated, - message: "This initializer is unsafe on macOS, please use the ImageAsset.image property") - convenience init?(asset: ImageAsset) { - #if os(iOS) || os(tvOS) - let bundle = BundleToken.bundle - self.init(named: asset.name, in: bundle, compatibleWith: nil) - #elseif os(macOS) - self.init(named: NSImage.Name(asset.name)) - #elseif os(watchOS) - self.init(named: asset.name) - #endif - } -} - -// swiftlint:disable convenience_type -private final class BundleToken { - static let bundle: Bundle = { - #if SWIFT_PACKAGE - return Bundle.module - #else - return Bundle(for: BundleToken.self) - #endif - }() -} -// swiftlint:enable convenience_type diff --git a/Mastodon/Generated/AutoGenerateProtocolDelegate.generated.swift b/Mastodon/Generated/AutoGenerateProtocolDelegate.generated.swift new file mode 100644 index 000000000..a771d3462 --- /dev/null +++ b/Mastodon/Generated/AutoGenerateProtocolDelegate.generated.swift @@ -0,0 +1,12 @@ +// Generated using Sourcery 1.6.1 — https://github.com/krzysztofzablocki/Sourcery +// DO NOT EDIT +// sourcery:inline:NotificationTableViewCellDelegate.AutoGenerateProtocolDelegate +notificationView(_ notificationView: NotificationView, menuButton button: UIButton, didSelectAction action: NotificationView.AuthorMenuAction, menuContext: NotificationView.AuthorMenuContext) +notificationView(_ notificationView: NotificationView, statusView: StatusView, authorAvatarButtonDidPressed button: AvatarButton) +notificationView(_ notificationView: NotificationView, statusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta) +notificationView(_ notificationView: NotificationView, statusView: StatusView, actionToolbarContainer: ActionToolbarContainer, buttonDidPressed button: UIButton, action: ActionToolbarContainer.Action) +notificationView(_ notificationView: NotificationView, quoteStatusView: StatusView, authorAvatarButtonDidPressed button: AvatarButton) +notificationView(_ notificationView: NotificationView, quoteStatusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta) +// sourcery:end + + diff --git a/Mastodon/Generated/AutoGenerateProtocolRelayDelegate.generated.swift b/Mastodon/Generated/AutoGenerateProtocolRelayDelegate.generated.swift new file mode 100644 index 000000000..ae7cb25a6 --- /dev/null +++ b/Mastodon/Generated/AutoGenerateProtocolRelayDelegate.generated.swift @@ -0,0 +1,30 @@ +// Generated using Sourcery 1.6.1 — https://github.com/krzysztofzablocki/Sourcery +// DO NOT EDIT +// sourcery:inline:NotificationViewContainerTableViewCell.AutoGenerateProtocolRelayDelegate +func notificationView(_ notificationView: NotificationView, menuButton button: UIButton, didSelectAction action: NotificationView.AuthorMenuAction, menuContext: NotificationView.AuthorMenuContext) { + notificationView(notificationView, menuButton: button, didSelectAction: action, menuContext: menuContext) +} + +func notificationView(_ notificationView: NotificationView, statusView: StatusView, authorAvatarButtonDidPressed button: AvatarButton) { + notificationView(notificationView, statusView: statusView, authorAvatarButtonDidPressed: button) +} + +func notificationView(_ notificationView: NotificationView, statusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta) { + notificationView(notificationView, statusView: statusView, metaText: metaText, didSelectMeta: meta) +} + +func notificationView(_ notificationView: NotificationView, statusView: StatusView, actionToolbarContainer: ActionToolbarContainer, buttonDidPressed button: UIButton, action: ActionToolbarContainer.Action) { + notificationView(notificationView, statusView: statusView, actionToolbarContainer: actionToolbarContainer, buttonDidPressed: button, action: action) +} + +func notificationView(_ notificationView: NotificationView, quoteStatusView: StatusView, authorAvatarButtonDidPressed button: AvatarButton) { + notificationView(notificationView, quoteStatusView: quoteStatusView, authorAvatarButtonDidPressed: button) +} + +func notificationView(_ notificationView: NotificationView, quoteStatusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta) { + notificationView(notificationView, quoteStatusView: quoteStatusView, metaText: metaText, didSelectMeta: meta) +} + +// sourcery:end + + diff --git a/Mastodon/Generated/AutoGenerateTableViewDelegate.generated.swift b/Mastodon/Generated/AutoGenerateTableViewDelegate.generated.swift new file mode 100644 index 000000000..ebf867007 --- /dev/null +++ b/Mastodon/Generated/AutoGenerateTableViewDelegate.generated.swift @@ -0,0 +1,35 @@ +// Generated using Sourcery 1.6.1 — https://github.com/krzysztofzablocki/Sourcery +// DO NOT EDIT + + + + + + + + +// sourcery:inline:UserTimelineViewController.AutoGenerateTableViewDelegate + +// Generated using Sourcery +// DO NOT EDIT +func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + aspectTableView(tableView, didSelectRowAt: 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) +} +// sourcery:end + diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift deleted file mode 100644 index ebf9869c4..000000000 --- a/Mastodon/Generated/Strings.swift +++ /dev/null @@ -1,1167 +0,0 @@ -// swiftlint:disable all -// Generated using SwiftGen — https://github.com/SwiftGen/SwiftGen - -import Foundation - -// swiftlint:disable superfluous_disable_command file_length implicit_return - -// MARK: - Strings - -// swiftlint:disable explicit_type_interface function_parameter_count identifier_name line_length -// swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces -internal enum L10n { - - internal enum Common { - internal enum Alerts { - internal enum BlockDomain { - /// Block Domain - internal static let blockEntireDomain = L10n.tr("Localizable", "Common.Alerts.BlockDomain.BlockEntireDomain") - /// 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. - internal static func title(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Alerts.BlockDomain.Title", String(describing: p1)) - } - } - internal enum CleanCache { - /// Successfully cleaned %@ cache. - internal static func message(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Alerts.CleanCache.Message", String(describing: p1)) - } - /// Clean Cache - internal static let title = L10n.tr("Localizable", "Common.Alerts.CleanCache.Title") - } - internal enum Common { - /// Please try again. - internal static let pleaseTryAgain = L10n.tr("Localizable", "Common.Alerts.Common.PleaseTryAgain") - /// Please try again later. - internal static let pleaseTryAgainLater = L10n.tr("Localizable", "Common.Alerts.Common.PleaseTryAgainLater") - } - internal enum DeletePost { - /// Delete - internal static let delete = L10n.tr("Localizable", "Common.Alerts.DeletePost.Delete") - /// Are you sure you want to delete this post? - internal static let title = L10n.tr("Localizable", "Common.Alerts.DeletePost.Title") - } - internal enum DiscardPostContent { - /// Confirm to discard composed post content. - internal static let message = L10n.tr("Localizable", "Common.Alerts.DiscardPostContent.Message") - /// Discard Draft - internal static let title = L10n.tr("Localizable", "Common.Alerts.DiscardPostContent.Title") - } - internal enum EditProfileFailure { - /// Cannot edit profile. Please try again. - internal static let message = L10n.tr("Localizable", "Common.Alerts.EditProfileFailure.Message") - /// Edit Profile Error - internal static let title = L10n.tr("Localizable", "Common.Alerts.EditProfileFailure.Title") - } - internal enum PublishPostFailure { - /// Failed to publish the post.\nPlease check your internet connection. - internal static let message = L10n.tr("Localizable", "Common.Alerts.PublishPostFailure.Message") - /// Publish Failure - internal static let title = L10n.tr("Localizable", "Common.Alerts.PublishPostFailure.Title") - internal enum AttachmentsMessage { - /// Cannot attach more than one video. - internal static let moreThanOneVideo = L10n.tr("Localizable", "Common.Alerts.PublishPostFailure.AttachmentsMessage.MoreThanOneVideo") - /// Cannot attach a video to a post that already contains images. - internal static let videoAttachWithPhoto = L10n.tr("Localizable", "Common.Alerts.PublishPostFailure.AttachmentsMessage.VideoAttachWithPhoto") - } - } - internal enum SavePhotoFailure { - /// Please enable the photo library access permission to save the photo. - internal static let message = L10n.tr("Localizable", "Common.Alerts.SavePhotoFailure.Message") - /// Save Photo Failure - internal static let title = L10n.tr("Localizable", "Common.Alerts.SavePhotoFailure.Title") - } - internal enum ServerError { - /// Server Error - internal static let title = L10n.tr("Localizable", "Common.Alerts.ServerError.Title") - } - internal enum SignOut { - /// Sign Out - internal static let confirm = L10n.tr("Localizable", "Common.Alerts.SignOut.Confirm") - /// Are you sure you want to sign out? - internal static let message = L10n.tr("Localizable", "Common.Alerts.SignOut.Message") - /// Sign Out - internal static let title = L10n.tr("Localizable", "Common.Alerts.SignOut.Title") - } - internal enum SignUpFailure { - /// Sign Up Failure - internal static let title = L10n.tr("Localizable", "Common.Alerts.SignUpFailure.Title") - } - internal enum VoteFailure { - /// The poll has ended - internal static let pollEnded = L10n.tr("Localizable", "Common.Alerts.VoteFailure.PollEnded") - /// Vote Failure - internal static let title = L10n.tr("Localizable", "Common.Alerts.VoteFailure.Title") - } - } - internal enum Controls { - internal enum Actions { - /// Add - internal static let add = L10n.tr("Localizable", "Common.Controls.Actions.Add") - /// Back - internal static let back = L10n.tr("Localizable", "Common.Controls.Actions.Back") - /// Block %@ - internal static func blockDomain(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Controls.Actions.BlockDomain", String(describing: p1)) - } - /// 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 - internal static let `continue` = L10n.tr("Localizable", "Common.Controls.Actions.Continue") - /// Copy Photo - internal static let copyPhoto = L10n.tr("Localizable", "Common.Controls.Actions.CopyPhoto") - /// Delete - internal static let delete = L10n.tr("Localizable", "Common.Controls.Actions.Delete") - /// Discard - internal static let discard = L10n.tr("Localizable", "Common.Controls.Actions.Discard") - /// Done - internal static let done = L10n.tr("Localizable", "Common.Controls.Actions.Done") - /// Edit - internal static let edit = L10n.tr("Localizable", "Common.Controls.Actions.Edit") - /// Find people to follow - internal static let findPeople = L10n.tr("Localizable", "Common.Controls.Actions.FindPeople") - /// Manually search instead - internal static let manuallySearch = L10n.tr("Localizable", "Common.Controls.Actions.ManuallySearch") - /// Next - internal static let next = L10n.tr("Localizable", "Common.Controls.Actions.Next") - /// OK - internal static let ok = L10n.tr("Localizable", "Common.Controls.Actions.Ok") - /// Open - internal static let `open` = L10n.tr("Localizable", "Common.Controls.Actions.Open") - /// Open in Safari - internal static let openInSafari = L10n.tr("Localizable", "Common.Controls.Actions.OpenInSafari") - /// Preview - internal static let preview = L10n.tr("Localizable", "Common.Controls.Actions.Preview") - /// Previous - internal static let previous = L10n.tr("Localizable", "Common.Controls.Actions.Previous") - /// Remove - internal static let remove = L10n.tr("Localizable", "Common.Controls.Actions.Remove") - /// Reply - internal static let reply = L10n.tr("Localizable", "Common.Controls.Actions.Reply") - /// Report %@ - internal static func reportUser(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Controls.Actions.ReportUser", String(describing: p1)) - } - /// Save - internal static let save = L10n.tr("Localizable", "Common.Controls.Actions.Save") - /// Save Photo - internal static let savePhoto = L10n.tr("Localizable", "Common.Controls.Actions.SavePhoto") - /// See More - internal static let seeMore = L10n.tr("Localizable", "Common.Controls.Actions.SeeMore") - /// Settings - internal static let settings = L10n.tr("Localizable", "Common.Controls.Actions.Settings") - /// Share - internal static let share = L10n.tr("Localizable", "Common.Controls.Actions.Share") - /// Share Post - internal static let sharePost = L10n.tr("Localizable", "Common.Controls.Actions.SharePost") - /// Share %@ - internal static func shareUser(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Controls.Actions.ShareUser", String(describing: p1)) - } - /// Sign In - internal static let signIn = L10n.tr("Localizable", "Common.Controls.Actions.SignIn") - /// Sign Up - internal static let signUp = L10n.tr("Localizable", "Common.Controls.Actions.SignUp") - /// Skip - internal static let skip = L10n.tr("Localizable", "Common.Controls.Actions.Skip") - /// Take Photo - internal static let takePhoto = L10n.tr("Localizable", "Common.Controls.Actions.TakePhoto") - /// Try Again - internal static let tryAgain = L10n.tr("Localizable", "Common.Controls.Actions.TryAgain") - /// Unblock %@ - internal static func unblockDomain(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Controls.Actions.UnblockDomain", String(describing: p1)) - } - } - internal enum Friendship { - /// Block - internal static let block = L10n.tr("Localizable", "Common.Controls.Friendship.Block") - /// Block %@ - internal static func blockDomain(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Controls.Friendship.BlockDomain", String(describing: p1)) - } - /// Blocked - internal static let blocked = L10n.tr("Localizable", "Common.Controls.Friendship.Blocked") - /// Block %@ - internal static func blockUser(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Controls.Friendship.BlockUser", String(describing: p1)) - } - /// Edit Info - internal static let editInfo = L10n.tr("Localizable", "Common.Controls.Friendship.EditInfo") - /// Follow - internal static let follow = L10n.tr("Localizable", "Common.Controls.Friendship.Follow") - /// Following - internal static let following = L10n.tr("Localizable", "Common.Controls.Friendship.Following") - /// Mute - internal static let mute = L10n.tr("Localizable", "Common.Controls.Friendship.Mute") - /// Muted - internal static let muted = L10n.tr("Localizable", "Common.Controls.Friendship.Muted") - /// Mute %@ - internal static func muteUser(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Controls.Friendship.MuteUser", String(describing: p1)) - } - /// Pending - internal static let pending = L10n.tr("Localizable", "Common.Controls.Friendship.Pending") - /// Request - internal static let request = L10n.tr("Localizable", "Common.Controls.Friendship.Request") - /// Unblock - internal static let unblock = L10n.tr("Localizable", "Common.Controls.Friendship.Unblock") - /// Unblock %@ - internal static func unblockUser(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Controls.Friendship.UnblockUser", String(describing: p1)) - } - /// Unmute - internal static let unmute = L10n.tr("Localizable", "Common.Controls.Friendship.Unmute") - /// Unmute %@ - internal static func unmuteUser(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Controls.Friendship.UnmuteUser", String(describing: p1)) - } - } - internal enum Keyboard { - internal enum Common { - /// Compose New Post - internal static let composeNewPost = L10n.tr("Localizable", "Common.Controls.Keyboard.Common.ComposeNewPost") - /// Open Settings - internal static let openSettings = L10n.tr("Localizable", "Common.Controls.Keyboard.Common.OpenSettings") - /// Show Favorites - internal static let showFavorites = L10n.tr("Localizable", "Common.Controls.Keyboard.Common.ShowFavorites") - /// Switch to %@ - internal static func switchToTab(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Controls.Keyboard.Common.SwitchToTab", String(describing: p1)) - } - } - internal enum SegmentedControl { - /// Next Section - internal static let nextSection = L10n.tr("Localizable", "Common.Controls.Keyboard.SegmentedControl.NextSection") - /// Previous Section - internal static let previousSection = L10n.tr("Localizable", "Common.Controls.Keyboard.SegmentedControl.PreviousSection") - } - internal enum Timeline { - /// Next Post - internal static let nextStatus = L10n.tr("Localizable", "Common.Controls.Keyboard.Timeline.NextStatus") - /// Open Author's Profile - internal static let openAuthorProfile = L10n.tr("Localizable", "Common.Controls.Keyboard.Timeline.OpenAuthorProfile") - /// Open Reblogger's Profile - internal static let openRebloggerProfile = L10n.tr("Localizable", "Common.Controls.Keyboard.Timeline.OpenRebloggerProfile") - /// Open Post - internal static let openStatus = L10n.tr("Localizable", "Common.Controls.Keyboard.Timeline.OpenStatus") - /// Preview Image - internal static let previewImage = L10n.tr("Localizable", "Common.Controls.Keyboard.Timeline.PreviewImage") - /// Previous Post - internal static let previousStatus = L10n.tr("Localizable", "Common.Controls.Keyboard.Timeline.PreviousStatus") - /// Reply to Post - internal static let replyStatus = L10n.tr("Localizable", "Common.Controls.Keyboard.Timeline.ReplyStatus") - /// Toggle Content Warning - internal static let toggleContentWarning = L10n.tr("Localizable", "Common.Controls.Keyboard.Timeline.ToggleContentWarning") - /// Toggle Favorite on Post - internal static let toggleFavorite = L10n.tr("Localizable", "Common.Controls.Keyboard.Timeline.ToggleFavorite") - /// Toggle Reblog on Post - internal static let toggleReblog = L10n.tr("Localizable", "Common.Controls.Keyboard.Timeline.ToggleReblog") - } - } - internal enum Status { - /// Content Warning - internal static let contentWarning = L10n.tr("Localizable", "Common.Controls.Status.ContentWarning") - /// Tap anywhere to reveal - internal static let mediaContentWarning = L10n.tr("Localizable", "Common.Controls.Status.MediaContentWarning") - /// Show Post - internal static let showPost = L10n.tr("Localizable", "Common.Controls.Status.ShowPost") - /// Show user profile - internal static let showUserProfile = L10n.tr("Localizable", "Common.Controls.Status.ShowUserProfile") - /// %@ reblogged - internal static func userReblogged(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Controls.Status.UserReblogged", String(describing: p1)) - } - /// Replied to %@ - internal static func userRepliedTo(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Controls.Status.UserRepliedTo", String(describing: p1)) - } - internal enum Actions { - /// Favorite - internal static let favorite = L10n.tr("Localizable", "Common.Controls.Status.Actions.Favorite") - /// Menu - internal static let menu = L10n.tr("Localizable", "Common.Controls.Status.Actions.Menu") - /// Reblog - internal static let reblog = L10n.tr("Localizable", "Common.Controls.Status.Actions.Reblog") - /// Reply - internal static let reply = L10n.tr("Localizable", "Common.Controls.Status.Actions.Reply") - /// Unfavorite - internal static let unfavorite = L10n.tr("Localizable", "Common.Controls.Status.Actions.Unfavorite") - /// Undo reblog - internal static let unreblog = L10n.tr("Localizable", "Common.Controls.Status.Actions.Unreblog") - } - internal enum Poll { - /// Closed - internal static let closed = L10n.tr("Localizable", "Common.Controls.Status.Poll.Closed") - /// Vote - internal static let vote = L10n.tr("Localizable", "Common.Controls.Status.Poll.Vote") - } - internal enum Tag { - /// Email - internal static let email = L10n.tr("Localizable", "Common.Controls.Status.Tag.Email") - /// Emoji - internal static let emoji = L10n.tr("Localizable", "Common.Controls.Status.Tag.Emoji") - /// Hashtag - internal static let hashtag = L10n.tr("Localizable", "Common.Controls.Status.Tag.Hashtag") - /// Link - internal static let link = L10n.tr("Localizable", "Common.Controls.Status.Tag.Link") - /// Mention - internal static let mention = L10n.tr("Localizable", "Common.Controls.Status.Tag.Mention") - /// URL - internal static let url = L10n.tr("Localizable", "Common.Controls.Status.Tag.Url") - } - } - internal enum Tabs { - /// Home - internal static let home = L10n.tr("Localizable", "Common.Controls.Tabs.Home") - /// Notification - internal static let notification = L10n.tr("Localizable", "Common.Controls.Tabs.Notification") - /// Profile - internal static let profile = L10n.tr("Localizable", "Common.Controls.Tabs.Profile") - /// Search - internal static let search = L10n.tr("Localizable", "Common.Controls.Tabs.Search") - } - internal enum Timeline { - /// Filtered - internal static let filtered = L10n.tr("Localizable", "Common.Controls.Timeline.Filtered") - internal enum Header { - /// You can’t view this user’s profile\nuntil they unblock you. - internal static let blockedWarning = L10n.tr("Localizable", "Common.Controls.Timeline.Header.BlockedWarning") - /// You can’t view this user's profile\nuntil you unblock them.\nYour profile looks like this to them. - internal static let blockingWarning = L10n.tr("Localizable", "Common.Controls.Timeline.Header.BlockingWarning") - /// No Post Found - internal static let noStatusFound = L10n.tr("Localizable", "Common.Controls.Timeline.Header.NoStatusFound") - /// This user has been suspended. - internal static let suspendedWarning = L10n.tr("Localizable", "Common.Controls.Timeline.Header.SuspendedWarning") - /// You can’t view %@’s profile\nuntil they unblock you. - internal static func userBlockedWarning(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Controls.Timeline.Header.UserBlockedWarning", String(describing: p1)) - } - /// You can’t view %@’s profile\nuntil you unblock them.\nYour profile looks like this to them. - internal static func userBlockingWarning(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Controls.Timeline.Header.UserBlockingWarning", String(describing: p1)) - } - /// %@’s account has been suspended. - internal static func userSuspendedWarning(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Controls.Timeline.Header.UserSuspendedWarning", String(describing: p1)) - } - } - internal enum Loader { - /// Loading missing posts... - internal static let loadingMissingPosts = L10n.tr("Localizable", "Common.Controls.Timeline.Loader.LoadingMissingPosts") - /// Load missing posts - internal static let loadMissingPosts = L10n.tr("Localizable", "Common.Controls.Timeline.Loader.LoadMissingPosts") - /// Show more replies - internal static let showMoreReplies = L10n.tr("Localizable", "Common.Controls.Timeline.Loader.ShowMoreReplies") - } - internal enum Timestamp { - /// Now - internal static let now = L10n.tr("Localizable", "Common.Controls.Timeline.Timestamp.Now") - } - } - } - } - - 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") - /// Type or paste what’s on your mind - internal static let contentInputPlaceholder = L10n.tr("Localizable", "Scene.Compose.ContentInputPlaceholder") - /// replying to %@ - internal static func replyingToUser(_ p1: Any) -> String { - return L10n.tr("Localizable", "Scene.Compose.ReplyingToUser", String(describing: p1)) - } - internal enum Accessibility { - /// Add Attachment - internal static let appendAttachment = L10n.tr("Localizable", "Scene.Compose.Accessibility.AppendAttachment") - /// Add Poll - internal static let appendPoll = L10n.tr("Localizable", "Scene.Compose.Accessibility.AppendPoll") - /// Custom Emoji Picker - internal static let customEmojiPicker = L10n.tr("Localizable", "Scene.Compose.Accessibility.CustomEmojiPicker") - /// Disable Content Warning - internal static let disableContentWarning = L10n.tr("Localizable", "Scene.Compose.Accessibility.DisableContentWarning") - /// Enable Content Warning - internal static let enableContentWarning = L10n.tr("Localizable", "Scene.Compose.Accessibility.EnableContentWarning") - /// Post Visibility Menu - internal static let postVisibilityMenu = L10n.tr("Localizable", "Scene.Compose.Accessibility.PostVisibilityMenu") - /// Remove Poll - internal static let removePoll = L10n.tr("Localizable", "Scene.Compose.Accessibility.RemovePoll") - } - internal enum Attachment { - /// This %@ is broken and can’t be\nuploaded to Mastodon. - internal static func attachmentBroken(_ p1: Any) -> String { - return L10n.tr("Localizable", "Scene.Compose.Attachment.AttachmentBroken", String(describing: p1)) - } - /// Describe the photo for the visually-impaired... - internal static let descriptionPhoto = L10n.tr("Localizable", "Scene.Compose.Attachment.DescriptionPhoto") - /// Describe the video for the visually-impaired... - internal static let descriptionVideo = L10n.tr("Localizable", "Scene.Compose.Attachment.DescriptionVideo") - /// photo - internal static let photo = L10n.tr("Localizable", "Scene.Compose.Attachment.Photo") - /// video - internal static let video = L10n.tr("Localizable", "Scene.Compose.Attachment.Video") - } - internal enum AutoComplete { - /// Space to add - internal static let spaceToAdd = L10n.tr("Localizable", "Scene.Compose.AutoComplete.SpaceToAdd") - } - internal enum ContentWarning { - /// Write an accurate warning here... - internal static let placeholder = L10n.tr("Localizable", "Scene.Compose.ContentWarning.Placeholder") - } - internal enum Keyboard { - /// Add Attachment - %@ - internal static func appendAttachmentEntry(_ p1: Any) -> String { - return L10n.tr("Localizable", "Scene.Compose.Keyboard.AppendAttachmentEntry", String(describing: p1)) - } - /// Discard Post - internal static let discardPost = L10n.tr("Localizable", "Scene.Compose.Keyboard.DiscardPost") - /// Publish Post - internal static let publishPost = L10n.tr("Localizable", "Scene.Compose.Keyboard.PublishPost") - /// Select Visibility - %@ - internal static func selectVisibilityEntry(_ p1: Any) -> String { - return L10n.tr("Localizable", "Scene.Compose.Keyboard.SelectVisibilityEntry", String(describing: p1)) - } - /// Toggle Content Warning - internal static let toggleContentWarning = L10n.tr("Localizable", "Scene.Compose.Keyboard.ToggleContentWarning") - /// Toggle Poll - internal static let togglePoll = L10n.tr("Localizable", "Scene.Compose.Keyboard.TogglePoll") - } - internal enum MediaSelection { - /// Browse - internal static let browse = L10n.tr("Localizable", "Scene.Compose.MediaSelection.Browse") - /// Take Photo - internal static let camera = L10n.tr("Localizable", "Scene.Compose.MediaSelection.Camera") - /// Photo Library - internal static let photoLibrary = L10n.tr("Localizable", "Scene.Compose.MediaSelection.PhotoLibrary") - } - internal enum Poll { - /// Duration: %@ - internal static func durationTime(_ p1: Any) -> String { - return L10n.tr("Localizable", "Scene.Compose.Poll.DurationTime", String(describing: p1)) - } - /// 1 Day - internal static let oneDay = L10n.tr("Localizable", "Scene.Compose.Poll.OneDay") - /// 1 Hour - internal static let oneHour = L10n.tr("Localizable", "Scene.Compose.Poll.OneHour") - /// Option %ld - internal static func optionNumber(_ p1: Int) -> String { - return L10n.tr("Localizable", "Scene.Compose.Poll.OptionNumber", p1) - } - /// 7 Days - internal static let sevenDays = L10n.tr("Localizable", "Scene.Compose.Poll.SevenDays") - /// 6 Hours - internal static let sixHours = L10n.tr("Localizable", "Scene.Compose.Poll.SixHours") - /// 30 minutes - internal static let thirtyMinutes = L10n.tr("Localizable", "Scene.Compose.Poll.ThirtyMinutes") - /// 3 Days - internal static let threeDays = L10n.tr("Localizable", "Scene.Compose.Poll.ThreeDays") - } - internal enum Title { - /// New Post - internal static let newPost = L10n.tr("Localizable", "Scene.Compose.Title.NewPost") - /// New Reply - internal static let newReply = L10n.tr("Localizable", "Scene.Compose.Title.NewReply") - } - internal enum Visibility { - /// Only people I mention - internal static let direct = L10n.tr("Localizable", "Scene.Compose.Visibility.Direct") - /// Followers only - internal static let `private` = L10n.tr("Localizable", "Scene.Compose.Visibility.Private") - /// Public - internal static let `public` = L10n.tr("Localizable", "Scene.Compose.Visibility.Public") - /// Unlisted - internal static let unlisted = L10n.tr("Localizable", "Scene.Compose.Visibility.Unlisted") - } - } - internal enum ConfirmEmail { - /// We just sent an email to %@,\ntap the link to confirm your account. - internal static func subtitle(_ p1: Any) -> String { - return L10n.tr("Localizable", "Scene.ConfirmEmail.Subtitle", String(describing: p1)) - } - /// One last thing. - internal static let title = L10n.tr("Localizable", "Scene.ConfirmEmail.Title") - internal enum Button { - /// I never got an email - internal static let dontReceiveEmail = L10n.tr("Localizable", "Scene.ConfirmEmail.Button.DontReceiveEmail") - /// Open Email App - internal static let openEmailApp = L10n.tr("Localizable", "Scene.ConfirmEmail.Button.OpenEmailApp") - } - internal enum DontReceiveEmail { - /// Check if your email address is correct as well as your junk folder if you haven’t. - internal static let description = L10n.tr("Localizable", "Scene.ConfirmEmail.DontReceiveEmail.Description") - /// Resend Email - internal static let resendEmail = L10n.tr("Localizable", "Scene.ConfirmEmail.DontReceiveEmail.ResendEmail") - /// Check your email - internal static let title = L10n.tr("Localizable", "Scene.ConfirmEmail.DontReceiveEmail.Title") - } - internal enum OpenEmailApp { - /// We just sent you an email. Check your junk folder if you haven’t. - internal static let description = L10n.tr("Localizable", "Scene.ConfirmEmail.OpenEmailApp.Description") - /// Mail - internal static let mail = L10n.tr("Localizable", "Scene.ConfirmEmail.OpenEmailApp.Mail") - /// Open Email Client - internal static let openEmailClient = L10n.tr("Localizable", "Scene.ConfirmEmail.OpenEmailApp.OpenEmailClient") - /// Check your inbox. - internal static let title = L10n.tr("Localizable", "Scene.ConfirmEmail.OpenEmailApp.Title") - } - } - internal enum Favorite { - /// 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") - internal enum NavigationBarState { - /// See new posts - internal static let newPosts = L10n.tr("Localizable", "Scene.HomeTimeline.NavigationBarState.NewPosts") - /// Offline - internal static let offline = L10n.tr("Localizable", "Scene.HomeTimeline.NavigationBarState.Offline") - /// Published! - internal static let published = L10n.tr("Localizable", "Scene.HomeTimeline.NavigationBarState.Published") - /// Publishing post... - internal static let publishing = L10n.tr("Localizable", "Scene.HomeTimeline.NavigationBarState.Publishing") - } - } - internal enum Notification { - /// %@ favorited your post - internal static func userFavoritedYourPost(_ p1: Any) -> String { - return L10n.tr("Localizable", "Scene.Notification.UserFavorited Your Post", String(describing: p1)) - } - /// %@ followed you - internal static func userFollowedYou(_ p1: Any) -> String { - return L10n.tr("Localizable", "Scene.Notification.UserFollowedYou", String(describing: p1)) - } - /// %@ mentioned you - internal static func userMentionedYou(_ p1: Any) -> String { - return L10n.tr("Localizable", "Scene.Notification.UserMentionedYou", String(describing: p1)) - } - /// %@ reblogged your post - internal static func userRebloggedYourPost(_ p1: Any) -> String { - return L10n.tr("Localizable", "Scene.Notification.UserRebloggedYourPost", String(describing: p1)) - } - /// %@ requested to follow you - internal static func userRequestedToFollowYou(_ p1: Any) -> String { - return L10n.tr("Localizable", "Scene.Notification.UserRequestedToFollowYou", String(describing: p1)) - } - /// %@ Your poll has ended - internal static func userYourPollHasEnded(_ p1: Any) -> String { - return L10n.tr("Localizable", "Scene.Notification.UserYourPollHasEnded", String(describing: p1)) - } - internal enum Keyobard { - /// Show Everything - internal static let showEverything = L10n.tr("Localizable", "Scene.Notification.Keyobard.ShowEverything") - /// Show Mentions - internal static let showMentions = L10n.tr("Localizable", "Scene.Notification.Keyobard.ShowMentions") - } - internal enum Title { - /// Everything - internal static let everything = L10n.tr("Localizable", "Scene.Notification.Title.Everything") - /// Mentions - internal static let mentions = L10n.tr("Localizable", "Scene.Notification.Title.Mentions") - } - } - internal enum Preview { - internal enum Keyboard { - /// Close Preview - internal static let closePreview = L10n.tr("Localizable", "Scene.Preview.Keyboard.ClosePreview") - /// Show Next - internal static let showNext = L10n.tr("Localizable", "Scene.Preview.Keyboard.ShowNext") - /// Show Previous - internal static let showPrevious = L10n.tr("Localizable", "Scene.Preview.Keyboard.ShowPrevious") - } - } - internal enum Profile { - internal enum Dashboard { - /// followers - internal static let followers = L10n.tr("Localizable", "Scene.Profile.Dashboard.Followers") - /// following - internal static let following = L10n.tr("Localizable", "Scene.Profile.Dashboard.Following") - /// posts - internal static let posts = L10n.tr("Localizable", "Scene.Profile.Dashboard.Posts") - } - internal enum Fields { - /// Add Row - internal static let addRow = L10n.tr("Localizable", "Scene.Profile.Fields.AddRow") - internal enum Placeholder { - /// Content - internal static let content = L10n.tr("Localizable", "Scene.Profile.Fields.Placeholder.Content") - /// Label - internal static let label = L10n.tr("Localizable", "Scene.Profile.Fields.Placeholder.Label") - } - } - internal enum RelationshipActionAlert { - internal enum ConfirmUnblockUsre { - /// Confirm to unblock %@ - internal static func message(_ p1: Any) -> String { - return L10n.tr("Localizable", "Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Message", String(describing: p1)) - } - /// Unblock Account - internal static let title = L10n.tr("Localizable", "Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Title") - } - internal enum ConfirmUnmuteUser { - /// Confirm to unmute %@ - internal static func message(_ p1: Any) -> String { - return L10n.tr("Localizable", "Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Message", String(describing: p1)) - } - /// Unmute Account - internal static let title = L10n.tr("Localizable", "Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Title") - } - } - internal enum SegmentedControl { - /// Media - internal static let media = L10n.tr("Localizable", "Scene.Profile.SegmentedControl.Media") - /// Posts - internal static let posts = L10n.tr("Localizable", "Scene.Profile.SegmentedControl.Posts") - /// Replies - internal static let replies = L10n.tr("Localizable", "Scene.Profile.SegmentedControl.Replies") - } - } - internal enum Register { - /// Tell us about you. - internal static let title = L10n.tr("Localizable", "Scene.Register.Title") - internal enum Error { - internal enum Item { - /// Agreement - internal static let agreement = L10n.tr("Localizable", "Scene.Register.Error.Item.Agreement") - /// Email - internal static let email = L10n.tr("Localizable", "Scene.Register.Error.Item.Email") - /// Locale - internal static let locale = L10n.tr("Localizable", "Scene.Register.Error.Item.Locale") - /// Password - internal static let password = L10n.tr("Localizable", "Scene.Register.Error.Item.Password") - /// Reason - internal static let reason = L10n.tr("Localizable", "Scene.Register.Error.Item.Reason") - /// Username - internal static let username = L10n.tr("Localizable", "Scene.Register.Error.Item.Username") - } - internal enum Reason { - /// %@ must be accepted - internal static func accepted(_ p1: Any) -> String { - return L10n.tr("Localizable", "Scene.Register.Error.Reason.Accepted", String(describing: p1)) - } - /// %@ is required - internal static func blank(_ p1: Any) -> String { - return L10n.tr("Localizable", "Scene.Register.Error.Reason.Blank", String(describing: p1)) - } - /// %@ contains a disallowed email provider - internal static func blocked(_ p1: Any) -> String { - return L10n.tr("Localizable", "Scene.Register.Error.Reason.Blocked", String(describing: p1)) - } - /// %@ is not a supported value - internal static func inclusion(_ p1: Any) -> String { - return L10n.tr("Localizable", "Scene.Register.Error.Reason.Inclusion", String(describing: p1)) - } - /// %@ is invalid - internal static func invalid(_ p1: Any) -> String { - return L10n.tr("Localizable", "Scene.Register.Error.Reason.Invalid", String(describing: p1)) - } - /// %@ is a reserved keyword - internal static func reserved(_ p1: Any) -> String { - return L10n.tr("Localizable", "Scene.Register.Error.Reason.Reserved", String(describing: p1)) - } - /// %@ is already in use - internal static func taken(_ p1: Any) -> String { - return L10n.tr("Localizable", "Scene.Register.Error.Reason.Taken", String(describing: p1)) - } - /// %@ is too long - internal static func tooLong(_ p1: Any) -> String { - return L10n.tr("Localizable", "Scene.Register.Error.Reason.TooLong", String(describing: p1)) - } - /// %@ is too short - internal static func tooShort(_ p1: Any) -> String { - return L10n.tr("Localizable", "Scene.Register.Error.Reason.TooShort", String(describing: p1)) - } - /// %@ does not seem to exist - internal static func unreachable(_ p1: Any) -> String { - return L10n.tr("Localizable", "Scene.Register.Error.Reason.Unreachable", String(describing: p1)) - } - } - internal enum Special { - /// This is not a valid email address - internal static let emailInvalid = L10n.tr("Localizable", "Scene.Register.Error.Special.EmailInvalid") - /// Password is too short (must be at least 8 characters) - internal static let passwordTooShort = L10n.tr("Localizable", "Scene.Register.Error.Special.PasswordTooShort") - /// Username must only contain alphanumeric characters and underscores - internal static let usernameInvalid = L10n.tr("Localizable", "Scene.Register.Error.Special.UsernameInvalid") - /// Username is too long (can’t be longer than 30 characters) - internal static let usernameTooLong = L10n.tr("Localizable", "Scene.Register.Error.Special.UsernameTooLong") - } - } - internal enum Input { - internal enum Avatar { - /// Delete - internal static let delete = L10n.tr("Localizable", "Scene.Register.Input.Avatar.Delete") - } - internal enum DisplayName { - /// display name - internal static let placeholder = L10n.tr("Localizable", "Scene.Register.Input.DisplayName.Placeholder") - } - internal enum Email { - /// email - internal static let placeholder = L10n.tr("Localizable", "Scene.Register.Input.Email.Placeholder") - } - internal enum Invite { - /// Why do you want to join? - internal static let registrationUserInviteRequest = L10n.tr("Localizable", "Scene.Register.Input.Invite.RegistrationUserInviteRequest") - } - internal enum Password { - /// Your password needs at least eight characters - internal static let hint = L10n.tr("Localizable", "Scene.Register.Input.Password.Hint") - /// password - internal static let placeholder = L10n.tr("Localizable", "Scene.Register.Input.Password.Placeholder") - } - internal enum Username { - /// This username is taken. - internal static let duplicatePrompt = L10n.tr("Localizable", "Scene.Register.Input.Username.DuplicatePrompt") - /// username - internal static let placeholder = L10n.tr("Localizable", "Scene.Register.Input.Username.Placeholder") - } - } - } - internal enum Report { - /// Are there any other posts you’d like to add to the report? - internal static let content1 = L10n.tr("Localizable", "Scene.Report.Content1") - /// Is there anything the moderators should know about this report? - internal static let content2 = L10n.tr("Localizable", "Scene.Report.Content2") - /// Send Report - internal static let send = L10n.tr("Localizable", "Scene.Report.Send") - /// Send without comment - internal static let skipToSend = L10n.tr("Localizable", "Scene.Report.SkipToSend") - /// Step 1 of 2 - internal static let step1 = L10n.tr("Localizable", "Scene.Report.Step1") - /// Step 2 of 2 - internal static let step2 = L10n.tr("Localizable", "Scene.Report.Step2") - /// Type or paste additional comments - internal static let textPlaceholder = L10n.tr("Localizable", "Scene.Report.TextPlaceholder") - /// Report %@ - internal static func title(_ p1: Any) -> String { - return L10n.tr("Localizable", "Scene.Report.Title", String(describing: p1)) - } - } - internal enum Search { - /// Search - internal static let title = L10n.tr("Localizable", "Scene.Search.Title") - internal enum Recommend { - /// See All - internal static let buttonText = L10n.tr("Localizable", "Scene.Search.Recommend.ButtonText") - internal enum Accounts { - /// You may like to follow these accounts - internal static let description = L10n.tr("Localizable", "Scene.Search.Recommend.Accounts.Description") - /// Follow - internal static let follow = L10n.tr("Localizable", "Scene.Search.Recommend.Accounts.Follow") - /// Accounts you might like - internal static let title = L10n.tr("Localizable", "Scene.Search.Recommend.Accounts.Title") - } - internal enum HashTag { - /// Hashtags that are getting quite a bit of attention - internal static let description = L10n.tr("Localizable", "Scene.Search.Recommend.HashTag.Description") - /// %@ people are talking - internal static func peopleTalking(_ p1: Any) -> String { - return L10n.tr("Localizable", "Scene.Search.Recommend.HashTag.PeopleTalking", String(describing: p1)) - } - /// Trending on Mastodon - internal static let title = L10n.tr("Localizable", "Scene.Search.Recommend.HashTag.Title") - } - } - internal enum SearchBar { - /// Cancel - internal static let cancel = L10n.tr("Localizable", "Scene.Search.SearchBar.Cancel") - /// Search hashtags and users - internal static let placeholder = L10n.tr("Localizable", "Scene.Search.SearchBar.Placeholder") - } - internal enum Searching { - /// Clear - internal static let clear = L10n.tr("Localizable", "Scene.Search.Searching.Clear") - /// Recent searches - internal static let recentSearch = L10n.tr("Localizable", "Scene.Search.Searching.RecentSearch") - internal enum EmptyState { - /// No results - internal static let noResults = L10n.tr("Localizable", "Scene.Search.Searching.EmptyState.NoResults") - } - internal enum Segment { - /// All - internal static let all = L10n.tr("Localizable", "Scene.Search.Searching.Segment.All") - /// Hashtags - internal static let hashtags = L10n.tr("Localizable", "Scene.Search.Searching.Segment.Hashtags") - /// People - internal static let people = L10n.tr("Localizable", "Scene.Search.Searching.Segment.People") - /// Posts - internal static let posts = L10n.tr("Localizable", "Scene.Search.Searching.Segment.Posts") - } - } - } - internal enum ServerPicker { - /// Pick a server,\nany server. - internal static let title = L10n.tr("Localizable", "Scene.ServerPicker.Title") - internal enum Button { - /// See Less - internal static let seeLess = L10n.tr("Localizable", "Scene.ServerPicker.Button.SeeLess") - /// See More - internal static let seeMore = L10n.tr("Localizable", "Scene.ServerPicker.Button.SeeMore") - internal enum Category { - /// academia - internal static let academia = L10n.tr("Localizable", "Scene.ServerPicker.Button.Category.Academia") - /// activism - internal static let activism = L10n.tr("Localizable", "Scene.ServerPicker.Button.Category.Activism") - /// All - internal static let all = L10n.tr("Localizable", "Scene.ServerPicker.Button.Category.All") - /// Category: All - internal static let allAccessiblityDescription = L10n.tr("Localizable", "Scene.ServerPicker.Button.Category.AllAccessiblityDescription") - /// art - internal static let art = L10n.tr("Localizable", "Scene.ServerPicker.Button.Category.Art") - /// food - internal static let food = L10n.tr("Localizable", "Scene.ServerPicker.Button.Category.Food") - /// furry - internal static let furry = L10n.tr("Localizable", "Scene.ServerPicker.Button.Category.Furry") - /// games - internal static let games = L10n.tr("Localizable", "Scene.ServerPicker.Button.Category.Games") - /// general - internal static let general = L10n.tr("Localizable", "Scene.ServerPicker.Button.Category.General") - /// journalism - internal static let journalism = L10n.tr("Localizable", "Scene.ServerPicker.Button.Category.Journalism") - /// lgbt - internal static let lgbt = L10n.tr("Localizable", "Scene.ServerPicker.Button.Category.Lgbt") - /// music - internal static let music = L10n.tr("Localizable", "Scene.ServerPicker.Button.Category.Music") - /// regional - internal static let regional = L10n.tr("Localizable", "Scene.ServerPicker.Button.Category.Regional") - /// tech - internal static let tech = L10n.tr("Localizable", "Scene.ServerPicker.Button.Category.Tech") - } - } - internal enum EmptyState { - /// Something went wrong while loading the data. Check your internet connection. - internal static let badNetwork = L10n.tr("Localizable", "Scene.ServerPicker.EmptyState.BadNetwork") - /// Finding available servers... - internal static let findingServers = L10n.tr("Localizable", "Scene.ServerPicker.EmptyState.FindingServers") - /// No results - internal static let noResults = L10n.tr("Localizable", "Scene.ServerPicker.EmptyState.NoResults") - } - internal enum Input { - /// Find a server or join your own... - internal static let placeholder = L10n.tr("Localizable", "Scene.ServerPicker.Input.Placeholder") - } - internal enum Label { - /// CATEGORY - internal static let category = L10n.tr("Localizable", "Scene.ServerPicker.Label.Category") - /// LANGUAGE - internal static let language = L10n.tr("Localizable", "Scene.ServerPicker.Label.Language") - /// USERS - internal static let users = L10n.tr("Localizable", "Scene.ServerPicker.Label.Users") - } - } - internal enum ServerRules { - /// privacy policy - internal static let privacyPolicy = L10n.tr("Localizable", "Scene.ServerRules.PrivacyPolicy") - /// By continuing, you’re subject to the terms of service and privacy policy for %@. - internal static func prompt(_ p1: Any) -> String { - return L10n.tr("Localizable", "Scene.ServerRules.Prompt", String(describing: p1)) - } - /// These rules are set by the admins of %@. - internal static func subtitle(_ p1: Any) -> String { - return L10n.tr("Localizable", "Scene.ServerRules.Subtitle", String(describing: p1)) - } - /// terms of service - internal static let termsOfService = L10n.tr("Localizable", "Scene.ServerRules.TermsOfService") - /// Some ground rules. - internal static let title = L10n.tr("Localizable", "Scene.ServerRules.Title") - internal enum Button { - /// I Agree - internal static let confirm = L10n.tr("Localizable", "Scene.ServerRules.Button.Confirm") - } - } - internal enum Settings { - /// Settings - internal static let title = L10n.tr("Localizable", "Scene.Settings.Title") - internal enum Footer { - /// Mastodon is open source software. You can report issues on GitHub at %@ (%@) - internal static func mastodonDescription(_ p1: Any, _ p2: Any) -> String { - return L10n.tr("Localizable", "Scene.Settings.Footer.MastodonDescription", String(describing: p1), String(describing: p2)) - } - } - internal enum Keyboard { - /// Close Settings Window - internal static let closeSettingsWindow = L10n.tr("Localizable", "Scene.Settings.Keyboard.CloseSettingsWindow") - } - internal enum Section { - internal enum Appearance { - /// Automatic - internal static let automatic = L10n.tr("Localizable", "Scene.Settings.Section.Appearance.Automatic") - /// Always Dark - internal static let dark = L10n.tr("Localizable", "Scene.Settings.Section.Appearance.Dark") - /// Always Light - internal static let light = L10n.tr("Localizable", "Scene.Settings.Section.Appearance.Light") - /// Appearance - internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.Appearance.Title") - } - internal enum BoringZone { - /// Account Settings - internal static let accountSettings = L10n.tr("Localizable", "Scene.Settings.Section.BoringZone.AccountSettings") - /// Privacy Policy - internal static let privacy = L10n.tr("Localizable", "Scene.Settings.Section.BoringZone.Privacy") - /// Terms of Service - internal static let terms = L10n.tr("Localizable", "Scene.Settings.Section.BoringZone.Terms") - /// The Boring Zone - internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.BoringZone.Title") - } - internal enum Notifications { - /// Reblogs my post - internal static let boosts = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Boosts") - /// Favorites my post - internal static let favorites = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Favorites") - /// Follows me - internal static let follows = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Follows") - /// Mentions me - internal static let mentions = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Mentions") - /// Notifications - internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Title") - internal enum Trigger { - /// anyone - internal static let anyone = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Trigger.Anyone") - /// anyone I follow - internal static let follow = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Trigger.Follow") - /// a follower - internal static let follower = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Trigger.Follower") - /// no one - internal static let noone = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Trigger.Noone") - /// Notify me when - internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Trigger.Title") - } - } - internal enum Preference { - /// Disable animated avatars - internal static let disableAvatarAnimation = L10n.tr("Localizable", "Scene.Settings.Section.Preference.DisableAvatarAnimation") - /// Disable animated emojis - internal static let disableEmojiAnimation = L10n.tr("Localizable", "Scene.Settings.Section.Preference.DisableEmojiAnimation") - /// Preferences - internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.Preference.Title") - /// True black dark mode - internal static let trueBlackDarkMode = L10n.tr("Localizable", "Scene.Settings.Section.Preference.TrueBlackDarkMode") - /// Use default browser to open links - internal static let usingDefaultBrowser = L10n.tr("Localizable", "Scene.Settings.Section.Preference.UsingDefaultBrowser") - } - internal enum SpicyZone { - /// Clear Media Cache - internal static let clear = L10n.tr("Localizable", "Scene.Settings.Section.SpicyZone.Clear") - /// Sign Out - internal static let signout = L10n.tr("Localizable", "Scene.Settings.Section.SpicyZone.Signout") - /// The Spicy Zone - internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.SpicyZone.Title") - } - } - } - internal enum SuggestionAccount { - /// When you follow someone, you’ll see their posts in your home feed. - internal static let followExplain = L10n.tr("Localizable", "Scene.SuggestionAccount.FollowExplain") - /// Find People to Follow - internal static let title = L10n.tr("Localizable", "Scene.SuggestionAccount.Title") - } - internal enum Thread { - /// Post - internal static let backTitle = L10n.tr("Localizable", "Scene.Thread.BackTitle") - /// Post from %@ - internal static func title(_ p1: Any) -> String { - return L10n.tr("Localizable", "Scene.Thread.Title", String(describing: p1)) - } - } - internal enum Welcome { - /// 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 { - internal enum Plural { - internal enum Count { - /// Plural format key: "Input limit exceeds %#@character_count@" - internal static func inputLimitExceeds(_ p1: Int) -> String { - return L10n.tr("Localizable", "a11y.plural.count.input_limit_exceeds", p1) - } - /// Plural format key: "Input limit remains %#@character_count@" - 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) - } - } - } - } - } - - internal enum Date { - internal enum Day { - /// Plural format key: "%#@count_day_left@" - internal static func `left`(_ p1: Int) -> String { - return L10n.tr("Localizable", "date.day.left", p1) - } - internal enum Ago { - /// Plural format key: "%#@count_day_ago_abbr@" - internal static func abbr(_ p1: Int) -> String { - return L10n.tr("Localizable", "date.day.ago.abbr", p1) - } - } - } - internal enum Hour { - /// Plural format key: "%#@count_hour_left@" - internal static func `left`(_ p1: Int) -> String { - return L10n.tr("Localizable", "date.hour.left", p1) - } - internal enum Ago { - /// Plural format key: "%#@count_hour_ago_abbr@" - internal static func abbr(_ p1: Int) -> String { - return L10n.tr("Localizable", "date.hour.ago.abbr", p1) - } - } - } - internal enum Minute { - /// Plural format key: "%#@count_minute_left@" - internal static func `left`(_ p1: Int) -> String { - return L10n.tr("Localizable", "date.minute.left", p1) - } - internal enum Ago { - /// Plural format key: "%#@count_minute_ago_abbr@" - internal static func abbr(_ p1: Int) -> String { - return L10n.tr("Localizable", "date.minute.ago.abbr", p1) - } - } - } - internal enum Month { - /// Plural format key: "%#@count_month_left@" - internal static func `left`(_ p1: Int) -> String { - return L10n.tr("Localizable", "date.month.left", p1) - } - internal enum Ago { - /// Plural format key: "%#@count_month_ago_abbr@" - internal static func abbr(_ p1: Int) -> String { - return L10n.tr("Localizable", "date.month.ago.abbr", p1) - } - } - } - internal enum Second { - /// Plural format key: "%#@count_second_left@" - internal static func `left`(_ p1: Int) -> String { - return L10n.tr("Localizable", "date.second.left", p1) - } - internal enum Ago { - /// Plural format key: "%#@count_second_ago_abbr@" - internal static func abbr(_ p1: Int) -> String { - return L10n.tr("Localizable", "date.second.ago.abbr", p1) - } - } - } - internal enum Year { - /// Plural format key: "%#@count_year_left@" - internal static func `left`(_ p1: Int) -> String { - return L10n.tr("Localizable", "date.year.left", p1) - } - internal enum Ago { - /// Plural format key: "%#@count_year_ago_abbr@" - internal static func abbr(_ p1: Int) -> String { - return L10n.tr("Localizable", "date.year.ago.abbr", p1) - } - } - } - } - - internal enum Plural { - /// Plural format key: "%#@count_people_talking@" - internal static func peopleTalking(_ p1: Int) -> String { - return L10n.tr("Localizable", "plural.people_talking", p1) - } - internal enum Count { - /// Plural format key: "%#@favorite_count@" - internal static func favorite(_ p1: Int) -> String { - return L10n.tr("Localizable", "plural.count.favorite", p1) - } - /// Plural format key: "%#@count_follower@" - internal static func follower(_ p1: Int) -> String { - return L10n.tr("Localizable", "plural.count.follower", p1) - } - /// Plural format key: "%#@count_following@" - internal static func following(_ p1: Int) -> String { - return L10n.tr("Localizable", "plural.count.following", p1) - } - /// Plural format key: "%#@post_count@" - internal static func post(_ p1: Int) -> String { - return L10n.tr("Localizable", "plural.count.post", p1) - } - /// Plural format key: "%#@reblog_count@" - internal static func reblog(_ p1: Int) -> String { - return L10n.tr("Localizable", "plural.count.reblog", p1) - } - /// Plural format key: "%#@vote_count@" - internal static func vote(_ p1: Int) -> String { - return L10n.tr("Localizable", "plural.count.vote", p1) - } - /// Plural format key: "%#@voter_count@" - internal static func voter(_ p1: Int) -> String { - return L10n.tr("Localizable", "plural.count.voter", p1) - } - internal enum MetricFormatted { - /// Plural format key: "%@ %#@post_count@" - internal static func post(_ p1: Any, _ p2: Int) -> String { - return L10n.tr("Localizable", "plural.count.metric_formatted.post", String(describing: p1), p2) - } - } - } - } -} -// swiftlint:enable explicit_type_interface function_parameter_count identifier_name line_length -// swiftlint:enable nesting type_body_length type_name vertical_whitespace_opening_braces - -// MARK: - Implementation Details - -extension L10n { - private static func tr(_ table: String, _ key: String, _ args: CVarArg...) -> String { - let format = BundleToken.bundle.localizedString(forKey: key, value: nil, table: table) - return String(format: format, locale: Locale.current, arguments: args) - } -} - -// swiftlint:disable convenience_type -private final class BundleToken { - static let bundle: Bundle = { - #if SWIFT_PACKAGE - return Bundle.module - #else - return Bundle(for: BundleToken.self) - #endif - }() -} -// swiftlint:enable convenience_type diff --git a/Mastodon/Helper/MastodonAuthenticationBox.swift b/Mastodon/Helper/MastodonAuthenticationBox.swift index 71ba50b5e..31c9649c6 100644 --- a/Mastodon/Helper/MastodonAuthenticationBox.swift +++ b/Mastodon/Helper/MastodonAuthenticationBox.swift @@ -6,10 +6,12 @@ // import Foundation -import MastodonSDK import CoreDataStack +import MastodonSDK +import MastodonUI -struct MastodonAuthenticationBox { +struct MastodonAuthenticationBox: UserIdentifier { + let authenticationRecord: ManagedObjectRecord<MastodonAuthentication> let domain: String let userID: MastodonUser.ID let appAuthorization: Mastodon.API.OAuth.Authorization diff --git a/Mastodon/Info.plist b/Mastodon/Info.plist index affa5b059..311ee390b 100644 --- a/Mastodon/Info.plist +++ b/Mastodon/Info.plist @@ -2,6 +2,19 @@ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> + <key>NSAppTransportSecurity</key> + <dict> + <key>NSExceptionDomains</key> + <dict> + <key>onion</key> + <dict> + <key>NSExceptionAllowsInsecureHTTPLoads</key> + <true/> + <key>NSIncludesSubdomains</key> + <true/> + </dict> + </dict> + </dict> <key>CADisableMinimumFrameDurationOnPhone</key> <true/> <key>CFBundleDevelopmentRegion</key> @@ -17,7 +30,7 @@ <key>CFBundlePackageType</key> <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> <key>CFBundleShortVersionString</key> - <string>1.2.0</string> + <string>1.3.0</string> <key>CFBundleURLTypes</key> <array> <dict> @@ -30,7 +43,7 @@ </dict> </array> <key>CFBundleVersion</key> - <string>88</string> + <string>109</string> <key>ITSAppUsesNonExemptEncryption</key> <false/> <key>LSApplicationQueriesSchemes</key> diff --git a/Mastodon/Persistence/Extension/MastodonEmoji.swift b/Mastodon/Persistence/Extension/MastodonEmoji.swift new file mode 100644 index 000000000..e9274a24d --- /dev/null +++ b/Mastodon/Persistence/Extension/MastodonEmoji.swift @@ -0,0 +1,34 @@ +// +// MastodonEmojis.swift +// MastodonEmojis +// +// Created by Cirno MainasuK on 2021-9-2. +// Copyright © 2021 Twidere. All rights reserved. +// + +import Foundation +import CoreDataStack +import MastodonSDK +import MastodonMeta + +extension MastodonEmoji { + public convenience init(emoji: Mastodon.Entity.Emoji) { + self.init( + code: emoji.shortcode, + url: emoji.url, + staticURL: emoji.staticURL, + visibleInPicker: emoji.visibleInPicker, + category: emoji.category + ) + } +} + +extension Collection where Element == MastodonEmoji { + public var asDictionary: MastodonContent.Emojis { + var dictionary: MastodonContent.Emojis = [:] + for emoji in self { + dictionary[emoji.code] = emoji.url + } + return dictionary + } +} diff --git a/Mastodon/Persistence/Extension/MastodonField.swift b/Mastodon/Persistence/Extension/MastodonField.swift new file mode 100644 index 000000000..4fa2ef971 --- /dev/null +++ b/Mastodon/Persistence/Extension/MastodonField.swift @@ -0,0 +1,21 @@ +// +// MastodonField.swift +// TwidereX +// +// Created by Cirno MainasuK on 2021-9-18. +// Copyright © 2021 Twidere. All rights reserved. +// + +import Foundation +import CoreDataStack +import MastodonSDK + +extension MastodonField { + public convenience init(field: Mastodon.Entity.Field) { + self.init( + name: field.name, + value: field.value, + verifiedAt: field.verifiedAt + ) + } +} diff --git a/Mastodon/Persistence/Extension/MastodonMention.swift b/Mastodon/Persistence/Extension/MastodonMention.swift new file mode 100644 index 000000000..6c3df37a5 --- /dev/null +++ b/Mastodon/Persistence/Extension/MastodonMention.swift @@ -0,0 +1,21 @@ +// +// MastodonMention.swift +// Mastodon +// +// Created by MainasuK on 2022-1-17. +// + +import Foundation +import CoreDataStack +import MastodonSDK + +extension MastodonMention { + public convenience init(mention: Mastodon.Entity.Mention) { + self.init( + id: mention.id, + username: mention.username, + acct: mention.acct, + url: mention.url + ) + } +} diff --git a/Mastodon/Persistence/Extension/MastodonUser+Property.swift b/Mastodon/Persistence/Extension/MastodonUser+Property.swift new file mode 100644 index 000000000..cebe7f8af --- /dev/null +++ b/Mastodon/Persistence/Extension/MastodonUser+Property.swift @@ -0,0 +1,39 @@ +// +// MastodonUser+Property.swift +// Mastodon +// +// Created by MainasuK on 2022-1-11. +// + +import Foundation +import CoreDataStack +import MastodonSDK + +extension MastodonUser.Property { + init(entity: Mastodon.Entity.Account, domain: String, networkDate: Date) { + self.init( + identifier: entity.id + "@" + domain, + domain: domain, + id: entity.id, + acct: entity.acct, + username: entity.username, + displayName: entity.displayName, + avatar: entity.avatar, + avatarStatic: entity.avatarStatic, + header: entity.header, + headerStatic: entity.headerStatic, + note: entity.note, + url: entity.url, + statusesCount: Int64(entity.statusesCount), + followingCount: Int64(entity.followingCount), + followersCount: Int64(entity.followersCount), + locked: entity.locked, + bot: entity.bot ?? false, + suspended: entity.suspended ?? false, + createdAt: entity.createdAt, + updatedAt: networkDate, + emojis: entity.mastodonEmojis, + fields: entity.mastodonFields + ) + } +} diff --git a/Mastodon/Persistence/Extension/Notification+Property.swift b/Mastodon/Persistence/Extension/Notification+Property.swift new file mode 100644 index 000000000..4d125bd52 --- /dev/null +++ b/Mastodon/Persistence/Extension/Notification+Property.swift @@ -0,0 +1,29 @@ +// +// Notification+Property.swift +// Mastodon +// +// Created by MainasuK on 2022-1-21. +// + +import Foundation +import CoreDataStack +import MastodonSDK +import class CoreDataStack.Notification + +extension Notification.Property { + public init( + entity: Mastodon.Entity.Notification, + domain: String, + userID: MastodonUser.ID, + networkDate: Date + ) { + self.init( + id: entity.id, + typeRaw: entity.type.rawValue, + domain: domain, + userID: userID, + createAt: entity.createdAt, + updatedAt: networkDate + ) + } +} diff --git a/Mastodon/Persistence/Extension/Poll+Property.swift b/Mastodon/Persistence/Extension/Poll+Property.swift new file mode 100644 index 000000000..f703d8e53 --- /dev/null +++ b/Mastodon/Persistence/Extension/Poll+Property.swift @@ -0,0 +1,30 @@ +// +// MastodonPoll.swift +// +// +// Created by MainasuK on 2021-12-9. +// + +import Foundation +import CoreDataStack +import MastodonSDK + +extension Poll.Property { + public init( + entity: Mastodon.Entity.Poll, + domain: String, + networkDate: Date + ) { + self.init( + domain: domain, + id: entity.id, + expiresAt: entity.expiresAt, + expired: entity.expired, + multiple: entity.multiple, + votesCount: Int64(entity.votesCount), + votersCount: Int64(entity.votersCount ?? 0), + createdAt: networkDate, + updatedAt: networkDate + ) + } +} diff --git a/Mastodon/Persistence/Extension/PollOption+Property.swift b/Mastodon/Persistence/Extension/PollOption+Property.swift new file mode 100644 index 000000000..4fa62979e --- /dev/null +++ b/Mastodon/Persistence/Extension/PollOption+Property.swift @@ -0,0 +1,26 @@ +// +// MastodonPollOption+Property.swift +// +// +// Created by MainasuK on 2021-12-9. +// + +import Foundation +import MastodonSDK +import CoreDataStack + +extension PollOption.Property { + public init( + index: Int, + entity: Mastodon.Entity.Poll.Option, + networkDate: Date + ) { + self.init( + index: Int64(index), + title: entity.title, + votesCount: Int64(entity.votesCount ?? 0), + createdAt: networkDate, + updatedAt: networkDate + ) + } +} diff --git a/Mastodon/Persistence/Extension/Status+Property.swift b/Mastodon/Persistence/Extension/Status+Property.swift new file mode 100644 index 000000000..c4508a997 --- /dev/null +++ b/Mastodon/Persistence/Extension/Status+Property.swift @@ -0,0 +1,91 @@ +// +// Status+Property.swift +// Mastodon +// +// Created by MainasuK on 2022-1-11. +// + +import Foundation +import CoreGraphics +import CoreDataStack +import MastodonSDK + +extension Status.Property { + init(entity: Mastodon.Entity.Status, domain: String, networkDate: Date) { + self.init( + identifier: entity.id + "@" + domain, + domain: domain, + id: entity.id, + uri: entity.uri, + createdAt: entity.createdAt, + content: entity.content ?? "", + visibility: entity.mastodonVisibility, + sensitive: entity.sensitive ?? false, + spoilerText: entity.spoilerText, + reblogsCount: Int64(entity.reblogsCount), + favouritesCount: Int64(entity.favouritesCount), + repliesCount: Int64(entity.repliesCount ?? 0), + url: entity.url, + inReplyToID: entity.inReplyToID, + inReplyToAccountID: entity.inReplyToAccountID, + language: entity.language, + text: entity.text, + updatedAt: networkDate, + deletedAt: nil, + attachments: entity.mastodonAttachments, + emojis: entity.mastodonEmojis, + mentions: entity.mastodonMentions + ) + } +} + +extension Mastodon.Entity.Status { + public var mastodonVisibility: MastodonVisibility { + let rawValue = visibility?.rawValue ?? "" + return MastodonVisibility(rawValue: rawValue) ?? ._other(rawValue) + } +} + +extension Mastodon.Entity.Status { + public var mastodonAttachments: [MastodonAttachment] { + guard let mediaAttachments = mediaAttachments else { return [] } + + let attachments = mediaAttachments.compactMap { media -> MastodonAttachment? in + guard let kind = media.attachmentKind, + let meta = media.meta, + let original = meta.original, + let width = original.width, // audio has width/height + let height = original.height + else { return nil } + + let durationMS: Int? = original.duration.flatMap { Int($0 * 1000) } + return MastodonAttachment( + id: media.id, + kind: kind, + size: CGSize(width: width, height: height), + focus: nil, // TODO: + blurhash: media.blurhash, + assetURL: media.url, + previewURL: media.previewURL, + textURL: media.textURL, + durationMS: durationMS, + altDescription: media.description + ) + } + + return attachments + } +} + +extension Mastodon.Entity.Attachment { + public var attachmentKind: MastodonAttachment.Kind? { + switch type { + case .unknown: return nil + case .image: return .image + case .gifv: return .gifv + case .video: return .video + case .audio: return .audio + case ._other: return nil + } + } +} diff --git a/Mastodon/Persistence/Extension/Tag+Property.swift b/Mastodon/Persistence/Extension/Tag+Property.swift new file mode 100644 index 000000000..633f7bddf --- /dev/null +++ b/Mastodon/Persistence/Extension/Tag+Property.swift @@ -0,0 +1,44 @@ +// +// Tag+Property.swift +// Mastodon +// +// Created by MainasuK on 2022-1-20. +// + +import Foundation +import CoreDataStack +import MastodonSDK + +extension Tag.Property { + public init( + entity: Mastodon.Entity.Tag, + domain: String, + networkDate: Date + ) { + self.init( + identifier: UUID(), + domain: domain, + createAt: networkDate, + updatedAt: networkDate, + name: entity.name, + url: entity.url, + histories: { + guard let histories = entity.history else { return [] } + let result: [MastodonTagHistory] = histories.map { history in + return MastodonTagHistory(entity: history) + } + return result + }() + ) + } +} + +extension MastodonTagHistory { + public convenience init(entity: Mastodon.Entity.History) { + self.init( + day: entity.day, + uses: entity.uses, + accounts: entity.accounts + ) + } +} diff --git a/Mastodon/Persistence/Persistence+MastodonUser.swift b/Mastodon/Persistence/Persistence+MastodonUser.swift new file mode 100644 index 000000000..1406f75aa --- /dev/null +++ b/Mastodon/Persistence/Persistence+MastodonUser.swift @@ -0,0 +1,161 @@ +// +// Persistence+MastodonUser.swift +// Persistence+MastodonUser +// +// Created by Cirno MainasuK on 2021-8-18. +// Copyright © 2021 Twidere. All rights reserved. +// + +import CoreData +import CoreDataStack +import Foundation +import MastodonSDK +import os.log + +extension Persistence.MastodonUser { + + public struct PersistContext { + public let domain: String + public let entity: Mastodon.Entity.Account + public let cache: Persistence.PersistCache<MastodonUser>? + public let networkDate: Date + public let log = OSLog.api + + public init( + domain: String, + entity: Mastodon.Entity.Account, + cache: Persistence.PersistCache<MastodonUser>?, + networkDate: Date + ) { + self.domain = domain + self.entity = entity + self.cache = cache + self.networkDate = networkDate + } + } + + public struct PersistResult { + public let user: MastodonUser + public let isNewInsertion: Bool + + public init( + user: MastodonUser, + isNewInsertion: Bool + ) { + self.user = user + self.isNewInsertion = isNewInsertion + } + } + + public static func createOrMerge( + in managedObjectContext: NSManagedObjectContext, + context: PersistContext + ) -> PersistResult { + if let oldMastodonUser = fetch(in: managedObjectContext, context: context) { + merge(mastodonUser: oldMastodonUser, context: context) + return PersistResult(user: oldMastodonUser, isNewInsertion: false) + } else { + let user = create(in: managedObjectContext, context: context) + return PersistResult(user: user, isNewInsertion: true) + } + } + +} + +extension Persistence.MastodonUser { + + public static func fetch( + in managedObjectContext: NSManagedObjectContext, + context: PersistContext + ) -> MastodonUser? { + if let cache = context.cache { + return cache.dictionary[context.entity.id] + } else { + let request = MastodonUser.sortedFetchRequest + request.predicate = MastodonUser.predicate( + domain: context.domain, + id: context.entity.id + ) + request.fetchLimit = 1 + do { + return try managedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + } + } + + @discardableResult + public static func create( + in managedObjectContext: NSManagedObjectContext, + context: PersistContext + ) -> MastodonUser { + let property = MastodonUser.Property( + entity: context.entity, + domain: context.domain, + networkDate: context.networkDate + ) + let user = MastodonUser.insert(into: managedObjectContext, property: property) + return user + } + + public static func merge( + mastodonUser user: MastodonUser, + context: PersistContext + ) { + guard context.networkDate > user.updatedAt else { return } + let property = MastodonUser.Property( + entity: context.entity, + domain: context.domain, + networkDate: context.networkDate + ) + user.update(property: property) + } + + private static func update( + mastodonUser user: MastodonUser, + context: PersistContext + ) { + // TODO: + } // end func update + +} + +extension Persistence.MastodonUser { + public struct RelationshipContext { + public let entity: Mastodon.Entity.Relationship + public let me: MastodonUser + public let networkDate: Date + public let log = OSLog.api + + public init( + entity: Mastodon.Entity.Relationship, + me: MastodonUser, + networkDate: Date + ) { + self.entity = entity + self.me = me + self.networkDate = networkDate + } + } + + public static func update( + mastodonUser user: MastodonUser, + context: RelationshipContext + ) { + guard context.entity.id != context.me.id else { return } // not update relationship for self + + let relationship = context.entity + let me = context.me + + user.update(isFollowing: relationship.following, by: me) + relationship.requested.flatMap { user.update(isFollowRequested: $0, by: me) } + // relationship.endorsed.flatMap { user.update(isEndorsed: $0, by: me) } + me.update(isFollowing: relationship.followedBy, by: user) + relationship.muting.flatMap { user.update(isMuting: $0, by: me) } + user.update(isBlocking: relationship.blocking, by: me) + relationship.domainBlocking.flatMap { user.update(isDomainBlocking: $0, by: me) } + relationship.blockedBy.flatMap { me.update(isBlocking: $0, by: user) } + } +} diff --git a/Mastodon/Persistence/Persistence+Notification.swift b/Mastodon/Persistence/Persistence+Notification.swift new file mode 100644 index 000000000..b8c2f27fd --- /dev/null +++ b/Mastodon/Persistence/Persistence+Notification.swift @@ -0,0 +1,199 @@ +// +// Persistence+Notification.swift +// Mastodon +// +// Created by MainasuK on 2022-1-21. +// + +import CoreData +import CoreDataStack +import Foundation +import MastodonSDK +import os.log +import class CoreDataStack.Notification + +extension Persistence.Notification { + + public struct PersistContext { + public let domain: String + public let entity: Mastodon.Entity.Notification + public let me: MastodonUser + public let networkDate: Date + public let log = OSLog.api + + public init( + domain: String, + entity: Mastodon.Entity.Notification, + me: MastodonUser, + networkDate: Date + ) { + self.domain = domain + self.entity = entity + self.me = me + self.networkDate = networkDate + } + } + + public struct PersistResult { + public let notification: Notification + public let isNewInsertion: Bool + + public init( + notification: Notification, + isNewInsertion: Bool + ) { + self.notification = notification + self.isNewInsertion = isNewInsertion + } + } + + public static func createOrMerge( + in managedObjectContext: NSManagedObjectContext, + context: PersistContext + ) -> PersistResult { + + if let old = fetch(in: managedObjectContext, context: context) { + merge(object: old, context: context) + return PersistResult( + notification: old, + isNewInsertion: false + ) + } else { + let accountResult = Persistence.MastodonUser.createOrMerge( + in: managedObjectContext, + context: Persistence.MastodonUser.PersistContext( + domain: context.domain, + entity: context.entity.account, + cache: nil, + networkDate: context.networkDate + ) + ) + let account = accountResult.user + + let status: Status? = { + guard let entity = context.entity.status else { return nil } + let result = Persistence.Status.createOrMerge( + in: managedObjectContext, + context: Persistence.Status.PersistContext( + domain: context.domain, + entity: entity, + me: context.me, + statusCache: nil, + userCache: nil, + networkDate: context.networkDate + ) + ) + return result.status + }() + + let relationship = Notification.Relationship( + account: account, + status: status + ) + + let object = create( + in: managedObjectContext, + context: context, + relationship: relationship + ) + + return PersistResult( + notification: object, + isNewInsertion: true + ) + } + } + +} + +extension Persistence.Notification { + + public static func fetch( + in managedObjectContext: NSManagedObjectContext, + context: PersistContext + ) -> Notification? { + let request = Notification.sortedFetchRequest + request.predicate = Notification.predicate( + domain: context.me.domain, + userID: context.me.id, + id: context.entity.id + ) + request.fetchLimit = 1 + do { + return try managedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + } + + @discardableResult + public static func create( + in managedObjectContext: NSManagedObjectContext, + context: PersistContext, + relationship: Notification.Relationship + ) -> Notification { + let property = Notification.Property( + entity: context.entity, + domain: context.me.domain, + userID: context.me.id, + networkDate: context.networkDate + ) + let object = Notification.insert( + into: managedObjectContext, + property: property, + relationship: relationship + ) + update(object: object, context: context) + return object + } + + public static func merge( + object: Notification, + context: PersistContext + ) { + guard context.networkDate > object.updatedAt else { return } + let property = Notification.Property( + entity: context.entity, + domain: context.me.domain, + userID: context.me.id, + networkDate: context.networkDate + ) + object.update(property: property) + + if let status = object.status, let entity = context.entity.status { + let property = Status.Property( + entity: entity, + domain: context.domain, + networkDate: context.networkDate + ) + status.update(property: property) + } + + let accountProperty = MastodonUser.Property( + entity: context.entity.account, + domain: context.domain, + networkDate: context.networkDate + ) + object.account.update(property: accountProperty) + + if let author = object.status, let entity = context.entity.status { + let property = Status.Property( + entity: entity, + domain: context.domain, + networkDate: context.networkDate + ) + author.update(property: property) + } + + update(object: object, context: context) + } + + private static func update( + object: Notification, + context: PersistContext + ) { + // do nothing + } + +} diff --git a/Mastodon/Persistence/Persistence+Poll.swift b/Mastodon/Persistence/Persistence+Poll.swift new file mode 100644 index 000000000..1d6802aab --- /dev/null +++ b/Mastodon/Persistence/Persistence+Poll.swift @@ -0,0 +1,180 @@ +// +// Persistence+MastodonPoll.swift +// +// +// Created by MainasuK on 2021-12-9. +// + +import CoreData +import CoreDataStack +import Foundation +import MastodonSDK +import os.log + +extension Persistence.Poll { + + public struct PersistContext { + public let domain: String + public let entity: Mastodon.Entity.Poll + public let me: MastodonUser? + public let networkDate: Date + public let log = OSLog.api + + public init( + domain: String, + entity: Mastodon.Entity.Poll, + me: MastodonUser?, + networkDate: Date + ) { + self.domain = domain + self.entity = entity + self.me = me + self.networkDate = networkDate + } + } + + public struct PersistResult { + public let poll: Poll + public let isNewInsertion: Bool + + public init( + poll: Poll, + isNewInsertion: Bool + ) { + self.poll = poll + self.isNewInsertion = isNewInsertion + } + + #if DEBUG + public let logger = Logger(subsystem: "Persistence.MastodonPoll.PersistResult", category: "Persist") + public func log() { + let pollInsertionFlag = isNewInsertion ? "+" : "-" + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [\(pollInsertionFlag)](\(poll.id)):") + } + #endif + } + + public static func createOrMerge( + in managedObjectContext: NSManagedObjectContext, + context: PersistContext + ) -> PersistResult { + + if let old = fetch(in: managedObjectContext, context: context) { + merge(poll: old, context: context) + return PersistResult( + poll: old, + isNewInsertion: false + ) + } else { + let options: [PollOption] = context.entity.options.enumerated().map { i, entity in + let optionResult = Persistence.PollOption.persist( + in: managedObjectContext, + context: Persistence.PollOption.PersistContext( + index: i, + entity: entity, + me: context.me, + networkDate: context.networkDate + ) + ) + return optionResult.option + } + + let poll = create( + in: managedObjectContext, + context: context + ) + poll.attach(options: options) + + return PersistResult( + poll: poll, + isNewInsertion: true + ) + } + } + +} + +extension Persistence.Poll { + + public static func fetch( + in managedObjectContext: NSManagedObjectContext, + context: PersistContext + ) -> Poll? { + let request = Poll.sortedFetchRequest + request.predicate = Poll.predicate(domain: context.domain, id: context.entity.id) + request.fetchLimit = 1 + do { + return try managedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + } + + @discardableResult + public static func create( + in managedObjectContext: NSManagedObjectContext, + context: PersistContext + ) -> Poll { + let property = Poll.Property( + entity: context.entity, + domain: context.domain, + networkDate: context.networkDate + ) + let poll = Poll.insert( + into: managedObjectContext, + property: property + ) + update(poll: poll, context: context) + return poll + } + + public static func merge( + poll: Poll, + context: PersistContext + ) { + guard context.networkDate > poll.updatedAt else { return } + let property = Poll.Property( + entity: context.entity, + domain: context.domain, + networkDate: context.networkDate + ) + poll.update(property: property) + update(poll: poll, context: context) + } + + public static func update( + poll: Poll, + context: PersistContext + ) { + let optionEntities = context.entity.options + let options = poll.options.sorted(by: { $0.index < $1.index }) + for (option, entity) in zip(options, optionEntities) { + Persistence.PollOption.merge( + option: option, + context: Persistence.PollOption.PersistContext( + index: Int(option.index), + entity: entity, + me: context.me, + networkDate: context.networkDate + ) + ) + } // end for in + + if let me = context.me { + if let voted = context.entity.voted { + poll.update(voted: voted, by: me) + } + + let ownVotes = context.entity.ownVotes ?? [] + for option in options { + let index = Int(option.index) + let isVote = ownVotes.contains(index) + option.update(voted: isVote, by: me) + } + } + + poll.update(updatedAt: context.networkDate) + } + +} diff --git a/Mastodon/Persistence/Persistence+PollOption.swift b/Mastodon/Persistence/Persistence+PollOption.swift new file mode 100644 index 000000000..1e284ac72 --- /dev/null +++ b/Mastodon/Persistence/Persistence+PollOption.swift @@ -0,0 +1,99 @@ +// +// Persistence+MastodonPollOption.swift +// +// +// Created by MainasuK on 2021-12-9. +// + +import CoreData +import CoreDataStack +import Foundation +import MastodonSDK +import os.log + +extension Persistence.PollOption { + + public struct PersistContext { + public let index: Int + public let entity: Mastodon.Entity.Poll.Option + public let me: MastodonUser? + public let networkDate: Date + public let log = OSLog.api + + public init( + index: Int, + entity: Mastodon.Entity.Poll.Option, + me: MastodonUser?, + networkDate: Date + ) { + self.index = index + self.entity = entity + self.me = me + self.networkDate = networkDate + } + } + + public struct PersistResult { + public let option: PollOption + public let isNewInsertion: Bool + + public init( + option: PollOption, + isNewInsertion: Bool + ) { + self.option = option + self.isNewInsertion = isNewInsertion + } + } + + // the bare Poll.Option entity not supports merge from entity. + // use merge entry on MastodonPoll with exists option objects + public static func persist( + in managedObjectContext: NSManagedObjectContext, + context: PersistContext + ) -> PersistResult { + let option = create(in: managedObjectContext, context: context) + return PersistResult(option: option, isNewInsertion: true) + } + +} + +extension Persistence.PollOption { + + @discardableResult + public static func create( + in managedObjectContext: NSManagedObjectContext, + context: PersistContext + ) -> PollOption { + let property = PollOption.Property( + index: context.index, + entity: context.entity, + networkDate: context.networkDate + ) + let option = PollOption.insert(into: managedObjectContext, property: property) + update(option: option, context: context) + return option + } + + public static func merge( + option: PollOption, + context: PersistContext + ) { + guard context.networkDate > option.updatedAt else { return } + let property = PollOption.Property( + index: context.index, + entity: context.entity, + networkDate: context.networkDate + ) + option.update(property: property) + update(option: option, context: context) + } + + private static func update( + option: PollOption, + context: PersistContext + ) { + // Do nothing + } // end func update + +} diff --git a/Mastodon/Persistence/Persistence+SearchHistory.swift b/Mastodon/Persistence/Persistence+SearchHistory.swift new file mode 100644 index 000000000..58d4c8fb1 --- /dev/null +++ b/Mastodon/Persistence/Persistence+SearchHistory.swift @@ -0,0 +1,116 @@ +// +// Persistence+SearchHistory.swift +// Mastodon +// +// Created by MainasuK on 2022-1-20. +// + +import CoreData +import CoreDataStack +import Foundation +import MastodonSDK +import os.log + +extension Persistence.SearchHistory { + + public struct PersistContext { + public let entity: Entity + public let me: MastodonUser + public let now: Date + public let log = OSLog.api + + public init( + entity: Entity, + me: MastodonUser, + now: Date + ) { + self.entity = entity + self.me = me + self.now = now + } + + public enum Entity: Hashable { + case user(MastodonUser) + case hashtag(Tag) + } + } + + public struct PersistResult { + public let searchHistory: SearchHistory + public let isNewInsertion: Bool + + public init( + searchHistory: SearchHistory, + isNewInsertion: Bool + ) { + self.searchHistory = searchHistory + self.isNewInsertion = isNewInsertion + } + } + + public static func createOrMerge( + in managedObjectContext: NSManagedObjectContext, + context: PersistContext + ) -> PersistResult { + if let old = fetch(in: managedObjectContext, context: context) { + update(searchHistory: old, context: context) + return PersistResult(searchHistory: old, isNewInsertion: false) + } else { + let object = create(in: managedObjectContext, context: context) + return PersistResult(searchHistory: object, isNewInsertion: true) + } + } + +} + +extension Persistence.SearchHistory { + + public static func fetch( + in managedObjectContext: NSManagedObjectContext, + context: PersistContext + ) -> SearchHistory? { + switch context.entity { + case .user(let user): + return user.findSearchHistory(for: context.me) + case .hashtag(let hashtag): + return hashtag.findSearchHistory(for: context.me) + } + } + + @discardableResult + public static func create( + in managedObjectContext: NSManagedObjectContext, + context: PersistContext + ) -> SearchHistory { + let property = SearchHistory.Property( + identifier: UUID(), + domain: context.me.domain, + userID: context.me.id, + createAt: context.now, + updatedAt: context.now + ) + let relationship: SearchHistory.Relationship = { + switch context.entity { + case .user(let user): + return SearchHistory.Relationship(account: user, hashtag: nil, status: nil) + case .hashtag(let hashtag): + return SearchHistory.Relationship(account: nil, hashtag: hashtag, status: nil) + } + }() + let searchHistory = SearchHistory.insert( + into: managedObjectContext, + property: property, + relationship: relationship + ) + update(searchHistory: searchHistory, context: context) + return searchHistory + } + + private static func update( + searchHistory: SearchHistory, + context: PersistContext + ) { + searchHistory.update(updatedAt: context.now) + } + +} diff --git a/Mastodon/Persistence/Persistence+Status.swift b/Mastodon/Persistence/Persistence+Status.swift new file mode 100644 index 000000000..b20df1496 --- /dev/null +++ b/Mastodon/Persistence/Persistence+Status.swift @@ -0,0 +1,220 @@ +// +// Persistence+Status.swift +// Persistence+Status +// +// Created by Cirno MainasuK on 2021-8-27. +// Copyright © 2021 Twidere. All rights reserved. +// + +import CoreData +import CoreDataStack +import Foundation +import MastodonSDK +import os.log + +extension Persistence.Status { + + public struct PersistContext { + public let domain: String + public let entity: Mastodon.Entity.Status + public let me: MastodonUser? + public let statusCache: Persistence.PersistCache<Status>? + public let userCache: Persistence.PersistCache<MastodonUser>? + public let networkDate: Date + public let log = OSLog.api + + public init( + domain: String, + entity: Mastodon.Entity.Status, + me: MastodonUser?, + statusCache: Persistence.PersistCache<Status>?, + userCache: Persistence.PersistCache<MastodonUser>?, + networkDate: Date + ) { + self.domain = domain + self.entity = entity + self.me = me + self.statusCache = statusCache + self.userCache = userCache + self.networkDate = networkDate + } + } + + public struct PersistResult { + public let status: Status + public let isNewInsertion: Bool + public let isNewInsertionAuthor: Bool + + public init( + status: Status, + isNewInsertion: Bool, + isNewInsertionAuthor: Bool + ) { + self.status = status + self.isNewInsertion = isNewInsertion + self.isNewInsertionAuthor = isNewInsertionAuthor + } + + #if DEBUG + public let logger = Logger(subsystem: "Persistence.Status.PersistResult", category: "Persist") + public func log() { + let statusInsertionFlag = isNewInsertion ? "+" : "-" + let authorInsertionFlag = isNewInsertionAuthor ? "+" : "-" + let contentPreview = status.content.prefix(32).replacingOccurrences(of: "\n", with: " ") + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [\(statusInsertionFlag)](\(status.id))[\(authorInsertionFlag)](\(status.author.id))@\(status.author.username): \(contentPreview)") + } + #endif + } + + public static func createOrMerge( + in managedObjectContext: NSManagedObjectContext, + context: PersistContext + ) -> PersistResult { + + let reblog = context.entity.reblog.flatMap { entity -> Status in + let result = createOrMerge( + in: managedObjectContext, + context: PersistContext( + domain: context.domain, + entity: entity, + me: context.me, + statusCache: context.statusCache, + userCache: context.userCache, + networkDate: context.networkDate + ) + ) + return result.status + } + + if let oldStatus = fetch(in: managedObjectContext, context: context) { + merge(mastodonStatus: oldStatus, context: context) + return PersistResult( + status: oldStatus, + isNewInsertion: false, + isNewInsertionAuthor: false + ) + } else { + let poll: Poll? = { + guard let entity = context.entity.poll else { return nil } + let result = Persistence.Poll.createOrMerge( + in: managedObjectContext, + context: Persistence.Poll.PersistContext( + domain: context.domain, + entity: entity, + me: context.me, + networkDate: context.networkDate + ) + ) + return result.poll + }() + + let authorResult = Persistence.MastodonUser.createOrMerge( + in: managedObjectContext, + context: Persistence.MastodonUser.PersistContext( + domain: context.domain, + entity: context.entity.account, + cache: context.userCache, + networkDate: context.networkDate + ) + ) + let author = authorResult.user + + let relationship = Status.Relationship( + author: author, + reblog: reblog, + poll: poll + ) + let status = create( + in: managedObjectContext, + context: context, + relationship: relationship + ) + + return PersistResult( + status: status, + isNewInsertion: true, + isNewInsertionAuthor: authorResult.isNewInsertion + ) + } + } + +} + +extension Persistence.Status { + + public static func fetch( + in managedObjectContext: NSManagedObjectContext, + context: PersistContext + ) -> Status? { + if let cache = context.statusCache { + return cache.dictionary[context.entity.id] + } else { + let request = Status.sortedFetchRequest + request.predicate = Status.predicate(domain: context.domain, id: context.entity.id) + request.fetchLimit = 1 + do { + return try managedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + } + } + + @discardableResult + public static func create( + in managedObjectContext: NSManagedObjectContext, + context: PersistContext, + relationship: Status.Relationship + ) -> Status { + let property = Status.Property( + entity: context.entity, + domain: context.domain, + networkDate: context.networkDate + ) + let status = Status.insert( + into: managedObjectContext, + property: property, + relationship: relationship + ) + update(status: status, context: context) + return status + } + + public static func merge( + mastodonStatus status: Status, + context: PersistContext + ) { + guard context.networkDate > status.updatedAt else { return } + let property = Status.Property( + entity: context.entity, + domain: context.domain, + networkDate: context.networkDate + ) + status.update(property: property) + if let poll = status.poll, let entity = context.entity.poll { + Persistence.Poll.merge( + poll: poll, + context: Persistence.Poll.PersistContext( + domain: context.domain, + entity: entity, + me: context.me, + networkDate: context.networkDate + ) + ) + } + update(status: status, context: context) + } + + private static func update( + status: Status, + context: PersistContext + ) { + // update friendships + if let user = context.me { + context.entity.reblogged.flatMap { status.update(reblogged: $0, by: user) } + context.entity.favourited.flatMap { status.update(liked: $0, by: user) } + } + } + +} diff --git a/Mastodon/Persistence/Persistence+Tag.swift b/Mastodon/Persistence/Persistence+Tag.swift new file mode 100644 index 000000000..7092a52cd --- /dev/null +++ b/Mastodon/Persistence/Persistence+Tag.swift @@ -0,0 +1,130 @@ +// +// Persistence+Tag.swift +// Mastodon +// +// Created by MainasuK on 2022-1-20. +// + +import CoreData +import CoreDataStack +import Foundation +import MastodonSDK +import os.log + +extension Persistence.Tag { + + public struct PersistContext { + public let domain: String + public let entity: Mastodon.Entity.Tag + public let me: MastodonUser? + public let networkDate: Date + public let log = OSLog.api + + public init( + domain: String, + entity: Mastodon.Entity.Tag, + me: MastodonUser?, + networkDate: Date + ) { + self.domain = domain + self.entity = entity + self.me = me + self.networkDate = networkDate + } + } + + public struct PersistResult { + public let tag: Tag + public let isNewInsertion: Bool + + public init( + tag: Tag, + isNewInsertion: Bool + ) { + self.tag = tag + self.isNewInsertion = isNewInsertion + } + } + + public static func createOrMerge( + in managedObjectContext: NSManagedObjectContext, + context: PersistContext + ) -> PersistResult { + if let old = fetch(in: managedObjectContext, context: context) { + merge(tag: old, context: context) + return PersistResult( + tag: old, + isNewInsertion: false + ) + } else { + let object = create( + in: managedObjectContext, + context: context + ) + + return PersistResult( + tag: object, + isNewInsertion: false + ) + } + } + +} + +extension Persistence.Tag { + + public static func fetch( + in managedObjectContext: NSManagedObjectContext, + context: PersistContext + ) -> Tag? { + let request = Tag.sortedFetchRequest + request.predicate = Tag.predicate(domain: context.domain, name: context.entity.name) + request.fetchLimit = 1 + do { + return try managedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + } + + @discardableResult + public static func create( + in managedObjectContext: NSManagedObjectContext, + context: PersistContext + ) -> Tag { + let property = Tag.Property( + entity: context.entity, + domain: context.domain, + networkDate: context.networkDate + ) + let object = Tag.insert( + into: managedObjectContext, + property: property + ) + update(tag: object, context: context) + return object + } + + public static func merge( + tag: Tag, + context: PersistContext + ) { + guard context.networkDate > tag.updatedAt else { return } + let property = Tag.Property( + entity: context.entity, + domain: context.domain, + networkDate: context.networkDate + ) + tag.update(property: property) + update(tag: tag, context: context) + } + + private static func update( + tag: Tag, + context: PersistContext + ) { + tag.update(updatedAt: context.networkDate) + } + +} diff --git a/Mastodon/Persistence/Persistence.swift b/Mastodon/Persistence/Persistence.swift new file mode 100644 index 000000000..350b603cc --- /dev/null +++ b/Mastodon/Persistence/Persistence.swift @@ -0,0 +1,33 @@ +// +// Persistence.swift +// Persistence +// +// Created by Cirno MainasuK on 2021-8-18. +// Copyright © 2021 Twidere. All rights reserved. +// + +import Foundation + +public enum Persistence { } + + +extension Persistence { + public enum MastodonUser { } + public enum Status { } + public enum Poll { } + public enum PollOption { } + public enum Tag { } + public enum SearchHistory { } + public enum Notification { } +} + +extension Persistence { + public class PersistCache<T> { + var dictionary: [String : T] = [:] + + public init(dictionary: [String : T] = [:]) { + self.dictionary = dictionary + } + } +} + diff --git a/Mastodon/Persistence/Protocol/MastodonEmojiContainer.swift b/Mastodon/Persistence/Protocol/MastodonEmojiContainer.swift new file mode 100644 index 000000000..e3bb62f63 --- /dev/null +++ b/Mastodon/Persistence/Protocol/MastodonEmojiContainer.swift @@ -0,0 +1,26 @@ +// +// MastodonEmojiContainer.swift +// MastodonEmojiContainer +// +// Created by Cirno MainasuK on 2021-9-3. +// Copyright © 2021 Twidere. All rights reserved. +// + +import Foundation +import MastodonSDK +import CoreDataStack + +public protocol MastodonEmojiContainer { + var emojis: [Mastodon.Entity.Emoji]? { get } +} + +extension MastodonEmojiContainer { + public var mastodonEmojis: [MastodonEmoji] { + return emojis.flatMap { emojis in + emojis.map { MastodonEmoji(emoji: $0) } + } ?? [] + } +} + +extension Mastodon.Entity.Account: MastodonEmojiContainer { } +extension Mastodon.Entity.Status: MastodonEmojiContainer { } diff --git a/Mastodon/Persistence/Protocol/MastodonFieldContainer.swift b/Mastodon/Persistence/Protocol/MastodonFieldContainer.swift new file mode 100644 index 000000000..fe1d29941 --- /dev/null +++ b/Mastodon/Persistence/Protocol/MastodonFieldContainer.swift @@ -0,0 +1,25 @@ +// +// MastodonFieldContainer.swift +// MastodonFieldContainer +// +// Created by Cirno MainasuK on 2021-8-18. +// Copyright © 2021 Twidere. All rights reserved. +// + +import Foundation +import CoreDataStack +import MastodonSDK + +public protocol MastodonFieldContainer { + var fields: [Mastodon.Entity.Field]? { get } +} + +extension MastodonFieldContainer { + public var mastodonFields: [MastodonField] { + return fields.flatMap { fields in + fields.map { MastodonField(field: $0) } + } ?? [] + } +} + +extension Mastodon.Entity.Account: MastodonFieldContainer { } diff --git a/Mastodon/Persistence/Protocol/MastodonMentionContainer.swift b/Mastodon/Persistence/Protocol/MastodonMentionContainer.swift new file mode 100644 index 000000000..75cae7573 --- /dev/null +++ b/Mastodon/Persistence/Protocol/MastodonMentionContainer.swift @@ -0,0 +1,24 @@ +// +// MastodonMentionContainer.swift +// Mastodon +// +// Created by MainasuK on 2022-1-17. +// + +import Foundation +import CoreDataStack +import MastodonSDK + +public protocol MastodonMentionContainer { + var mentions: [Mastodon.Entity.Mention]? { get } +} + +extension MastodonMentionContainer { + public var mastodonMentions: [MastodonMention] { + return mentions.flatMap { mentions in + mentions.map { MastodonMention(mention: $0) } + } ?? [] + } +} + +extension Mastodon.Entity.Status: MastodonMentionContainer { } diff --git a/Mastodon/Protocol/AvatarConfigurableView.swift b/Mastodon/Protocol/AvatarConfigurableView.swift deleted file mode 100644 index d771fa5a9..000000000 --- a/Mastodon/Protocol/AvatarConfigurableView.swift +++ /dev/null @@ -1,113 +0,0 @@ -// -// AvatarConfigurableView.swift -// Mastodon -// -// Created by Cirno MainasuK on 2021-2-4. -// - -import Foundation -import UIKit -import Combine -import AlamofireImage -import FLAnimatedImage - -protocol AvatarConfigurableView { - static var configurableAvatarImageSize: CGSize { get } - static var configurableAvatarImageCornerRadius: CGFloat { get } - var configurableAvatarImageView: FLAnimatedImageView? { get } - func configure(with configuration: AvatarConfigurableViewConfiguration) - func avatarConfigurableView(_ avatarConfigurableView: AvatarConfigurableView, didFinishConfiguration configuration: AvatarConfigurableViewConfiguration) -} - -extension AvatarConfigurableView { - - public func configure(with configuration: AvatarConfigurableViewConfiguration) { - let placeholderImage: UIImage = { - guard let placeholderImage = configuration.placeholderImage else { - #if APP_EXTENSION - let placeholderImage = configuration.placeholderImage ?? UIImage.placeholder(size: Self.configurableAvatarImageSize, color: .systemFill) - if Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 { - return placeholderImage - .af.imageAspectScaled(toFill: Self.configurableAvatarImageSize) - .af.imageRounded(withCornerRadius: Self.configurableAvatarImageCornerRadius, divideRadiusByImageScale: false) - } else { - return placeholderImage.af.imageRoundedIntoCircle() - } - #else - return AppContext.shared.placeholderImageCacheService.image( - color: .systemFill, - size: Self.configurableAvatarImageSize, - cornerRadius: Self.configurableAvatarImageCornerRadius - ) - #endif - } - return placeholderImage - }() - - // accessibility - configurableAvatarImageView?.accessibilityIgnoresInvertColors = true - - defer { - avatarConfigurableView(self, didFinishConfiguration: configuration) - } - - guard let configurableAvatarImageView = configurableAvatarImageView else { - return - } - - // set corner radius (due to GIF won't crop) - configurableAvatarImageView.layer.masksToBounds = true - configurableAvatarImageView.layer.cornerRadius = Self.configurableAvatarImageCornerRadius - configurableAvatarImageView.layer.cornerCurve = Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 ? .continuous :.circular - - // set border - configureLayerBorder(view: configurableAvatarImageView, configuration: configuration) - - configurableAvatarImageView.setImage( - url: configuration.avatarImageURL, - placeholder: placeholderImage, - scaleToSize: Self.configurableAvatarImageSize - ) - } - - func configureLayerBorder(view: UIView, configuration: AvatarConfigurableViewConfiguration) { - guard let borderWidth = configuration.borderWidth, borderWidth > 0, - let borderColor = configuration.borderColor else { - return - } - - view.layer.masksToBounds = true - view.layer.cornerRadius = Self.configurableAvatarImageCornerRadius - view.layer.cornerCurve = .continuous - view.layer.borderColor = borderColor.cgColor - view.layer.borderWidth = borderWidth - } - - func avatarConfigurableView(_ avatarConfigurableView: AvatarConfigurableView, didFinishConfiguration configuration: AvatarConfigurableViewConfiguration) { } - -} - -struct AvatarConfigurableViewConfiguration { - - let avatarImageURL: URL? - let placeholderImage: UIImage? - let borderColor: UIColor? - let borderWidth: CGFloat? - - let keepImageCorner: Bool - - init( - avatarImageURL: URL?, - placeholderImage: UIImage? = nil, - borderColor: UIColor? = nil, - borderWidth: CGFloat? = nil, - keepImageCorner: Bool = false // default clip corner on image - ) { - self.avatarImageURL = avatarImageURL - self.placeholderImage = placeholderImage - self.borderColor = borderColor - self.borderWidth = borderWidth - self.keepImageCorner = keepImageCorner - } - -} diff --git a/Mastodon/Protocol/CellFrameCacheContainer.swift b/Mastodon/Protocol/CellFrameCacheContainer.swift new file mode 100644 index 000000000..b7cefe757 --- /dev/null +++ b/Mastodon/Protocol/CellFrameCacheContainer.swift @@ -0,0 +1,29 @@ +// +// CellFrameCacheContainer.swift +// TwidereX +// +// Created by Cirno MainasuK on 2021-10-13. +// Copyright © 2021 Twidere. All rights reserved. +// + +import UIKit + +protocol CellFrameCacheContainer { + var cellFrameCache: NSCache<NSNumber, NSValue> { get } + + func keyForCache(tableView: UITableView, indexPath: IndexPath) -> NSNumber? +} + +extension CellFrameCacheContainer { + func cacheCellFrame(tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { + guard let key = keyForCache(tableView: tableView, indexPath: indexPath) else { return } + let value = NSValue(cgRect: cell.frame) + cellFrameCache.setObject(value, forKey: key) + } + + func retrieveCellFrame(tableView: UITableView, indexPath: IndexPath) -> CGRect? { + guard let key = keyForCache(tableView: tableView, indexPath: indexPath) else { return nil } + guard let frame = cellFrameCache.object(forKey: key)?.cgRectValue else { return nil } + return frame + } +} diff --git a/Mastodon/Protocol/ContentOffsetAdjustableTimelineViewControllerDelegate.swift b/Mastodon/Protocol/ContentOffsetAdjustableTimelineViewControllerDelegate.swift deleted file mode 100644 index 98160eb42..000000000 --- a/Mastodon/Protocol/ContentOffsetAdjustableTimelineViewControllerDelegate.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// ContentOffsetAdjustableTimelineViewControllerDelegate.swift -// Mastodon -// -// Created by sxiaojian on 2021/2/5. -// - -import UIKit - -protocol ContentOffsetAdjustableTimelineViewControllerDelegate: AnyObject { - func navigationBar() -> UINavigationBar? -} - diff --git a/Mastodon/Protocol/LoadMoreConfigurableTableViewContainer.swift b/Mastodon/Protocol/LoadMoreConfigurableTableViewContainer.swift deleted file mode 100644 index 4f32be54a..000000000 --- a/Mastodon/Protocol/LoadMoreConfigurableTableViewContainer.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// LoadMoreConfigurableTableViewContainer.swift -// Mastodon -// -// Created by sxiaojian on 2021/2/3. -// - -import UIKit -import GameplayKit - -/// The tableView container driven by state machines with "LoadMore" logic -protocol LoadMoreConfigurableTableViewContainer: UIViewController { - - associatedtype BottomLoaderTableViewCell: UITableViewCell - associatedtype LoadingState: GKState - - var loadMoreConfigurableTableView: UITableView { get } - var loadMoreConfigurableStateMachine: GKStateMachine { get } - func handleScrollViewDidScroll(_ scrollView: UIScrollView) -} - -extension LoadMoreConfigurableTableViewContainer { - func handleScrollViewDidScroll(_ scrollView: UIScrollView) { - guard scrollView === loadMoreConfigurableTableView else { return } - - // check if current scroll position is the bottom of table - let contentOffsetY = loadMoreConfigurableTableView.contentOffset.y - let bottomVisiblePageContentOffsetY = loadMoreConfigurableTableView.contentSize.height - (1.5 * loadMoreConfigurableTableView.visibleSize.height) - guard contentOffsetY > bottomVisiblePageContentOffsetY else { - return - } - - let cells = loadMoreConfigurableTableView.visibleCells.compactMap { $0 as? BottomLoaderTableViewCell } - guard let loaderTableViewCell = cells.first else { return } - - if let tabBar = tabBarController?.tabBar, let window = view.window { - let loaderTableViewCellFrameInWindow = loadMoreConfigurableTableView.convert(loaderTableViewCell.frame, to: nil) - let windowHeight = window.frame.height - let loaderAppear = (loaderTableViewCellFrameInWindow.origin.y + 0.8 * loaderTableViewCell.frame.height) < (windowHeight - tabBar.frame.height) - if loaderAppear { - loadMoreConfigurableStateMachine.enter(LoadingState.self) - } else { - // do nothing - } - } else { - loadMoreConfigurableStateMachine.enter(LoadingState.self) - } - } -} diff --git a/Mastodon/Protocol/NamingState.swift b/Mastodon/Protocol/NamingState.swift new file mode 100644 index 000000000..edf6265e8 --- /dev/null +++ b/Mastodon/Protocol/NamingState.swift @@ -0,0 +1,12 @@ +// +// NamingState.swift +// Mastodon +// +// Created by MainasuK on 2022-1-17. +// + +import Foundation + +protocol NamingState { + var name: String { get } +} diff --git a/Mastodon/Protocol/NeedsDependency+AVPlayerViewControllerDelegate.swift b/Mastodon/Protocol/NeedsDependency+AVPlayerViewControllerDelegate.swift deleted file mode 100644 index e52fdc059..000000000 --- a/Mastodon/Protocol/NeedsDependency+AVPlayerViewControllerDelegate.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// NeedsDependency+AVPlayerViewControllerDelegate.swift -// Mastodon -// -// Created by xiaojian sun on 2021/3/10. -// - -import Foundation -import AVKit - -extension NeedsDependency where Self: AVPlayerViewControllerDelegate { - - func handlePlayerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { - context.videoPlaybackService.playerViewModel(for: playerViewController)?.isFullScreenPresentationing = true - } - - func handlePlayerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { - context.videoPlaybackService.playerViewModel(for: playerViewController)?.isFullScreenPresentationing = false - } - -} diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Block.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Block.swift new file mode 100644 index 000000000..a1bf3136f --- /dev/null +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Block.swift @@ -0,0 +1,25 @@ +// +// DataSourceFacade+Block.swift +// Mastodon +// +// Created by MainasuK on 2022-1-24. +// + +import UIKit +import CoreDataStack + +extension DataSourceFacade { + static func responseToUserBlockAction( + dependency: NeedsDependency, + user: ManagedObjectRecord<MastodonUser>, + authenticationBox: MastodonAuthenticationBox + ) async throws { + let selectionFeedbackGenerator = UISelectionFeedbackGenerator() + await selectionFeedbackGenerator.selectionChanged() + + _ = try await dependency.context.apiService.toggleBlock( + user: user, + authenticationBox: authenticationBox + ) + } // end func +} diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Favorite.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Favorite.swift new file mode 100644 index 000000000..a248ed42c --- /dev/null +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Favorite.swift @@ -0,0 +1,26 @@ +// +// DataSourceFacade+Favorite.swift +// Mastodon +// +// Created by MainasuK on 2022-1-21. +// + +import UIKit +import CoreData +import CoreDataStack + +extension DataSourceFacade { + static func responseToStatusFavoriteAction( + provider: DataSourceProvider, + status: ManagedObjectRecord<Status>, + authenticationBox: MastodonAuthenticationBox + ) async throws { + let selectionFeedbackGenerator = UISelectionFeedbackGenerator() + await selectionFeedbackGenerator.selectionChanged() + + _ = try await provider.context.apiService.favorite( + record: status, + authenticationBox: authenticationBox + ) + } +} diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Follow.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Follow.swift new file mode 100644 index 000000000..b4f2362c3 --- /dev/null +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Follow.swift @@ -0,0 +1,25 @@ +// +// DataSourceFacade+Follow.swift +// Mastodon +// +// Created by MainasuK on 2022-1-24. +// + +import UIKit +import CoreDataStack + +extension DataSourceFacade { + static func responseToUserFollowAction( + dependency: NeedsDependency, + user: ManagedObjectRecord<MastodonUser>, + authenticationBox: MastodonAuthenticationBox + ) async throws { + let selectionFeedbackGenerator = UISelectionFeedbackGenerator() + await selectionFeedbackGenerator.selectionChanged() + + _ = try await dependency.context.apiService.toggleFollow( + user: user, + authenticationBox: authenticationBox + ) + } // end func +} diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Hashtag.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Hashtag.swift new file mode 100644 index 000000000..7abde62fe --- /dev/null +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Hashtag.swift @@ -0,0 +1,67 @@ +// +// DataSourceFacade+Hashtag.swift +// Mastodon +// +// Created by MainasuK on 2022-1-20. +// + +import UIKit +import CoreDataStack +import MastodonSDK + +extension DataSourceFacade { + @MainActor + static func coordinateToHashtagScene( + provider: DataSourceProvider, + tag: DataSourceItem.TagKind + ) async { + switch tag { + case .entity(let entity): + await coordinateToHashtagScene(provider: provider, tag: entity) + case .record(let record): + await coordinateToHashtagScene(provider: provider, tag: record) + } + } + + @MainActor + static func coordinateToHashtagScene( + provider: DataSourceProvider, + tag: Mastodon.Entity.Tag + ) async { + let hashtagTimelineViewModel = HashtagTimelineViewModel( + context: provider.context, + hashtag: tag.name + ) + + provider.coordinator.present( + scene: .hashtagTimeline(viewModel: hashtagTimelineViewModel), + from: provider, + transition: .show + ) + } + + @MainActor + static func coordinateToHashtagScene( + provider: DataSourceProvider, + tag: ManagedObjectRecord<Tag> + ) async { + let managedObjectContext = provider.context.managedObjectContext + let _name: String? = try? await managedObjectContext.perform { + guard let tag = tag.object(in: managedObjectContext) else { return nil } + return tag.name + } + + guard let name = _name else { return } + + let hashtagTimelineViewModel = HashtagTimelineViewModel( + context: provider.context, + hashtag: name + ) + + provider.coordinator.present( + scene: .hashtagTimeline(viewModel: hashtagTimelineViewModel), + from: provider, + transition: .show + ) + } +} diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Media.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Media.swift new file mode 100644 index 000000000..329a7f39c --- /dev/null +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Media.swift @@ -0,0 +1,233 @@ +// +// DataSourceFacade+Media.swift +// Mastodon +// +// Created by MainasuK on 2022-1-26. +// + +import UIKit +import CoreDataStack +import MastodonUI +import MastodonLocalization + +extension DataSourceFacade { + + @MainActor + static func coordinateToMediaPreviewScene( + dependency: NeedsDependency & MediaPreviewableViewController, + mediaPreviewItem: MediaPreviewViewModel.PreviewItem, + mediaPreviewTransitionItem: MediaPreviewTransitionItem + ) { + let mediaPreviewViewModel = MediaPreviewViewModel( + context: dependency.context, + item: mediaPreviewItem, + transitionItem: mediaPreviewTransitionItem + ) + dependency.coordinator.present( + scene: .mediaPreview(viewModel: mediaPreviewViewModel), + from: dependency, + transition: .custom(transitioningDelegate: dependency.mediaPreviewTransitionController) + ) + } + +} + +extension DataSourceFacade { + + struct AttachmentPreviewContext { + let containerView: ContainerView + let mediaView: MediaView + let index: Int + + enum ContainerView { + case mediaView(MediaView) + case mediaGridContainerView(MediaGridContainerView) + } + + func thumbnails() async -> [UIImage?] { + switch containerView { + case .mediaView(let mediaView): + let thumbnail = await mediaView.thumbnail() + return [thumbnail] + case .mediaGridContainerView(let mediaGridContainerView): + let thumbnails = await mediaGridContainerView.mediaViews.parallelMap { mediaView in + return await mediaView.thumbnail() + } + return thumbnails + } + } + } + + @MainActor + static func coordinateToMediaPreviewScene( + dependency: NeedsDependency & MediaPreviewableViewController, + status: ManagedObjectRecord<Status>, + previewContext: AttachmentPreviewContext + ) async throws { + let managedObjectContext = dependency.context.managedObjectContext + let attachments: [MastodonAttachment] = try await managedObjectContext.perform { + guard let _status = status.object(in: managedObjectContext) else { return [] } + let status = _status.reblog ?? _status + return status.attachments + } + + let thumbnails = await previewContext.thumbnails() + + let _source: MediaPreviewTransitionItem.Source? = { + switch previewContext.containerView { + case .mediaView(let mediaView): + return .attachment(mediaView) + case .mediaGridContainerView(let mediaGridContainerView): + return .attachments(mediaGridContainerView) + } + }() + guard let source = _source else { + return + } + + let mediaPreviewTransitionItem: MediaPreviewTransitionItem = { + let item = MediaPreviewTransitionItem( + source: source, + previewableViewController: dependency + ) + + let mediaView = previewContext.mediaView + + item.initialFrame = { + let initialFrame = mediaView.superview!.convert(mediaView.frame, to: nil) + assert(initialFrame != .zero) + return initialFrame + }() + + let thumbnail = mediaView.thumbnail() + item.image = thumbnail + + item.aspectRatio = { + if let thumbnail = thumbnail { + return thumbnail.size + } + let index = previewContext.index + guard index < attachments.count else { return nil } + let size = attachments[index].size + return size + }() + + return item + }() + + + let mediaPreviewItem = MediaPreviewViewModel.PreviewItem.attachment(.init( + attachments: attachments, + initialIndex: previewContext.index, + thumbnails: thumbnails + )) + + coordinateToMediaPreviewScene( + dependency: dependency, + mediaPreviewItem: mediaPreviewItem, + mediaPreviewTransitionItem: mediaPreviewTransitionItem + ) + } + +} + +extension DataSourceFacade { + + struct ImagePreviewContext { + let imageView: UIImageView + let containerView: ContainerView + + enum ContainerView { + case profileAvatar(ProfileHeaderView) + case profileBanner(ProfileHeaderView) + } + + func thumbnail() async -> UIImage? { + return await imageView.image + } + } + + @MainActor + static func coordinateToMediaPreviewScene( + dependency: NeedsDependency & MediaPreviewableViewController, + user: ManagedObjectRecord<MastodonUser>, + previewContext: ImagePreviewContext + ) async throws { + let managedObjectContext = dependency.context.managedObjectContext + + var _avatarAssetURL: String? + var _headerAssetURL: String? + + try await managedObjectContext.perform { + guard let user = user.object(in: managedObjectContext) else { return } + _avatarAssetURL = user.avatar + _headerAssetURL = user.header + } + + let thumbnail = await previewContext.thumbnail() + + let source: MediaPreviewTransitionItem.Source = { + switch previewContext.containerView { + case .profileAvatar(let view): return .profileAvatar(view) + case .profileBanner(let view): return .profileBanner(view) + } + }() + + let mediaPreviewTransitionItem: MediaPreviewTransitionItem = { + let item = MediaPreviewTransitionItem( + source: source, + previewableViewController: dependency + ) + + let imageView = previewContext.imageView + item.initialFrame = { + let initialFrame = imageView.superview!.convert(imageView.frame, to: nil) + assert(initialFrame != .zero) + return initialFrame + }() + + item.image = thumbnail + + item.aspectRatio = { + if let thumbnail = thumbnail { + return thumbnail.size + } + return CGSize(width: 100, height: 100) + }() + + item.sourceImageViewCornerRadius = { + switch previewContext.containerView { + case .profileAvatar: + return ProfileHeaderView.avatarImageViewCornerRadius + case .profileBanner: + return 0 + } + }() + + return item + }() + + + let mediaPreviewItem: MediaPreviewViewModel.PreviewItem = { + switch previewContext.containerView { + case .profileAvatar: + return .profileAvatar(.init( + assetURL: _avatarAssetURL, + thumbnail: thumbnail + )) + case .profileBanner: + return .profileAvatar(.init( + assetURL: _headerAssetURL, + thumbnail: thumbnail + )) + } + }() + + coordinateToMediaPreviewScene( + dependency: dependency, + mediaPreviewItem: mediaPreviewItem, + mediaPreviewTransitionItem: mediaPreviewTransitionItem + ) + } + +} diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Meta.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Meta.swift new file mode 100644 index 000000000..bf54f70ad --- /dev/null +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Meta.swift @@ -0,0 +1,73 @@ +// +// DataSourceFacade+Meta.swift +// Mastodon +// +// Created by MainasuK on 2022-1-17. +// + +import Foundation +import CoreDataStack +import MetaTextKit + +extension DataSourceFacade { + + static func responseToMetaTextAction( + provider: DataSourceProvider, + target: StatusTarget, + status: ManagedObjectRecord<Status>, + meta: Meta + ) async throws { + let _redirectRecord = await DataSourceFacade.status( + managedObjectContext: provider.context.managedObjectContext, + status: status, + target: target + ) + guard let redirectRecord = _redirectRecord else { return } + + await responseToMetaTextAction( + provider: provider, + status: redirectRecord, + meta: meta + ) + + } + + static func responseToMetaTextAction( + provider: DataSourceProvider, + status: ManagedObjectRecord<Status>, + meta: Meta + ) async { + switch meta { + case .url(_, _, let url, _), + .mention(_, let url, _) where url.lowercased().hasPrefix("http"): + // note: + // some server mark the normal url as "u-url" class. highlighted content is a URL + guard let url = URL(string: url) else { return } + if let domain = provider.context.authenticationService.activeMastodonAuthenticationBox.value?.domain, url.host == domain, + url.pathComponents.count >= 4, + url.pathComponents[0] == "/", + url.pathComponents[1] == "web", + url.pathComponents[2] == "statuses" { + let statusID = url.pathComponents[3] + let threadViewModel = RemoteThreadViewModel(context: provider.context, statusID: statusID) + await provider.coordinator.present(scene: .thread(viewModel: threadViewModel), from: nil, transition: .show) + } else { + await provider.coordinator.present(scene: .safari(url: url), from: nil, transition: .safariPresent(animated: true, completion: nil)) + } + case .hashtag(_, let hashtag, _): + let hashtagTimelineViewModel = HashtagTimelineViewModel(context: provider.context, hashtag: hashtag) + await provider.coordinator.present(scene: .hashtagTimeline(viewModel: hashtagTimelineViewModel), from: provider, transition: .show) + case .mention(_, let mention, let userInfo): + await coordinateToProfileScene( + provider: provider, + status: status, + mention: mention, + userInfo: userInfo + ) + default: + assertionFailure() + break + } + } + +} diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Model.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Model.swift new file mode 100644 index 000000000..efdf41dbd --- /dev/null +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Model.swift @@ -0,0 +1,54 @@ +// +// DataSourceFacade+Model.swift +// Mastodon +// +// Created by MainasuK on 2022-1-13. +// + +import Foundation +import CoreData +import CoreDataStack +import MastodonUI + +extension DataSourceFacade { + static func status( + managedObjectContext: NSManagedObjectContext, + status: ManagedObjectRecord<Status>, + target: StatusTarget + ) async -> ManagedObjectRecord<Status>? { + return try? await managedObjectContext.perform { + guard let object = status.object(in: managedObjectContext) else { return nil } + return DataSourceFacade.status(status: object, target: target) + .flatMap { ManagedObjectRecord<Status>(objectID: $0.objectID) } + } + } +} + +extension DataSourceFacade { + static func author( + managedObjectContext: NSManagedObjectContext, + status: ManagedObjectRecord<Status>, + target: StatusTarget + ) async -> ManagedObjectRecord<MastodonUser>? { + return try? await managedObjectContext.perform { + guard let object = status.object(in: managedObjectContext) else { return nil } + return DataSourceFacade.status(status: object, target: target) + .flatMap { $0.author } + .flatMap { ManagedObjectRecord<MastodonUser>(objectID: $0.objectID) } + } + } +} + +extension DataSourceFacade { + static func status( + status: Status, + target: StatusTarget + ) -> Status? { + switch target { + case .status: + return status.reblog ?? status + case .reblog: + return status + } + } +} diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Mute.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Mute.swift new file mode 100644 index 000000000..421d5046c --- /dev/null +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Mute.swift @@ -0,0 +1,25 @@ +// +// DataSourceFacade+Mute.swift +// Mastodon +// +// Created by MainasuK on 2022-1-24. +// + +import UIKit +import CoreDataStack + +extension DataSourceFacade { + static func responseToUserMuteAction( + dependency: NeedsDependency, + user: ManagedObjectRecord<MastodonUser>, + authenticationBox: MastodonAuthenticationBox + ) async throws { + let selectionFeedbackGenerator = UISelectionFeedbackGenerator() + await selectionFeedbackGenerator.selectionChanged() + + _ = try await dependency.context.apiService.toggleMute( + user: user, + authenticationBox: authenticationBox + ) + } // end func +} diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Profile.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Profile.swift new file mode 100644 index 000000000..36eaab621 --- /dev/null +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Profile.swift @@ -0,0 +1,374 @@ +// +// DataSourceFacade+Profile.swift +// Mastodon +// +// Created by MainasuK on 2022-1-13. +// + +import UIKit +import CoreDataStack + +extension DataSourceFacade { + + static func coordinateToProfileScene( + provider: DataSourceProvider, + target: StatusTarget, + status: ManagedObjectRecord<Status> + ) async { + let _redirectRecord = await DataSourceFacade.author( + managedObjectContext: provider.context.managedObjectContext, + status: status, + target: target + ) + guard let redirectRecord = _redirectRecord else { + assertionFailure() + return + } + await coordinateToProfileScene( + provider: provider, + user: redirectRecord + ) + } + + @MainActor + static func coordinateToProfileScene( + provider: DataSourceProvider, + user: ManagedObjectRecord<MastodonUser> + ) async { + guard let user = user.object(in: provider.context.managedObjectContext) else { + assertionFailure() + return + } + + let profileViewModel = CachedProfileViewModel( + context: provider.context, + mastodonUser: user + ) + + provider.coordinator.present( + scene: .profile(viewModel: profileViewModel), + from: provider, + transition: .show + ) + } + +} + +extension DataSourceFacade { + + static func coordinateToProfileScene( + provider: DataSourceProvider, + status: ManagedObjectRecord<Status>, + mention: String, // username, + userInfo: [AnyHashable: Any]? + ) async { + guard let authenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { return } + let domain = authenticationBox.domain + + let href = userInfo?["href"] as? String + guard let url = href.flatMap({ URL(string: $0) }) else { return } + + let managedObjectContext = provider.context.managedObjectContext + let mentions = try? await managedObjectContext.perform { + return status.object(in: managedObjectContext)?.mentions ?? [] + } + + guard let mention = mentions?.first(where: { $0.username == mention }) else { + await provider.coordinator.present( + scene: .safari(url: url), + from: provider, + transition: .safariPresent(animated: true, completion: nil) + ) + return + } + + let userID = mention.id + let profileViewModel: ProfileViewModel = { + // check if self + guard userID != authenticationBox.userID else { + return MeProfileViewModel(context: provider.context) + } + + let request = MastodonUser.sortedFetchRequest + request.fetchLimit = 1 + request.predicate = MastodonUser.predicate(domain: domain, id: userID) + let _user = provider.context.managedObjectContext.safeFetch(request).first + + if let user = _user { + return CachedProfileViewModel(context: provider.context, mastodonUser: user) + } else { + return RemoteProfileViewModel(context: provider.context, userID: userID) + } + }() + + await provider.coordinator.present( + scene: .profile(viewModel: profileViewModel), + from: provider, + transition: .show + ) + } + +} + +extension DataSourceFacade { + + struct ProfileActionMenuContext { + let isMuting: Bool + let isBlocking: Bool + let isMyself: Bool + + let cell: UITableViewCell? + let sourceView: UIView? + let barButtonItem: UIBarButtonItem? + } + + @MainActor + static func createProfileActionMenu( + dependency: NeedsDependency, + user: ManagedObjectRecord<MastodonUser> + ) -> UIMenu { + var children: [UIMenuElement] = [] +// 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, weak sourceView, weak barButtonItem] _ in +// guard let provider = provider else { return } +// let activityViewController = createActivityViewControllerForMastodonUser(mastodonUser: shareUser, dependency: provider) +// provider.coordinator.present( +// scene: .activityViewController( +// activityViewController: activityViewController, +// sourceView: sourceView, +// barButtonItem: barButtonItem +// ), +// from: provider, +// transition: .activityViewControllerPresent(animated: true, completion: nil) +// ) +// } +// children.append(shareAction) +// } +// +// 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, weak sourceView, weak barButtonItem] _ in +// guard let provider = provider else { return } +// let activityViewController = createActivityViewControllerForMastodonUser(status: shareStatus, dependency: provider) +// provider.coordinator.present( +// scene: .activityViewController( +// activityViewController: activityViewController, +// sourceView: sourceView, +// barButtonItem: barButtonItem +// ), +// from: provider, +// transition: .activityViewControllerPresent(animated: true, completion: nil) +// ) +// } +// children.append(shareAction) +// } +// +// if !isMyself { +// // mute +// let muteAction = UIAction( +// title: isMuting ? L10n.Common.Controls.Friendship.unmuteUser(name) : L10n.Common.Controls.Friendship.mute, +// image: isMuting ? UIImage(systemName: "speaker") : UIImage(systemName: "speaker.slash"), +// discoverabilityTitle: isMuting ? nil : L10n.Common.Controls.Friendship.muteUser(name), +// attributes: isMuting ? [] : .destructive, +// state: .off +// ) { [weak provider, weak cell] _ in +// guard let provider = provider else { return } +// +// UserProviderFacade.toggleUserMuteRelationship( +// provider: provider, +// cell: cell +// ) +// .sink { _ in +// // do nothing +// } receiveValue: { _ in +// // do nothing +// } +// .store(in: &provider.context.disposeBag) +// } +// if isMuting { +// children.append(muteAction) +// } else { +// let muteMenu = UIMenu(title: L10n.Common.Controls.Friendship.muteUser(name), image: UIImage(systemName: "speaker.slash"), options: [], children: [muteAction]) +// children.append(muteMenu) +// } +// } +// +// if !isMyself { +// // block +// let blockAction = UIAction( +// title: isBlocking ? L10n.Common.Controls.Friendship.unblockUser(name) : L10n.Common.Controls.Friendship.block, +// image: isBlocking ? UIImage(systemName: "hand.raised.slash") : UIImage(systemName: "hand.raised"), +// discoverabilityTitle: isBlocking ? nil : L10n.Common.Controls.Friendship.blockUser(name), +// attributes: isBlocking ? [] : .destructive, +// state: .off +// ) { [weak provider, weak cell] _ in +// guard let provider = provider else { return } +// +// UserProviderFacade.toggleUserBlockRelationship( +// provider: provider, +// cell: cell +// ) +// .sink { _ in +// // do nothing +// } receiveValue: { _ in +// // do nothing +// } +// .store(in: &provider.context.disposeBag) +// } +// if isBlocking { +// children.append(blockAction) +// } else { +// let blockMenu = UIMenu(title: L10n.Common.Controls.Friendship.blockUser(name), image: UIImage(systemName: "hand.raised"), options: [], children: [blockAction]) +// children.append(blockMenu) +// } +// } +// +// 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 +// guard let provider = provider else { return } +// guard let authenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { +// return +// } +// let viewModel = ReportViewModel( +// context: provider.context, +// domain: authenticationBox.domain, +// user: mastodonUser, +// status: nil +// ) +// provider.coordinator.present( +// scene: .report(viewModel: viewModel), +// from: provider, +// transition: .modal(animated: true, completion: nil) +// ) +// } +// children.append(reportAction) +// } +// +// 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, 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, 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 } +// alertController.addAction(cancelAction) +// 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) +// provider.present(alertController, animated: true, completion: nil) +// } +// children.append(blockDomainAction) +// } +// } +// +// 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 +// 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 } +// alertController.addAction(cancelAction) +// 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 +// ) +// .sink { _ in +// // do nothing +// } receiveValue: { _ in +// // do nothing +// } +// .store(in: &provider.context.disposeBag) +// } +// alertController.addAction(deleteAction) +// provider.present(alertController, animated: true, completion: nil) +// } +// children.append(deleteAction) +// } + + return UIMenu(title: "", options: [], children: children) + } + + static func createActivityViewController( + dependency: NeedsDependency, + user: ManagedObjectRecord<MastodonUser> + ) async throws -> UIActivityViewController? { + let managedObjectContext = dependency.context.managedObjectContext + let activityItems: [Any] = try await managedObjectContext.perform { + guard let user = user.object(in: managedObjectContext) else { return [] } + return user.activityItems + } + guard !activityItems.isEmpty else { + assertionFailure() + return nil + } + + let activityViewController = await UIActivityViewController( + activityItems: activityItems, + applicationActivities: [SafariActivity(sceneCoordinator: dependency.coordinator)] + ) + return activityViewController + } + + static func createActivityViewControllerForMastodonUser(status: Status, dependency: NeedsDependency) -> UIActivityViewController { + let activityViewController = UIActivityViewController( + activityItems: status.activityItems, + applicationActivities: [SafariActivity(sceneCoordinator: dependency.coordinator)] + ) + return activityViewController + } +} diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Reblog.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Reblog.swift new file mode 100644 index 000000000..359b285d4 --- /dev/null +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Reblog.swift @@ -0,0 +1,26 @@ +// +// DataSourceFacade+Reblog.swift +// Mastodon +// +// Created by MainasuK on 2022-1-21. +// + +import UIKit +import CoreDataStack +import MastodonUI + +extension DataSourceFacade { + static func responseToStatusReblogAction( + provider: DataSourceProvider, + status: ManagedObjectRecord<Status>, + authenticationBox: MastodonAuthenticationBox + ) async throws { + let selectionFeedbackGenerator = UISelectionFeedbackGenerator() + await selectionFeedbackGenerator.selectionChanged() + + _ = try await provider.context.apiService.reblog( + record: status, + authenticationBox: authenticationBox + ) + } // end func +} diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+SearchHistory.swift b/Mastodon/Protocol/Provider/DataSourceFacade+SearchHistory.swift new file mode 100644 index 000000000..cbc6bf348 --- /dev/null +++ b/Mastodon/Protocol/Provider/DataSourceFacade+SearchHistory.swift @@ -0,0 +1,116 @@ +// +// DataSourceFacade+SearchHistory.swift +// Mastodon +// +// Created by MainasuK on 2022-1-20. +// + +import Foundation +import CoreDataStack + +extension DataSourceFacade { + + static func responseToCreateSearchHistory( + provider: DataSourceProvider, + item: DataSourceItem + ) async { + switch item { + case .status: + break // not create search history for status + case .user(let record): + let authenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value + let managedObjectContext = provider.context.backgroundManagedObjectContext + + try? await managedObjectContext.performChanges { + guard let me = authenticationBox?.authenticationRecord.object(in: managedObjectContext)?.user else { return } + guard let user = record.object(in: managedObjectContext) else { return } + _ = Persistence.SearchHistory.createOrMerge( + in: managedObjectContext, + context: Persistence.SearchHistory.PersistContext( + entity: .user(user), + me: me, + now: Date() + ) + ) + } // end try? await managedObjectContext.performChanges { … } + case .hashtag(let tag): + let _authenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value + let managedObjectContext = provider.context.backgroundManagedObjectContext + + switch tag { + case .entity(let entity): + try? await managedObjectContext.performChanges { + guard let authenticationBox = _authenticationBox else { return } + guard let me = authenticationBox.authenticationRecord.object(in: managedObjectContext)?.user else { return } + + let now = Date() + + let result = Persistence.Tag.createOrMerge( + in: managedObjectContext, + context: Persistence.Tag.PersistContext( + domain: authenticationBox.domain, + entity: entity, + me: me, + networkDate: now + ) + ) + + _ = Persistence.SearchHistory.createOrMerge( + in: managedObjectContext, + context: Persistence.SearchHistory.PersistContext( + entity: .hashtag(result.tag), + me: me, + now: now + ) + ) + } // end try? await managedObjectContext.performChanges { … } + case .record(let record): + try? await managedObjectContext.performChanges { + guard let authenticationBox = _authenticationBox else { return } + guard let me = authenticationBox.authenticationRecord.object(in: managedObjectContext)?.user else { return } + guard let tag = record.object(in: managedObjectContext) else { return } + + let now = Date() + + _ = Persistence.SearchHistory.createOrMerge( + in: managedObjectContext, + context: Persistence.SearchHistory.PersistContext( + entity: .hashtag(tag), + me: me, + now: now + ) + ) + } // end try? await managedObjectContext.performChanges { … } + } // end switch tag { … } + case .notification: + assertionFailure() + } // end switch item { … } + } // end func + +} + +extension DataSourceFacade { + + static func responseToDeleteSearchHistory( + provider: DataSourceProvider + ) async throws { + let _authenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value + let managedObjectContext = provider.context.backgroundManagedObjectContext + + try await managedObjectContext.performChanges { + guard let authenticationBox = _authenticationBox else { return } + guard let me = authenticationBox.authenticationRecord.object(in: managedObjectContext)?.user else { return } + let request = SearchHistory.sortedFetchRequest + request.predicate = SearchHistory.predicate( + domain: authenticationBox.domain, + userID: authenticationBox.userID + ) + let searchHistories = managedObjectContext.safeFetch(request) + + for searchHistory in searchHistories { + managedObjectContext.delete(searchHistory) + } + } // end try await managedObjectContext.performChanges { … } + } // end func + +} diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift new file mode 100644 index 000000000..eab85e95e --- /dev/null +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift @@ -0,0 +1,309 @@ +// +// DataSourceFacade+Status.swift +// Mastodon +// +// Created by MainasuK on 2022-1-17. +// + +import UIKit +import CoreDataStack +import MastodonUI +import MastodonLocalization + +// Delete +extension DataSourceFacade { + + static func responseToDeleteStatus( + dependency: NeedsDependency, + status: ManagedObjectRecord<Status>, + authenticationBox: MastodonAuthenticationBox + ) async throws { + _ = try await dependency.context.apiService.deleteStatus( + status: status, + authenticationBox: authenticationBox + ) + } + +} + +// Share +extension DataSourceFacade { + + @MainActor + public static func responseToStatusShareAction( + provider: DataSourceProvider, + status: ManagedObjectRecord<Status>, + button: UIButton + ) async throws { + let activityViewController = try await createActivityViewController( + provider: provider, + status: status + ) + provider.coordinator.present( + scene: .activityViewController( + activityViewController: activityViewController, + sourceView: button, + barButtonItem: nil + ), + from: provider, + transition: .activityViewControllerPresent(animated: true, completion: nil) + ) + } + + private static func createActivityViewController( + provider: DataSourceProvider, + status: ManagedObjectRecord<Status> + ) async throws -> UIActivityViewController { + var activityItems: [Any] = try await provider.context.managedObjectContext.perform { + guard let status = status.object(in: provider.context.managedObjectContext) else { return [] } + let url = status.url ?? status.uri + return [URL(string: url)].compactMap { $0 } as [Any] + } + var applicationActivities: [UIActivity] = [ + SafariActivity(sceneCoordinator: provider.coordinator), // open URL + ] + + if let provider = provider as? ShareActivityProvider { + activityItems.append(contentsOf: provider.activities) + applicationActivities.append(contentsOf: provider.applicationActivities) + } + + let activityViewController = await UIActivityViewController( + activityItems: activityItems, + applicationActivities: applicationActivities + ) + return activityViewController + } +} + +// ActionToolBar +extension DataSourceFacade { + @MainActor + static func responseToActionToolbar( + provider: DataSourceProvider, + status: ManagedObjectRecord<Status>, + action: ActionToolbarContainer.Action, + authenticationBox: MastodonAuthenticationBox, + sender: UIButton + ) async throws { + let managedObjectContext = provider.context.managedObjectContext + let _status: ManagedObjectRecord<Status>? = try? await managedObjectContext.perform { + guard let object = status.object(in: managedObjectContext) else { return nil } + let objectID = (object.reblog ?? object).objectID + return .init(objectID: objectID) + } + guard let status = _status else { + assertionFailure() + return + } + + switch action { + case .reply: + guard let authenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { return } + let selectionFeedbackGenerator = UISelectionFeedbackGenerator() + selectionFeedbackGenerator.selectionChanged() + + let composeViewModel = ComposeViewModel( + context: provider.context, + composeKind: .reply(status: status), + authenticationBox: authenticationBox + ) + provider.coordinator.present( + scene: .compose(viewModel: composeViewModel), + from: provider, + transition: .modal(animated: true, completion: nil) + ) + case .reblog: + try await DataSourceFacade.responseToStatusReblogAction( + provider: provider, + status: status, + authenticationBox: authenticationBox + ) + case .like: + try await DataSourceFacade.responseToStatusFavoriteAction( + provider: provider, + status: status, + authenticationBox: authenticationBox + ) + case .share: + try await DataSourceFacade.responseToStatusShareAction( + provider: provider, + status: status, + button: sender + ) + } // end switch + } // end func + +} + +// menu +extension DataSourceFacade { + + struct MenuContext { + let author: ManagedObjectRecord<MastodonUser>? + let status: ManagedObjectRecord<Status>? + let button: UIButton? + let barButtonItem: UIBarButtonItem? + } + + @MainActor + static func responseToMenuAction( + dependency: NeedsDependency & UIViewController, + action: MastodonMenu.Action, + menuContext: MenuContext, + authenticationBox: MastodonAuthenticationBox + ) async throws { + switch action { + case .muteUser(let actionContext): + let alertController = UIAlertController( + title: actionContext.isMuting ? "Unmute Account" : "Mute Account", + message: actionContext.isMuting ? "Confirm to unmute \(actionContext.name)" : "Confirm to mute \(actionContext.name)", + preferredStyle: .alert + ) + let confirmAction = UIAlertAction( + title: actionContext.isMuting ? L10n.Common.Controls.Friendship.unmute : L10n.Common.Controls.Friendship.mute, + style: .destructive + ) { [weak dependency] _ in + guard let dependency = dependency else { return } + Task { + let managedObjectContext = dependency.context.managedObjectContext + let _user: ManagedObjectRecord<MastodonUser>? = try? await managedObjectContext.perform { + guard let user = menuContext.author?.object(in: managedObjectContext) else { return nil } + return ManagedObjectRecord<MastodonUser>(objectID: user.objectID) + } + guard let user = _user else { return } + try await DataSourceFacade.responseToUserMuteAction( + dependency: dependency, + user: user, + authenticationBox: authenticationBox + ) + } // end Task + } + alertController.addAction(confirmAction) + let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel, handler: nil) + alertController.addAction(cancelAction) + dependency.present(alertController, animated: true, completion: nil) + case .blockUser(let actionContext): + let alertController = UIAlertController( + title: actionContext.isBlocking ? "Unblock Account" : "Block Account", + message: actionContext.isBlocking ? "Confirm to unblock \(actionContext.name)" : "Confirm to block \(actionContext.name)", + preferredStyle: .alert + ) + let confirmAction = UIAlertAction( + title: actionContext.isBlocking ? L10n.Common.Controls.Friendship.unblock : L10n.Common.Controls.Friendship.block, + style: .destructive + ) { [weak dependency] _ in + guard let dependency = dependency else { return } + Task { + let managedObjectContext = dependency.context.managedObjectContext + let _user: ManagedObjectRecord<MastodonUser>? = try? await managedObjectContext.perform { + guard let user = menuContext.author?.object(in: managedObjectContext) else { return nil } + return ManagedObjectRecord<MastodonUser>(objectID: user.objectID) + } + guard let user = _user else { return } + try await DataSourceFacade.responseToUserBlockAction( + dependency: dependency, + user: user, + authenticationBox: authenticationBox + ) + } // end Task + } + alertController.addAction(confirmAction) + let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel, handler: nil) + alertController.addAction(cancelAction) + dependency.present(alertController, animated: true, completion: nil) + case .reportUser: + Task { + guard let user = menuContext.author else { return } + + let reportViewModel = ReportViewModel( + context: dependency.context, + user: user, + status: menuContext.status + ) + + dependency.coordinator.present( + scene: .report(viewModel: reportViewModel), + from: dependency, + transition: .modal(animated: true, completion: nil) + ) + } // end Task + + case .shareUser: + guard let user = menuContext.author else { + assertionFailure() + return + } + let _activityViewController = try await DataSourceFacade.createActivityViewController( + dependency: dependency, + user: user + ) + guard let activityViewController = _activityViewController else { return } + dependency.coordinator.present( + scene: .activityViewController( + activityViewController: activityViewController, + sourceView: menuContext.button, + barButtonItem: menuContext.barButtonItem + ), + from: dependency, + transition: .activityViewControllerPresent(animated: true, completion: nil) + ) + case .deleteStatus: + let alertController = UIAlertController( + title: "Delete Post", + message: "Are you sure you want to delete this post?", + preferredStyle: .alert + ) + let confirmAction = UIAlertAction( + title: L10n.Common.Controls.Actions.delete, + style: .destructive + ) { [weak dependency] _ in + guard let dependency = dependency else { return } + guard let status = menuContext.status else { return } + Task { + try await DataSourceFacade.responseToDeleteStatus( + dependency: dependency, + status: status, + authenticationBox: authenticationBox + ) + } // end Task + } + alertController.addAction(confirmAction) + let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel, handler: nil) + alertController.addAction(cancelAction) + dependency.present(alertController, animated: true, completion: nil) + + } + } // end func +} + +extension DataSourceFacade { + + static func responseToToggleSensitiveAction( + dependency: NeedsDependency, + status: ManagedObjectRecord<Status> + ) async throws { + try await dependency.context.managedObjectContext.perform { + guard let _status = status.object(in: dependency.context.managedObjectContext) else { return } + let status = _status.reblog ?? _status + + let allToggled = status.isContentSensitiveToggled && status.isMediaSensitiveToggled + + status.update(isContentSensitiveToggled: !allToggled) + status.update(isMediaSensitiveToggled: !allToggled) + } + } + +// static func responseToToggleMediaSensitiveAction( +// dependency: NeedsDependency, +// status: ManagedObjectRecord<Status> +// ) async throws { +// try await dependency.context.managedObjectContext.perform { +// guard let _status = status.object(in: dependency.context.managedObjectContext) else { return } +// let status = _status.reblog ?? _status +// +// status.update(isMediaSensitiveToggled: !status.isMediaSensitiveToggled) +// } +// } + +} diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Thread.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Thread.swift new file mode 100644 index 000000000..269504215 --- /dev/null +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Thread.swift @@ -0,0 +1,55 @@ +// +// DataSourceFacade+Thread.swift +// Mastodon +// +// Created by MainasuK on 2022-1-17. +// + +import Foundation +import CoreData +import CoreDataStack + +extension DataSourceFacade { + static func coordinateToStatusThreadScene( + provider: DataSourceProvider, + target: StatusTarget, + status: ManagedObjectRecord<Status> + ) async { + let _root: StatusItem.Thread? = await { + let _redirectRecord = await DataSourceFacade.status( + managedObjectContext: provider.context.managedObjectContext, + status: status, + target: target + ) + guard let redirectRecord = _redirectRecord else { return nil } + + let threadContext = StatusItem.Thread.Context(status: redirectRecord) + return StatusItem.Thread.root(context: threadContext) + }() + guard let root = _root else { + assertionFailure() + return + } + + await coordinateToStatusThreadScene( + provider: provider, + root: root + ) + } + + @MainActor + static func coordinateToStatusThreadScene( + provider: DataSourceProvider, + root: StatusItem.Thread + ) async { + let threadViewModel = ThreadViewModel( + context: provider.context, + optionalRoot: root + ) + provider.coordinator.present( + scene: .thread(viewModel: threadViewModel), + from: provider, + transition: .show + ) + } +} diff --git a/Mastodon/Protocol/Provider/DataSourceFacade.swift b/Mastodon/Protocol/Provider/DataSourceFacade.swift new file mode 100644 index 000000000..4d3536517 --- /dev/null +++ b/Mastodon/Protocol/Provider/DataSourceFacade.swift @@ -0,0 +1,16 @@ +// +// DataSourceFacade.swift +// DataSourceFacade +// +// Created by Cirno MainasuK on 2021-8-30. +// Copyright © 2021 Twidere. All rights reserved. +// + +import Foundation + +enum DataSourceFacade { + enum StatusTarget { + case status // remove reblog wrapper + case reblog // keep reblog wrapper + } +} diff --git a/Mastodon/Protocol/Provider/DataSourceProvider+NotificationTableViewCellDelegate.swift b/Mastodon/Protocol/Provider/DataSourceProvider+NotificationTableViewCellDelegate.swift new file mode 100644 index 000000000..0924028ff --- /dev/null +++ b/Mastodon/Protocol/Provider/DataSourceProvider+NotificationTableViewCellDelegate.swift @@ -0,0 +1,496 @@ +// +// DataSourceProvider+NotificationTableViewCellDelegate.swift +// Mastodon +// +// Created by MainasuK on 2022-1-26. +// + +import UIKit +import MetaTextKit +import MastodonUI +import CoreDataStack + +// MARK: - Notification AuthorMenuAction +extension NotificationTableViewCellDelegate where Self: DataSourceProvider { + func tableViewCell( + _ cell: UITableViewCell, + notificationView: NotificationView, + menuButton button: UIButton, + didSelectAction action: MastodonMenu.Action + ) { + guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } + Task { + let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil) + guard let item = await item(from: source) else { + assertionFailure() + return + } + guard case let .notification(notification) = item else { + assertionFailure("only works for status data provider") + return + } + + let _author: ManagedObjectRecord<MastodonUser>? = try await self.context.managedObjectContext.perform { + guard let notification = notification.object(in: self.context.managedObjectContext) else { return nil } + return .init(objectID: notification.account.objectID) + } + guard let author = _author else { + assertionFailure() + return + } + + try await DataSourceFacade.responseToMenuAction( + dependency: self, + action: action, + menuContext: .init( + author: author, + status: nil, + button: button, + barButtonItem: nil + ), + authenticationBox: authenticationBox + ) + } // end Task + } +} + +// MARK: - Notification Author Avatar +extension NotificationTableViewCellDelegate where Self: DataSourceProvider { + func tableViewCell( + _ cell: UITableViewCell, + notificationView: NotificationView, + authorAvatarButtonDidPressed button: AvatarButton + ) { + Task { + let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil) + guard let item = await item(from: source) else { + assertionFailure() + return + } + guard case let .notification(notification) = item else { + assertionFailure("only works for status data provider") + return + } + let _author: ManagedObjectRecord<MastodonUser>? = try await self.context.managedObjectContext.perform { + guard let notification = notification.object(in: self.context.managedObjectContext) else { return nil } + return .init(objectID: notification.account.objectID) + } + guard let author = _author else { + assertionFailure() + return + } + await DataSourceFacade.coordinateToProfileScene( + provider: self, + user: author + ) + } // end Task + } +} + +// MARK: - Status Content +extension NotificationTableViewCellDelegate where Self: DataSourceProvider { + func tableViewCell( + _ cell: UITableViewCell, + notificationView: NotificationView, + statusView: StatusView, + metaText: MetaText, + didSelectMeta meta: Meta + ) { + Task { + try await responseToStatusMeta(cell, didSelectMeta: meta) + } // end Task + } +} + +private struct NotificationMediaTransitionContext { + let status: ManagedObjectRecord<Status> + let needsToggleMediaSensitive: Bool +} + +extension NotificationTableViewCellDelegate where Self: DataSourceProvider & MediaPreviewableViewController { + + func tableViewCell( + _ cell: UITableViewCell, + notificationView: NotificationView, + statusView: StatusView, + mediaGridContainerView: MediaGridContainerView, + mediaView: MediaView, + didSelectMediaViewAt index: Int + ) { + Task { + let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil) + guard let item = await item(from: source) else { + assertionFailure() + return + } + guard case let .notification(record) = item else { + assertionFailure("only works for status data provider") + return + } + + let managedObjectContext = self.context.managedObjectContext + let _mediaTransitionContext: NotificationMediaTransitionContext? = try await managedObjectContext.perform { + guard let notification = record.object(in: managedObjectContext) else { return nil } + guard let _status = notification.status else { return nil } + let status = _status.reblog ?? _status + return NotificationMediaTransitionContext( + status: .init(objectID: status.objectID), + needsToggleMediaSensitive: status.isMediaSensitiveToggled ? !status.sensitive : status.sensitive + ) + } + + guard let mediaTransitionContext = _mediaTransitionContext else { return } + + guard !mediaTransitionContext.needsToggleMediaSensitive else { + try await DataSourceFacade.responseToToggleSensitiveAction( + dependency: self, + status: mediaTransitionContext.status + ) + return + } + + try await DataSourceFacade.coordinateToMediaPreviewScene( + dependency: self, + status: mediaTransitionContext.status, + previewContext: DataSourceFacade.AttachmentPreviewContext( + containerView: .mediaGridContainerView(mediaGridContainerView), + mediaView: mediaView, + index: index + ) + ) + } // end Task + } + + func tableViewCell( + _ cell: UITableViewCell, + notificationView: NotificationView, + quoteStatusView: StatusView, + mediaGridContainerView: MediaGridContainerView, + mediaView: MediaView, + didSelectMediaViewAt index: Int + ) { + Task { + let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil) + guard let item = await item(from: source) else { + assertionFailure() + return + } + guard case let .notification(record) = item else { + assertionFailure("only works for status data provider") + return + } + + let managedObjectContext = self.context.managedObjectContext + let _mediaTransitionContext: NotificationMediaTransitionContext? = try await managedObjectContext.perform { + guard let notification = record.object(in: managedObjectContext) else { return nil } + guard let _status = notification.status else { return nil } + let status = _status.reblog ?? _status + return NotificationMediaTransitionContext( + status: .init(objectID: status.objectID), + needsToggleMediaSensitive: status.isMediaSensitiveToggled ? !status.sensitive : status.sensitive + ) + } + + guard let mediaTransitionContext = _mediaTransitionContext else { return } + + guard !mediaTransitionContext.needsToggleMediaSensitive else { + try await DataSourceFacade.responseToToggleSensitiveAction( + dependency: self, + status: mediaTransitionContext.status + ) + return + } + + try await DataSourceFacade.coordinateToMediaPreviewScene( + dependency: self, + status: mediaTransitionContext.status, + previewContext: DataSourceFacade.AttachmentPreviewContext( + containerView: .mediaGridContainerView(mediaGridContainerView), + mediaView: mediaView, + index: index + ) + ) + } // end Task + } + +} + +// MARK: - Status Toolbar +extension NotificationTableViewCellDelegate where Self: DataSourceProvider { + func tableViewCell( + _ cell: UITableViewCell, + notificationView: NotificationView, + statusView: StatusView, actionToolbarContainer: ActionToolbarContainer, + buttonDidPressed button: UIButton, + action: ActionToolbarContainer.Action + ) { + guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } + Task { + let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil) + guard let item = await item(from: source) else { + assertionFailure() + return + } + guard case let .notification(notification) = item else { + assertionFailure("only works for status data provider") + return + } + let _status: ManagedObjectRecord<Status>? = try await self.context.managedObjectContext.perform { + guard let notification = notification.object(in: self.context.managedObjectContext) else { return nil } + guard let status = notification.status else { return nil } + return .init(objectID: status.objectID) + } + guard let status = _status else { + assertionFailure() + return + } + try await DataSourceFacade.responseToActionToolbar( + provider: self, + status: status, + action: action, + authenticationBox: authenticationBox, + sender: button + ) + } // end Task + } +} + +// MARK: - Status Author Avatar +extension NotificationTableViewCellDelegate where Self: DataSourceProvider { + func tableViewCell( + _ cell: UITableViewCell, + notificationView: NotificationView, + quoteStatusView: StatusView, + authorAvatarButtonDidPressed button: AvatarButton + ) { + Task { + let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil) + guard let item = await item(from: source) else { + assertionFailure() + return + } + guard case let .notification(notification) = item else { + assertionFailure("only works for status data provider") + return + } + let _author: ManagedObjectRecord<MastodonUser>? = try await self.context.managedObjectContext.perform { + guard let notification = notification.object(in: self.context.managedObjectContext) else { return nil } + guard let status = notification.status else { return nil } + return .init(objectID: status.author.objectID) + } + guard let author = _author else { + assertionFailure() + return + } + await DataSourceFacade.coordinateToProfileScene( + provider: self, + user: author + ) + } // end Task + } +} + +// MARK: - Status Content +extension NotificationTableViewCellDelegate where Self: DataSourceProvider { + + func tableViewCell( + _ cell: UITableViewCell, + notificationView: NotificationView, + quoteStatusView: StatusView, metaText: MetaText, + didSelectMeta meta: Meta + ) { + Task { + try await responseToStatusMeta(cell, didSelectMeta: meta) + } // end Task + } + + private func responseToStatusMeta( + _ cell: UITableViewCell, + didSelectMeta meta: Meta + ) async throws { + let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil) + guard let item = await item(from: source) else { + assertionFailure() + return + } + guard case let .notification(notification) = item else { + assertionFailure("only works for notification item") + return + } + let _status: ManagedObjectRecord<Status>? = try await self.context.managedObjectContext.perform { + guard let notification = notification.object(in: self.context.managedObjectContext) else { return nil } + guard let status = notification.status else { return nil } + return .init(objectID: status.objectID) + } + guard let status = _status else { + assertionFailure() + return + } + try await DataSourceFacade.responseToMetaTextAction( + provider: self, + target: .status, + status: status, + meta: meta + ) + } + + func tableViewCell( + _ cell: UITableViewCell, + notificationView: NotificationView, + statusView: StatusView, + spoilerOverlayViewDidPressed overlayView: SpoilerOverlayView + ) { + Task { + let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil) + guard let item = await item(from: source) else { + assertionFailure() + return + } + guard case let .notification(notification) = item else { + assertionFailure("only works for notification item") + return + } + let _status: ManagedObjectRecord<Status>? = try await self.context.managedObjectContext.perform { + guard let notification = notification.object(in: self.context.managedObjectContext) else { return nil } + guard let status = notification.status else { return nil } + return .init(objectID: status.objectID) + } + guard let status = _status else { + assertionFailure() + return + } + try await DataSourceFacade.responseToToggleSensitiveAction( + dependency: self, + status: status + ) + } // end Task + } + + +// func tableViewCell( +// _ cell: UITableViewCell, notificationView: NotificationView, +// statusView: StatusView, +// spoilerBannerViewDidPressed bannerView: SpoilerBannerView +// ) { +// Task { +// let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil) +// guard let item = await item(from: source) else { +// assertionFailure() +// return +// } +// guard case let .notification(notification) = item else { +// assertionFailure("only works for notification item") +// return +// } +// let _status: ManagedObjectRecord<Status>? = try await self.context.managedObjectContext.perform { +// guard let notification = notification.object(in: self.context.managedObjectContext) else { return nil } +// guard let status = notification.status else { return nil } +// return .init(objectID: status.objectID) +// } +// guard let status = _status else { +// assertionFailure() +// return +// } +// try await DataSourceFacade.responseToToggleSensitiveAction( +// dependency: self, +// status: status +// ) +// } // end Task +// } + + + func tableViewCell( + _ cell: UITableViewCell, + notificationView: NotificationView, + quoteStatusView: StatusView, + spoilerOverlayViewDidPressed overlayView: SpoilerOverlayView + ) { + Task { + let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil) + guard let item = await item(from: source) else { + assertionFailure() + return + } + guard case let .notification(notification) = item else { + assertionFailure("only works for notification item") + return + } + let _status: ManagedObjectRecord<Status>? = try await self.context.managedObjectContext.perform { + guard let notification = notification.object(in: self.context.managedObjectContext) else { return nil } + guard let status = notification.status else { return nil } + return .init(objectID: status.objectID) + } + guard let status = _status else { + assertionFailure() + return + } + try await DataSourceFacade.responseToToggleSensitiveAction( + dependency: self, + status: status + ) + } // end Task + } + + func tableViewCell( + _ cell: UITableViewCell, + notificationView: NotificationView, + quoteStatusView: StatusView, + spoilerBannerViewDidPressed bannerView: SpoilerBannerView + ) { + Task { + let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil) + guard let item = await item(from: source) else { + assertionFailure() + return + } + guard case let .notification(notification) = item else { + assertionFailure("only works for notification item") + return + } + let _status: ManagedObjectRecord<Status>? = try await self.context.managedObjectContext.perform { + guard let notification = notification.object(in: self.context.managedObjectContext) else { return nil } + guard let status = notification.status else { return nil } + return .init(objectID: status.objectID) + } + guard let status = _status else { + assertionFailure() + return + } + try await DataSourceFacade.responseToToggleSensitiveAction( + dependency: self, + status: status + ) + } // end Task + } + + +} + +// MARK: a11y +extension NotificationTableViewCellDelegate where Self: DataSourceProvider { + func tableViewCell(_ cell: UITableViewCell, notificationView: NotificationView, accessibilityActivate: Void) { + Task { + let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil) + guard let item = await item(from: source) else { + return + } + switch item { + case .status(let status): + await DataSourceFacade.coordinateToStatusThreadScene( + provider: self, + target: .status, // remove reblog wrapper + status: status + ) + case .user(let user): + await DataSourceFacade.coordinateToProfileScene( + provider: self, + user: user + ) + case .notification(let notification): + assertionFailure("TODO") + default: + assertionFailure("TODO") + } + } // end Task + } +} diff --git a/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewCellDelegate.swift new file mode 100644 index 000000000..d14b5c346 --- /dev/null +++ b/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewCellDelegate.swift @@ -0,0 +1,509 @@ +// +// DataSourceProvider+StatusTableViewCellDelegate.swift +// Mastodon +// +// Created by MainasuK on 2022-1-13. +// + +import UIKit +import CoreDataStack +import MetaTextKit +import MastodonUI + +// MARK: - header +extension StatusTableViewCellDelegate where Self: DataSourceProvider { + + func tableViewCell( + _ cell: UITableViewCell, + statusView: StatusView, + headerDidPressed header: UIView + ) { + Task { + let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil) + guard let item = await item(from: source) else { + assertionFailure() + return + } + guard case let .status(status) = item else { + assertionFailure("only works for status data provider") + return + } + + switch await statusView.viewModel.header { + case .none: + break + case .reply: + let _replyToAuthor: ManagedObjectRecord<MastodonUser>? = try? await context.managedObjectContext.perform { + guard let status = status.object(in: self.context.managedObjectContext) else { return nil } + guard let inReplyToAccountID = status.inReplyToAccountID else { return nil } + let request = MastodonUser.sortedFetchRequest + request.predicate = MastodonUser.predicate(domain: status.author.domain, id: inReplyToAccountID) + request.fetchLimit = 1 + guard let author = self.context.managedObjectContext.safeFetch(request).first else { return nil } + return .init(objectID: author.objectID) + } + guard let replyToAuthor = _replyToAuthor else { + return + } + + await DataSourceFacade.coordinateToProfileScene( + provider: self, + user: replyToAuthor + ) + + case .repost: + await DataSourceFacade.coordinateToProfileScene( + provider: self, + target: .reblog, // keep the wrapper for header author + status: status + ) + } + } + } + +} + +// MARK: - avatar button +extension StatusTableViewCellDelegate where Self: DataSourceProvider { + + func tableViewCell( + _ cell: UITableViewCell, + statusView: StatusView, + authorAvatarButtonDidPressed button: AvatarButton + ) { + Task { + let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil) + guard let item = await item(from: source) else { + assertionFailure() + return + } + guard case let .status(status) = item else { + assertionFailure("only works for status data provider") + return + } + await DataSourceFacade.coordinateToProfileScene( + provider: self, + target: .status, + status: status + ) + } + } + +} + +// MARK: - content +extension StatusTableViewCellDelegate where Self: DataSourceProvider { + + func tableViewCell( + _ cell: UITableViewCell, + statusView: StatusView, + metaText: MetaText, + didSelectMeta meta: Meta + ) { + Task { + let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil) + guard let item = await item(from: source) else { + assertionFailure() + return + } + guard case let .status(status) = item else { + assertionFailure("only works for status data provider") + return + } + + try await DataSourceFacade.responseToMetaTextAction( + provider: self, + target: .status, + status: status, + meta: meta + ) + } + } + +} + +// MARK: - media +extension StatusTableViewCellDelegate where Self: DataSourceProvider & MediaPreviewableViewController { + + func tableViewCell( + _ cell: UITableViewCell, + statusView: StatusView, + mediaGridContainerView: MediaGridContainerView, + mediaView: MediaView, + didSelectMediaViewAt index: Int + ) { + Task { + let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil) + guard let item = await item(from: source) else { + assertionFailure() + return + } + guard case let .status(status) = item else { + assertionFailure("only works for status data provider") + return + } + + let managedObjectContext = self.context.managedObjectContext + let needsToggleMediaSensitive: Bool = try await managedObjectContext.perform { + guard let _status = status.object(in: managedObjectContext) else { return false } + let status = _status.reblog ?? _status + return status.isMediaSensitiveToggled ? !status.sensitive : status.sensitive + } + + guard !needsToggleMediaSensitive else { + try await DataSourceFacade.responseToToggleSensitiveAction( + dependency: self, + status: status + ) + return + } + + try await DataSourceFacade.coordinateToMediaPreviewScene( + dependency: self, + status: status, + previewContext: DataSourceFacade.AttachmentPreviewContext( + containerView: .mediaGridContainerView(mediaGridContainerView), + mediaView: mediaView, + index: index + ) + ) + } // end Task + } + +} + + +// MARK: - poll +extension StatusTableViewCellDelegate where Self: DataSourceProvider { + + func tableViewCell( + _ cell: UITableViewCell, + statusView: StatusView, + pollTableView tableView: UITableView, + didSelectRowAt indexPath: IndexPath + ) { + guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } + guard let pollTableViewDiffableDataSource = statusView.pollTableViewDiffableDataSource else { return } + guard let pollItem = pollTableViewDiffableDataSource.itemIdentifier(for: indexPath) else { return } + + let managedObjectContext = context.managedObjectContext + + Task { + guard case let .option(pollOption) = pollItem else { + assertionFailure("only works for status data provider") + return + } + + var _poll: ManagedObjectRecord<Poll>? + var _isMultiple: Bool? + var _choice: Int? + + try await managedObjectContext.performChanges { + guard let pollOption = pollOption.object(in: managedObjectContext) else { return } + let poll = pollOption.poll + _poll = .init(objectID: poll.objectID) + + _isMultiple = poll.multiple + guard !poll.isVoting else { return } + + if !poll.multiple { + for option in poll.options where option != pollOption { + option.update(isSelected: false) + } + + // mark voting + poll.update(isVoting: true) + // set choice + _choice = Int(pollOption.index) + } + + pollOption.update(isSelected: !pollOption.isSelected) + poll.update(updatedAt: Date()) + } + + // Trigger vote API request for + guard let poll = _poll, + _isMultiple == false, + let choice = _choice + else { return } + + do { + _ = try await context.apiService.vote( + poll: poll, + choices: [choice], + authenticationBox: authenticationBox + ) + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): vote poll for \(choice) success") + } catch { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): vote poll fail: \(error.localizedDescription)") + + // restore voting state + try await managedObjectContext.performChanges { + guard let pollOption = pollOption.object(in: managedObjectContext) else { return } + let poll = pollOption.poll + poll.update(isVoting: false) + } + } + + } // end Task + } + + func tableViewCell( + _ cell: UITableViewCell, + statusView: StatusView, + pollVoteButtonPressed button: UIButton + ) { + guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } + guard let pollTableViewDiffableDataSource = statusView.pollTableViewDiffableDataSource else { return } + guard let firstPollItem = pollTableViewDiffableDataSource.snapshot().itemIdentifiers.first else { return } + guard case let .option(firstPollOption) = firstPollItem else { return } + + let managedObjectContext = context.managedObjectContext + + Task { + var _poll: ManagedObjectRecord<Poll>? + var _choices: [Int]? + + try await managedObjectContext.performChanges { + guard let poll = firstPollOption.object(in: managedObjectContext)?.poll else { return } + _poll = .init(objectID: poll.objectID) + + guard poll.multiple else { return } + + // mark voting + poll.update(isVoting: true) + // set choice + _choices = poll.options + .filter { $0.isSelected } + .map { Int($0.index) } + + poll.update(updatedAt: Date()) + } + + // Trigger vote API request for + guard let poll = _poll, + let choices = _choices + else { return } + + do { + _ = try await context.apiService.vote( + poll: poll, + choices: choices, + authenticationBox: authenticationBox + ) + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): vote poll for \(choices) success") + } catch { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): vote poll fail: \(error.localizedDescription)") + + // restore voting state + try await managedObjectContext.performChanges { + guard let poll = poll.object(in: managedObjectContext) else { return } + poll.update(isVoting: false) + } + } + + } // end Task + } + +} + +// MARK: - toolbar +extension StatusTableViewCellDelegate where Self: DataSourceProvider { + func tableViewCell( + _ cell: UITableViewCell, + statusView: StatusView, + actionToolbarContainer: ActionToolbarContainer, + buttonDidPressed button: UIButton, + action: ActionToolbarContainer.Action + ) { + guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } + Task { + let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil) + guard let item = await item(from: source) else { + assertionFailure() + return + } + guard case let .status(status) = item else { + assertionFailure("only works for status data provider") + return + } + + try await DataSourceFacade.responseToActionToolbar( + provider: self, + status: status, + action: action, + authenticationBox: authenticationBox, + sender: button + ) + } // end Task + } + +} + +// MARK: - menu button +extension StatusTableViewCellDelegate where Self: DataSourceProvider { + func tableViewCell( + _ cell: UITableViewCell, + statusView: StatusView, + menuButton button: UIButton, + didSelectAction action: MastodonMenu.Action + ) { + guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } + Task { + let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil) + guard let item = await item(from: source) else { + assertionFailure() + return + } + guard case let .status(status) = item else { + assertionFailure("only works for status data provider") + return + } + let _author: ManagedObjectRecord<MastodonUser>? = try await self.context.managedObjectContext.perform { + guard let _status = status.object(in: self.context.managedObjectContext) else { return nil } + let author = (_status.reblog ?? _status).author + return .init(objectID: author.objectID) + } + guard let author = _author else { + assertionFailure() + return + } + + try await DataSourceFacade.responseToMenuAction( + dependency: self, + action: action, + menuContext: .init( + author: author, + status: status, + button: button, + barButtonItem: nil + ), + authenticationBox: authenticationBox + ) + } // end Task + } + +} + +// MARK: - content warning +extension StatusTableViewCellDelegate where Self: DataSourceProvider { + + func tableViewCell( + _ cell: UITableViewCell, + statusView: StatusView, + contentSensitiveeToggleButtonDidPressed button: UIButton + ) { + Task { + let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil) + guard let item = await item(from: source) else { + assertionFailure() + return + } + guard case let .status(status) = item else { + assertionFailure("only works for status data provider") + return + } + try await DataSourceFacade.responseToToggleSensitiveAction( + dependency: self, + status: status + ) + } // end Task + } + + func tableViewCell( + _ cell: UITableViewCell, + statusView: StatusView, + spoilerOverlayViewDidPressed overlayView: SpoilerOverlayView + ) { + Task { + let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil) + guard let item = await item(from: source) else { + assertionFailure() + return + } + guard case let .status(status) = item else { + assertionFailure("only works for status data provider") + return + } + try await DataSourceFacade.responseToToggleSensitiveAction( + dependency: self, + status: status + ) + } // end Task + } + +// func tableViewCell( +// _ cell: UITableViewCell, +// statusView: StatusView, +// spoilerBannerViewDidPressed bannerView: SpoilerBannerView +// ) { +// Task { +// let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil) +// guard let item = await item(from: source) else { +// assertionFailure() +// return +// } +// guard case let .status(status) = item else { +// assertionFailure("only works for status data provider") +// return +// } +// try await DataSourceFacade.responseToToggleSensitiveAction( +// dependency: self, +// status: status +// ) +// } // end Task +// } + + func tableViewCell( + _ cell: UITableViewCell, + statusView: StatusView, + mediaGridContainerView: MediaGridContainerView, + mediaSensitiveButtonDidPressed button: UIButton + ) { + Task { + let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil) + guard let item = await item(from: source) else { + assertionFailure() + return + } + guard case let .status(status) = item else { + assertionFailure("only works for status data provider") + return + } + try await DataSourceFacade.responseToToggleSensitiveAction( + dependency: self, + status: status + ) + } // end Task + } + +} + +// MARK: a11y +extension StatusTableViewCellDelegate where Self: DataSourceProvider { + func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, accessibilityActivate: Void) { + Task { + let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil) + guard let item = await item(from: source) else { + return + } + switch item { + case .status(let status): + await DataSourceFacade.coordinateToStatusThreadScene( + provider: self, + target: .status, // remove reblog wrapper + status: status + ) + case .user(let user): + await DataSourceFacade.coordinateToProfileScene( + provider: self, + user: user + ) + case .notification(let notification): + assertionFailure("TODO") + default: + assertionFailure("TODO") + } + } + } +} diff --git a/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewControllerNavigateable.swift b/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewControllerNavigateable.swift new file mode 100644 index 000000000..fb4a7d843 --- /dev/null +++ b/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewControllerNavigateable.swift @@ -0,0 +1,191 @@ +// +// DataSourceProvider+StatusTableViewControllerNavigateable.swift +// Mastodon +// +// Created by MainasuK on 2022-2-16. +// + +import os.log +import UIKit +import CoreDataStack + +extension StatusTableViewControllerNavigateableCore where Self: DataSourceProvider & StatusTableViewControllerNavigateableRelay { + + var statusNavigationKeyCommands: [UIKeyCommand] { + StatusTableViewNavigation.allCases.map { navigation in + UIKeyCommand( + title: navigation.title, + image: nil, + action: #selector(Self.statusKeyCommandHandlerRelay(_:)), + input: navigation.input, + modifierFlags: navigation.modifierFlags, + propertyList: navigation.propertyList, + alternates: [], + discoverabilityTitle: nil, + attributes: [], + state: .off + ) + } + } + +} + +extension StatusTableViewControllerNavigateableCore where Self: DataSourceProvider { + + func statusKeyCommandHandler(_ sender: UIKeyCommand) { + guard let rawValue = sender.propertyList as? String, + let navigation = StatusTableViewNavigation(rawValue: rawValue) else { return } + + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: %s", ((#file as NSString).lastPathComponent), #line, #function, navigation.title) + Task { + switch navigation { + case .openAuthorProfile: await openAuthorProfile(target: .status) + case .openRebloggerProfile: await openAuthorProfile(target: .reblog) + case .replyStatus: await replyStatus() + case .toggleReblog: await toggleReblog() + case .toggleFavorite: await toggleFavorite() + case .toggleContentWarning: await toggleContentWarning() + case .previewImage: await previewImage() + } + } + } + +} + +// status coordinate +extension StatusTableViewControllerNavigateableCore where Self: DataSourceProvider { + + @MainActor + private func statusRecord() async -> ManagedObjectRecord<Status>? { + guard let indexPathForSelectedRow = tableView.indexPathForSelectedRow else { return nil } + let source = DataSourceItem.Source(indexPath: indexPathForSelectedRow) + guard let item = await item(from: source) else { return nil } + + switch item { + case .status(let record): + return record + case .notification(let record): + let _statusRecord: ManagedObjectRecord<Status>? = try? await context.managedObjectContext.perform { + guard let notification = record.object(in: self.context.managedObjectContext) else { return nil } + guard let status = notification.status else { return nil } + return .init(objectID: status.objectID) + } + guard let statusRecord = _statusRecord else { + return nil + } + return statusRecord + default: + return nil + } + } + + @MainActor + private func openAuthorProfile(target: DataSourceFacade.StatusTarget) async { + guard let status = await statusRecord() else { return } + await DataSourceFacade.coordinateToProfileScene( + provider: self, + target: target, + status: status + ) + } + + @MainActor + private func replyStatus() async { + guard let status = await statusRecord() else { return } + + guard let authenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { return } + let selectionFeedbackGenerator = UISelectionFeedbackGenerator() + selectionFeedbackGenerator.selectionChanged() + + let composeViewModel = ComposeViewModel( + context: self.context, + composeKind: .reply(status: status), + authenticationBox: authenticationBox + ) + self.coordinator.present( + scene: .compose(viewModel: composeViewModel), + from: self, + transition: .modal(animated: true, completion: nil) + ) + } + + @MainActor + private func previewImage() async { + guard let status = await statusRecord() else { return } + + guard let provider = self as? (DataSourceProvider & MediaPreviewableViewController) else { return } + guard let indexPathForSelectedRow = tableView.indexPathForSelectedRow, + let cell = tableView.cellForRow(at: indexPathForSelectedRow) as? StatusTableViewCell + else { return } + + guard let mediaView = cell.statusView.mediaGridContainerView.mediaViews.first else { return } + + do { + try await DataSourceFacade.coordinateToMediaPreviewScene( + dependency: provider, + status: status, + previewContext: DataSourceFacade.AttachmentPreviewContext( + containerView: .mediaGridContainerView(cell.statusView.mediaGridContainerView), + mediaView: mediaView, + index: 0 + ) + ) + } catch { + assertionFailure() + } + } + +} + +// toggle +extension StatusTableViewControllerNavigateableCore where Self: DataSourceProvider { + + @MainActor + private func toggleReblog() async { + guard let status = await statusRecord() else { return } + + guard let authenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { return } + + do { + try await DataSourceFacade.responseToStatusReblogAction( + provider: self, + status: status, + authenticationBox: authenticationBox + ) + } catch { + assertionFailure() + } + } + + @MainActor + private func toggleFavorite() async { + guard let status = await statusRecord() else { return } + + guard let authenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { return } + + do { + try await DataSourceFacade.responseToStatusFavoriteAction( + provider: self, + status: status, + authenticationBox: authenticationBox + ) + } catch { + assertionFailure() + } + } + + @MainActor + private func toggleContentWarning() async { + guard let status = await statusRecord() else { return } + + do { + try await DataSourceFacade.responseToToggleSensitiveAction( + dependency: self, + status: status + ) + } catch { + assertionFailure() + } + } + +} diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+TableViewControllerNavigateable.swift b/Mastodon/Protocol/Provider/DataSourceProvider+TableViewControllerNavigateable.swift similarity index 59% rename from Mastodon/Protocol/StatusProvider/StatusProvider+TableViewControllerNavigateable.swift rename to Mastodon/Protocol/Provider/DataSourceProvider+TableViewControllerNavigateable.swift index 8be4acd59..f7e50cff8 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+TableViewControllerNavigateable.swift +++ b/Mastodon/Protocol/Provider/DataSourceProvider+TableViewControllerNavigateable.swift @@ -1,8 +1,8 @@ // -// StatusProvider+TableViewControllerNavigateable.swift +// DataSourceProvider+TableViewControllerNavigateable.swift // Mastodon // -// Created by MainasuK Cirno on 2021-5-21. +// Created by MainasuK on 2022-2-16. // import os.log @@ -46,72 +46,58 @@ extension TableViewControllerNavigateableCore { // navigate status up/down -extension TableViewControllerNavigateableCore where Self: StatusProvider { +extension TableViewControllerNavigateableCore where Self: DataSourceProvider { func navigate(direction: TableViewNavigationDirection) { if let indexPathForSelectedRow = tableView.indexPathForSelectedRow { // navigate up/down on the current selected item - navigateToStatus(direction: direction, indexPath: indexPathForSelectedRow) + Task { + await navigateToStatus(direction: direction, indexPath: indexPathForSelectedRow) + } } else { // set first visible item selected navigateToFirstVisibleStatus() } } - private func navigateToStatus(direction: TableViewNavigationDirection, indexPath: IndexPath) { - guard let diffableDataSource = tableViewDiffableDataSource else { return } - let items = diffableDataSource.snapshot().itemIdentifiers - guard let selectedItem = diffableDataSource.itemIdentifier(for: indexPath), - let selectedItemIndex = items.firstIndex(of: selectedItem) else { - return - } - - let _navigateToItem: Item? = { - var index = selectedItemIndex - while 0..<items.count ~= index { - index = { - switch direction { - case .up: return index - 1 - case .down: return index + 1 - } - }() - guard 0..<items.count ~= index else { return nil } - let item = items[index] - - guard Self.validNavigateableItem(item) else { continue } - return item + @MainActor + private func navigateToStatus( + direction: TableViewNavigationDirection, + indexPath: IndexPath + ) async { + let row: Int = { + let index = indexPath.row + switch direction { + case .up: return index - 1 + case .down: return index + 1 } - return nil }() + let indexPath = IndexPath(row: row , section: indexPath.section) + guard indexPath.section >= 0, indexPath.section < tableView.numberOfSections, + indexPath.row >= 0, indexPath.row < tableView.numberOfRows(inSection: indexPath.section) + else { return } - guard let item = _navigateToItem, let indexPath = diffableDataSource.indexPath(for: item) else { return } let scrollPosition: UITableView.ScrollPosition = overrideNavigationScrollPosition ?? Self.navigateScrollPosition(tableView: tableView, indexPath: indexPath) tableView.selectRow(at: indexPath, animated: true, scrollPosition: scrollPosition) } private func navigateToFirstVisibleStatus() { - guard let indexPathsForVisibleRows = tableView.indexPathsForVisibleRows else { return } - guard let diffableDataSource = tableViewDiffableDataSource else { return } + guard var indexPathsForVisibleRows = tableView.indexPathsForVisibleRows?.sorted() else { return } - var visibleItems: [Item] = indexPathsForVisibleRows.sorted().compactMap { indexPath in - guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return nil } - guard Self.validNavigateableItem(item) else { return nil } - return item - } - if indexPathsForVisibleRows.first?.row != 0, visibleItems.count > 1 { + if indexPathsForVisibleRows.first?.row != 0 { // drop first when visible not the first cell of table - visibleItems.removeFirst() + indexPathsForVisibleRows.removeFirst() } - guard let item = visibleItems.first, let indexPath = diffableDataSource.indexPath(for: item) else { return } + + guard let indexPath = indexPathsForVisibleRows.first else { return } let scrollPosition: UITableView.ScrollPosition = overrideNavigationScrollPosition ?? Self.navigateScrollPosition(tableView: tableView, indexPath: indexPath) tableView.selectRow(at: indexPath, animated: true, scrollPosition: scrollPosition) } - static func validNavigateableItem(_ item: Item) -> Bool { + static func validNavigateableItem(_ item: DataSourceItem) -> Bool { switch item { - case .homeTimelineIndex, - .status, - .root, .leaf, .reply: + case .status, + .notification: return true default: return false @@ -138,10 +124,27 @@ extension TableViewControllerNavigateableCore { } -extension TableViewControllerNavigateableCore where Self: StatusProvider { +extension TableViewControllerNavigateableCore where Self: DataSourceProvider { func open() { guard let indexPathForSelectedRow = tableView.indexPathForSelectedRow else { return } - StatusProviderFacade.coordinateToStatusThreadScene(for: .primary, provider: self, indexPath: indexPathForSelectedRow) + let source = DataSourceItem.Source(indexPath: indexPathForSelectedRow) + + Task { @MainActor in + guard let item = await item(from: source) else { return } + switch item { + case .status(let record): + await DataSourceFacade.coordinateToStatusThreadScene( + provider: self, + target: .status, + status: record + ) + case .notification(let record): + assertionFailure() + default: + assertionFailure() + } + } // end Task +// StatusProviderFacade.coordinateToStatusThreadScene(for: .primary, provider: self, indexPath: indexPathForSelectedRow) } } diff --git a/Mastodon/Protocol/Provider/DataSourceProvider+UITableViewDelegate.swift b/Mastodon/Protocol/Provider/DataSourceProvider+UITableViewDelegate.swift new file mode 100644 index 000000000..3968df110 --- /dev/null +++ b/Mastodon/Protocol/Provider/DataSourceProvider+UITableViewDelegate.swift @@ -0,0 +1,274 @@ +// +// DataSourceProvider+UITableViewDelegate.swift +// Mastodon +// +// Created by MainasuK on 2022-1-17. +// + +import os.log +import UIKit +import CoreDataStack +import MastodonLocalization + +extension UITableViewDelegate where Self: DataSourceProvider { + + func aspectTableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): indexPath: \(indexPath.debugDescription)") + Task { + let source = DataSourceItem.Source(tableViewCell: nil, indexPath: indexPath) + guard let item = await item(from: source) else { + return + } + switch item { + case .status(let status): + await DataSourceFacade.coordinateToStatusThreadScene( + provider: self, + target: .status, // remove reblog wrapper + status: status + ) + case .user(let user): + await DataSourceFacade.coordinateToProfileScene( + provider: self, + user: user + ) + case .hashtag(let tag): + await DataSourceFacade.coordinateToHashtagScene( + provider: self, + tag: tag + ) + case .notification(let notification): + let managedObjectContext = context.managedObjectContext + + let _status: ManagedObjectRecord<Status>? = try await managedObjectContext.perform { + guard let notification = notification.object(in: managedObjectContext) else { return nil } + guard let status = notification.status else { return nil } + return .init(objectID: status.objectID) + } + if let status = _status { + await DataSourceFacade.coordinateToStatusThreadScene( + provider: self, + target: .status, // remove reblog wrapper + status: status + ) + } else { + let _author: ManagedObjectRecord<MastodonUser>? = try await managedObjectContext.perform { + guard let notification = notification.object(in: managedObjectContext) else { return nil } + return .init(objectID: notification.account.objectID) + } + if let author = _author { + await DataSourceFacade.coordinateToProfileScene( + provider: self, + user: author + ) + } + } + } + } // end Task + } // end func + +} + +extension UITableViewDelegate where Self: DataSourceProvider & MediaPreviewableViewController { + + func aspectTableView( + _ tableView: UITableView, + contextMenuConfigurationForRowAt + indexPath: IndexPath, point: CGPoint + ) -> UIContextMenuConfiguration? { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") + + guard let cell = tableView.cellForRow(at: indexPath) as? StatusViewContainerTableViewCell else { return nil } + + let mediaViews = cell.statusView.mediaGridContainerView.mediaViews + +// if cell.statusView.mediaGridContainerView.viewModel.isContentWarningOverlayDisplay == true { +// return nil +// } + + for (i, mediaView) in mediaViews.enumerated() { + let pointInMediaView = mediaView.convert(point, from: tableView) + guard mediaView.point(inside: pointInMediaView, with: nil) else { + continue + } + guard let image = mediaView.thumbnail(), + let assetURLString = mediaView.configuration?.assetURL, + let assetURL = URL(string: assetURLString), + let resourceType = mediaView.configuration?.resourceType + else { + // not provide preview unless thumbnail ready + return nil + } + + let contextMenuImagePreviewViewModel = ContextMenuImagePreviewViewModel( + assetURL: assetURL, + thumbnail: image, + aspectRatio: image.size + ) + + let configuration = TimelineTableViewCellContextMenuConfiguration(identifier: nil) { () -> UIViewController? in + if UIDevice.current.userInterfaceIdiom == .pad && mediaViews.count == 1 { + return nil + } + let previewProvider = ContextMenuImagePreviewViewController() + previewProvider.viewModel = contextMenuImagePreviewViewModel + return previewProvider + + } actionProvider: { _ -> UIMenu? in + return UIMenu( + title: "", + image: nil, + identifier: nil, + options: [], + children: [ + UIAction( + title: L10n.Common.Controls.Actions.savePhoto, + image: UIImage(systemName: "square.and.arrow.down"), + attributes: [], + state: .off + ) { [weak self] _ in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: save photo", ((#file as NSString).lastPathComponent), #line, #function) + guard let self = self else { return } + Task { @MainActor in + do { + try await self.context.photoLibraryService.save( + imageSource: .url(assetURL) + ).singleOutput() + } catch { + guard let error = error as? PhotoLibraryService.PhotoLibraryError, + case .noPermission = error + else { return } + let alertController = SettingService.openSettingsAlertController( + title: L10n.Common.Alerts.SavePhotoFailure.title, + message: L10n.Common.Alerts.SavePhotoFailure.message + ) + self.coordinator.present( + scene: .alertController(alertController: alertController), + from: self, + transition: .alertController(animated: true, completion: nil) + ) + } + } // end Task + }, + UIAction( + title: L10n.Common.Controls.Actions.copyPhoto, + image: UIImage(systemName: "doc.on.doc"), + identifier: nil, + discoverabilityTitle: nil, + attributes: [], + state: .off + ) { [weak self] _ in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: copy photo", ((#file as NSString).lastPathComponent), #line, #function) + guard let self = self else { return } + Task { + try await self.context.photoLibraryService.copy( + imageSource: .url(assetURL) + ).singleOutput() + } + }, + UIAction( + title: L10n.Common.Controls.Actions.share, + image: UIImage(systemName: "square.and.arrow.up")!, + identifier: nil, + discoverabilityTitle: nil, + attributes: [], + state: .off + ) { [weak self] _ in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: share", ((#file as NSString).lastPathComponent), #line, #function) + guard let self = self else { return } + Task { + let applicationActivities: [UIActivity] = [ + SafariActivity(sceneCoordinator: self.coordinator) + ] + let activityViewController = UIActivityViewController( + activityItems: [assetURL], + applicationActivities: applicationActivities + ) + activityViewController.popoverPresentationController?.sourceView = mediaView + self.present(activityViewController, animated: true, completion: nil) + } + } + ] + ) + } + configuration.indexPath = indexPath + configuration.index = i + return configuration + } // end for … in … + + return nil + } + + func aspectTableView( + _ tableView: UITableView, + previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration + ) -> UITargetedPreview? { + return aspectTableView(tableView, configuration: configuration) + } + + func aspectTableView( + _ tableView: UITableView, + previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration + ) -> UITargetedPreview? { + return aspectTableView(tableView, configuration: configuration) + } + + private func aspectTableView( + _ tableView: UITableView, + configuration: UIContextMenuConfiguration + ) -> UITargetedPreview? { + guard let configuration = configuration as? TimelineTableViewCellContextMenuConfiguration else { return nil } + guard let indexPath = configuration.indexPath, let index = configuration.index else { return nil } + if let cell = tableView.cellForRow(at: indexPath) as? StatusViewContainerTableViewCell { + let mediaViews = cell.statusView.mediaGridContainerView.mediaViews + guard index < mediaViews.count else { return nil } + let mediaView = mediaViews[index] + let parameters = UIPreviewParameters() + parameters.backgroundColor = .clear + parameters.visiblePath = UIBezierPath(roundedRect: mediaView.bounds, cornerRadius: MediaView.cornerRadius) + return UITargetedPreview(view: mediaView, parameters: parameters) + } else { + return nil + } + } + + func aspectTableView( + _ tableView: UITableView, + willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, + animator: UIContextMenuInteractionCommitAnimating + ) { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") + + guard let configuration = configuration as? TimelineTableViewCellContextMenuConfiguration else { return } + guard let indexPath = configuration.indexPath, let index = configuration.index else { return } + guard let cell = tableView.cellForRow(at: indexPath) as? StatusViewContainerTableViewCell else { return } + let mediaGridContainerView = cell.statusView.mediaGridContainerView + let mediaViews = mediaGridContainerView.mediaViews + guard index < mediaViews.count else { return } + let mediaView = mediaViews[index] + + animator.addCompletion { + Task { [weak self] in + guard let self = self else { return } + let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil) + guard let item = await self.item(from: source) else { + assertionFailure() + return + } + guard case let .status(status) = item else { + assertionFailure("only works for status data provider") + return + } + try await DataSourceFacade.coordinateToMediaPreviewScene( + dependency: self, + status: status, + previewContext: DataSourceFacade.AttachmentPreviewContext( + containerView: .mediaGridContainerView(mediaGridContainerView), + mediaView: mediaView, + index: index + ) + ) + } // end Task + } // end animator.addCompletion { … } + + } +} diff --git a/Mastodon/Protocol/Provider/DataSourceProvider.swift b/Mastodon/Protocol/Provider/DataSourceProvider.swift new file mode 100644 index 000000000..425e40417 --- /dev/null +++ b/Mastodon/Protocol/Provider/DataSourceProvider.swift @@ -0,0 +1,50 @@ +// +// DataSourceProvider.swift +// DataSourceProvider +// +// Created by Cirno MainasuK on 2021-8-30. +// Copyright © 2021 Twidere. All rights reserved. +// + +import os.log +import UIKit +import CoreDataStack +import MastodonSDK +import class CoreDataStack.Notification + +enum DataSourceItem: Hashable { + case status(record: ManagedObjectRecord<Status>) + case user(record: ManagedObjectRecord<MastodonUser>) + case hashtag(tag: TagKind) + case notification(record: ManagedObjectRecord<Notification>) +} + +extension DataSourceItem { + enum TagKind: Hashable { + case entity(Mastodon.Entity.Tag) + case record(ManagedObjectRecord<Tag>) + } +} + +extension DataSourceItem { + struct Source { + let collectionViewCell: UICollectionViewCell? + let tableViewCell: UITableViewCell? + let indexPath: IndexPath? + + init( + collectionViewCell: UICollectionViewCell? = nil, + tableViewCell: UITableViewCell? = nil, + indexPath: IndexPath? = nil + ) { + self.collectionViewCell = collectionViewCell + self.tableViewCell = tableViewCell + self.indexPath = indexPath + } + } +} + +protocol DataSourceProvider: NeedsDependency & UIViewController { + var logger: Logger { get } + func item(from source: DataSourceItem.Source) async -> DataSourceItem? +} diff --git a/Mastodon/Protocol/ScrollViewContainer.swift b/Mastodon/Protocol/ScrollViewContainer.swift index ae79d0e0f..c9f10ba3a 100644 --- a/Mastodon/Protocol/ScrollViewContainer.swift +++ b/Mastodon/Protocol/ScrollViewContainer.swift @@ -8,12 +8,12 @@ import UIKit protocol ScrollViewContainer: UIViewController { - var scrollView: UIScrollView { get } + var scrollView: UIScrollView? { get } func scrollToTop(animated: Bool) } extension ScrollViewContainer { func scrollToTop(animated: Bool) { - scrollView.scrollRectToVisible(CGRect(origin: .zero, size: CGSize(width: 1, height: 1)), animated: animated) + scrollView?.scrollRectToVisible(CGRect(origin: .zero, size: CGSize(width: 1, height: 1)), animated: animated) } } diff --git a/Mastodon/Protocol/SegmentedControlNavigateable.swift b/Mastodon/Protocol/SegmentedControlNavigateable.swift index ed76de21f..097e0b3ad 100644 --- a/Mastodon/Protocol/SegmentedControlNavigateable.swift +++ b/Mastodon/Protocol/SegmentedControlNavigateable.swift @@ -6,6 +6,8 @@ // import UIKit +import MastodonAsset +import MastodonLocalization typealias SegmentedControlNavigateable = SegmentedControlNavigateableCore & SegmentedControlNavigateableRelay diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusNodeDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusNodeDelegate.swift deleted file mode 100644 index 3c6d7da19..000000000 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusNodeDelegate.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// StatusProvider+StatusNodeDelegate.swift -// Mastodon -// -// Created by Cirno MainasuK on 2021-6-20. -// - -#if ASDK - -import Foundation - -// MARK: - StatusViewDelegate -extension StatusNodeDelegate where Self: StatusProvider { - -} - -#endif diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift deleted file mode 100644 index 5dab05295..000000000 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift +++ /dev/null @@ -1,194 +0,0 @@ -// -// StatusProvider+StatusTableViewCellDelegate.swift -// Mastodon -// -// Created by sxiaojian on 2021/2/8. -// - -import os.log -import UIKit -import Combine -import CoreData -import CoreDataStack -import MastodonSDK -import Meta -import MetaTextKit - -// MARK: - StatusViewDelegate -extension StatusTableViewCellDelegate where Self: StatusProvider { - - func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, headerInfoLabelDidPressed label: UILabel) { - StatusProviderFacade.coordinateToStatusAuthorProfileScene(for: .secondary, provider: self, cell: cell) - } - - func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, avatarImageViewDidPressed imageView: UIImageView) { - StatusProviderFacade.coordinateToStatusAuthorProfileScene(for: .primary, provider: self, cell: cell) - } - - func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta) { - StatusProviderFacade.responseToStatusMetaTextAction(provider: self, cell: cell, metaText: metaText, didSelectMeta: meta) - } - - func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, revealContentWarningButtonDidPressed button: UIButton) { - StatusProviderFacade.responseToStatusContentWarningRevealAction(provider: self, cell: cell) - } - - func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { - StatusProviderFacade.responseToStatusContentWarningRevealAction(provider: self, cell: cell) - } - -} - -// MARK: - ActionToolbarContainerDelegate -extension StatusTableViewCellDelegate where Self: StatusProvider { - - func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, replyButtonDidPressed sender: UIButton) { - StatusProviderFacade.responseToStatusReplyAction(provider: self, cell: cell) - } - - func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, reblogButtonDidPressed sender: UIButton) { - StatusProviderFacade.responseToStatusReblogAction(provider: self, cell: cell) - } - - func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, likeButtonDidPressed sender: UIButton) { - StatusProviderFacade.responseToStatusLikeAction(provider: self, cell: cell) - } - -} - -// MARK: - MosciaImageViewContainerDelegate -extension StatusTableViewCellDelegate where Self: StatusProvider { - - - func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { - StatusProviderFacade.responseToStatusContentWarningRevealAction(provider: self, cell: cell) - } - - func statusTableViewCell(_ cell: StatusTableViewCell, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { - StatusProviderFacade.responseToStatusContentWarningRevealAction(provider: self, cell: cell) - } - - func statusTableViewCell(_ cell: StatusTableViewCell, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { - StatusProviderFacade.responseToStatusContentWarningRevealAction(provider: self, cell: cell) - } - -} - -extension StatusTableViewCellDelegate where Self: StatusProvider & MediaPreviewableViewController { - func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) { - if UIAccessibility.isVoiceOverRunning, !(self is ThreadViewController) { - StatusProviderFacade.coordinateToStatusThreadScene(for: .primary, provider: self, cell: cell) - } else { - StatusProviderFacade.coordinateToStatusMediaPreviewScene(provider: self, cell: cell, mosaicImageView: mosaicImageViewContainer, didTapImageView: imageView, atIndex: index) - } - } -} - -// MARK: - PollTableView -extension StatusTableViewCellDelegate where Self: StatusProvider { - - func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, pollVoteButtonPressed button: UIButton) { - guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } - status(for: cell, indexPath: nil) - .receive(on: DispatchQueue.main) - .setFailureType(to: Error.self) - .compactMap { status -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Poll>, Error>? in - guard let status = (status?.reblog ?? status) else { return nil } - guard let poll = status.poll else { return nil } - - let votedOptions = poll.options.filter { ($0.votedBy ?? Set()).contains(where: { $0.id == activeMastodonAuthenticationBox.userID }) } - let choices = votedOptions.map { $0.index.intValue } - let domain = poll.status.domain - - button.isEnabled = false - - return self.context.apiService.vote( - domain: domain, - pollID: poll.id, - pollObjectID: poll.objectID, - choices: choices, - mastodonAuthenticationBox: activeMastodonAuthenticationBox - ) - } - .switchToLatest() - .sink(receiveCompletion: { completion in - switch completion { - case .failure(let error): - // TODO: handle error - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: multiple vote fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) - button.isEnabled = true - case .finished: - break - } - }, receiveValue: { response in - // do nothing - }) - .store(in: &context.disposeBag) - } - - func statusTableViewCell(_ cell: StatusTableViewCell, pollTableView: PollTableView, didSelectRowAt indexPath: IndexPath) { - guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } - guard let activeMastodonAuthentication = context.authenticationService.activeMastodonAuthentication.value else { return } - - guard let diffableDataSource = cell.statusView.pollTableViewDataSource else { return } - let item = diffableDataSource.itemIdentifier(for: indexPath) - guard case let .option(objectID, _) = item else { return } - guard let option = managedObjectContext.object(with: objectID) as? PollOption else { return } - - let poll = option.poll - let pollObjectID = option.poll.objectID - let domain = poll.status.domain - - if poll.multiple { - var votedOptions = poll.options.filter { ($0.votedBy ?? Set()).contains(where: { $0.id == activeMastodonAuthenticationBox.userID }) } - if votedOptions.contains(option) { - votedOptions.remove(option) - } else { - votedOptions.insert(option) - } - let choices = votedOptions.map { $0.index.intValue } - context.apiService.vote( - pollObjectID: option.poll.objectID, - mastodonUserObjectID: activeMastodonAuthentication.user.objectID, - choices: choices - ) - .handleEvents(receiveOutput: { _ in - // TODO: add haptic - }) - .receive(on: DispatchQueue.main) - .sink { completion in - // Do nothing - } receiveValue: { _ in - // Do nothing - } - .store(in: &context.disposeBag) - } else { - let choices = [option.index.intValue] - context.apiService.vote( - pollObjectID: pollObjectID, - mastodonUserObjectID: activeMastodonAuthentication.user.objectID, - choices: [option.index.intValue] - ) - .handleEvents(receiveOutput: { _ in - // TODO: add haptic - }) - .flatMap { pollID -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Poll>, Error> in - return self.context.apiService.vote( - domain: domain, - pollID: pollID, - pollObjectID: pollObjectID, - choices: choices, - mastodonAuthenticationBox: activeMastodonAuthenticationBox - ) - } - .receive(on: DispatchQueue.main) - .sink { completion in - - } receiveValue: { response in - print(response.value) - } - .store(in: &context.disposeBag) - } - } - -} diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewKeyCommandNavigateable.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewKeyCommandNavigateable.swift deleted file mode 100644 index 4503057a1..000000000 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewKeyCommandNavigateable.swift +++ /dev/null @@ -1,100 +0,0 @@ -// -// StatusProvider+KeyCommands.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-5-19. -// - -import os.log -import UIKit - -extension StatusTableViewControllerNavigateableCore where Self: StatusProvider & StatusTableViewControllerNavigateableRelay { - - var statusNavigationKeyCommands: [UIKeyCommand] { - StatusTableViewNavigation.allCases.map { navigation in - UIKeyCommand( - title: navigation.title, - image: nil, - action: #selector(Self.statusKeyCommandHandlerRelay(_:)), - input: navigation.input, - modifierFlags: navigation.modifierFlags, - propertyList: navigation.propertyList, - alternates: [], - discoverabilityTitle: nil, - attributes: [], - state: .off - ) - } - } - -} - -extension StatusTableViewControllerNavigateableCore where Self: StatusProvider { - - func statusKeyCommandHandler(_ sender: UIKeyCommand) { - guard let rawValue = sender.propertyList as? String, - let navigation = StatusTableViewNavigation(rawValue: rawValue) else { return } - - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: %s", ((#file as NSString).lastPathComponent), #line, #function, navigation.title) - switch navigation { - case .openAuthorProfile: openAuthorProfile() - case .openRebloggerProfile: openRebloggerProfile() - case .replyStatus: replyStatus() - case .toggleReblog: toggleReblog() - case .toggleFavorite: toggleFavorite() - case .toggleContentWarning: toggleContentWarning() - case .previewImage: previewImage() - } - } - -} - -// status coordinate -extension StatusTableViewControllerNavigateableCore where Self: StatusProvider { - - private func openAuthorProfile() { - guard let indexPathForSelectedRow = tableView.indexPathForSelectedRow else { return } - StatusProviderFacade.coordinateToStatusAuthorProfileScene(for: .primary, provider: self, indexPath: indexPathForSelectedRow) - } - - private func openRebloggerProfile() { - guard let indexPathForSelectedRow = tableView.indexPathForSelectedRow else { return } - StatusProviderFacade.coordinateToStatusAuthorProfileScene(for: .secondary, provider: self, indexPath: indexPathForSelectedRow) - } - - private func replyStatus() { - guard let indexPathForSelectedRow = tableView.indexPathForSelectedRow else { return } - StatusProviderFacade.responseToStatusReplyAction(provider: self, indexPath: indexPathForSelectedRow) - } - - private func previewImage() { - guard let indexPathForSelectedRow = tableView.indexPathForSelectedRow else { return } - guard let provider = self as? (StatusProvider & MediaPreviewableViewController) else { return } - guard let cell = tableView.cellForRow(at: indexPathForSelectedRow), - let presentable = cell as? MosaicImageViewContainerPresentable else { return } - let mosaicImageView = presentable.mosaicImageViewContainer - guard let imageView = mosaicImageView.imageViews.first else { return } - StatusProviderFacade.coordinateToStatusMediaPreviewScene(provider: provider, cell: cell, mosaicImageView: mosaicImageView, didTapImageView: imageView, atIndex: 0) - } - -} - -// toggle -extension StatusTableViewControllerNavigateableCore where Self: StatusProvider { - - private func toggleReblog() { - guard let indexPathForSelectedRow = tableView.indexPathForSelectedRow else { return } - StatusProviderFacade.responseToStatusReblogAction(provider: self, indexPath: indexPathForSelectedRow) - } - - private func toggleFavorite() { - guard let indexPathForSelectedRow = tableView.indexPathForSelectedRow else { return } - StatusProviderFacade.responseToStatusLikeAction(provider: self, indexPath: indexPathForSelectedRow) - } - - private func toggleContentWarning() { - guard let indexPathForSelectedRow = tableView.indexPathForSelectedRow else { return } - StatusProviderFacade.responseToStatusContentWarningRevealAction(provider: self, indexPath: indexPathForSelectedRow) - } - -} diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDataSourcePrefetching.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDataSourcePrefetching.swift deleted file mode 100644 index 537f10c8c..000000000 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDataSourcePrefetching.swift +++ /dev/null @@ -1,58 +0,0 @@ -// -// StatusProvider+UITableViewDataSourcePrefetching.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-3-10. -// - -import UIKit -import CoreData -import CoreDataStack - -extension StatusTableViewCellDelegate where Self: StatusProvider { - func handleTableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { - let statusObjectItems = self.statusObjectItems(indexPaths: indexPaths) - self.context.statusPrefetchingService.prefetch(statusObjectItems: statusObjectItems) - - // prefetch reply status - guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } - let domain = activeMastodonAuthenticationBox.domain - let items = self.items(indexPaths: indexPaths) - - let managedObjectContext = context.managedObjectContext - managedObjectContext.perform { [weak self] in - guard let self = self else { return } - - var statuses: [Status] = [] - for item in items { - switch item { - case .homeTimelineIndex(let objectID, _): - guard let homeTimelineIndex = try? managedObjectContext.existingObject(with: objectID) as? HomeTimelineIndex else { continue } - statuses.append(homeTimelineIndex.status) - case .status(let objectID, _): - guard let status = try? managedObjectContext.existingObject(with: objectID) as? Status else { continue } - statuses.append(status) - default: - continue - } - } - - for status in statuses { - if let replyToID = status.inReplyToID, status.replyTo == nil { - self.context.statusPrefetchingService.prefetchReplyTo( - domain: domain, - statusObjectID: status.objectID, - statusID: status.id, - replyToStatusID: replyToID, - authorizationBox: activeMastodonAuthenticationBox - ) - } - } // end for in - } // end context.perform - } // end func - - func handleTableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) { - let statusObjectItems = self.statusObjectItems(indexPaths: indexPaths) - self.context.statusPrefetchingService.cancelPrefetch(statusObjectItems: statusObjectItems) - } -} diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift deleted file mode 100644 index 1abfcf70b..000000000 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift +++ /dev/null @@ -1,393 +0,0 @@ -// -// StatusProvider+UITableViewDelegate.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-3-3. -// - -import Combine -import CoreDataStack -import MastodonSDK -import os.log -import UIKit - -extension StatusTableViewCellDelegate where Self: StatusProvider { - - func handleTableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { - // update poll when status appear - let now = Date() - var pollID: Mastodon.Entity.Poll.ID? - status(for: cell, indexPath: indexPath) - .compactMap { [weak self] status -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Poll>, Error>? in - guard let self = self else { return nil } - guard let authenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { return nil } - guard let status = (status?.reblog ?? status) else { return nil } - guard let poll = status.poll else { return nil } - pollID = poll.id - - // not expired AND last update > 60s - guard !poll.expired else { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: poll %s expired. Skip for update", (#file as NSString).lastPathComponent, #line, #function, poll.id) - return nil - } - let timeIntervalSinceUpdate = now.timeIntervalSince(poll.updatedAt) - #if DEBUG - let autoRefreshTimeInterval: TimeInterval = 3 // speedup testing - #else - let autoRefreshTimeInterval: TimeInterval = 60 - #endif - guard timeIntervalSinceUpdate > autoRefreshTimeInterval else { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: poll %s updated in the %.2fs. Skip for update", (#file as NSString).lastPathComponent, #line, #function, poll.id, timeIntervalSinceUpdate) - return nil - } - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: poll %s info update…", (#file as NSString).lastPathComponent, #line, #function, poll.id) - - return self.context.apiService.poll( - domain: status.domain, - pollID: poll.id, - pollObjectID: poll.objectID, - mastodonAuthenticationBox: authenticationBox - ) - } - .setFailureType(to: Error.self) - .switchToLatest() - .receive(on: DispatchQueue.main) - .sink(receiveCompletion: { completion in - switch completion { - case .failure(let error): - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: poll %s info fail to update: %s", (#file as NSString).lastPathComponent, #line, #function, pollID ?? "?", error.localizedDescription) - case .finished: - break - } - }, receiveValue: { response in - let poll = response.value - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: poll %s info updated", (#file as NSString).lastPathComponent, #line, #function, poll.id) - }) - .store(in: &disposeBag) - - status(for: cell, indexPath: indexPath) - .receive(on: RunLoop.main) - .sink { [weak self] status in - guard let self = self else { return } - let status = status?.reblog ?? status - guard let media = (status?.mediaAttachments ?? Set()).first else { return } - guard let videoPlayerViewModel = self.context.videoPlaybackService.dequeueVideoPlayerViewModel(for: media) else { return } - - DispatchQueue.main.async { - videoPlayerViewModel.willDisplay() - } - } - .store(in: &disposeBag) - } - - func handleTableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { - // os_log("%{public}s[%{public}ld], %{public}s: indexPath %s", ((#file as NSString).lastPathComponent), #line, #function, indexPath.debugDescription) - - status(for: cell, indexPath: indexPath) - .sink { [weak self] status in - guard let self = self else { return } - guard let media = (status?.mediaAttachments ?? Set()).first else { return } - - if let videoPlayerViewModel = self.context.videoPlaybackService.dequeueVideoPlayerViewModel(for: media) { - DispatchQueue.main.async { - videoPlayerViewModel.didEndDisplaying() - } - } - if let currentAudioAttachment = self.context.audioPlaybackService.attachment, - status?.mediaAttachments?.contains(currentAudioAttachment) == true { - self.context.audioPlaybackService.pause() - } - } - .store(in: &disposeBag) - } - - func handleTableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - StatusProviderFacade.coordinateToStatusThreadScene(for: .primary, provider: self, indexPath: indexPath) - } - -} - -extension StatusTableViewCellDelegate where Self: StatusProvider { - - private typealias ImagePreviewPresentableCell = UITableViewCell & DisposeBagCollectable & MosaicImageViewContainerPresentable - - func handleTableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { - guard let imagePreviewPresentableCell = tableView.cellForRow(at: indexPath) as? ImagePreviewPresentableCell else { return nil } - guard imagePreviewPresentableCell.isRevealing else { return nil } - - let status = self.status(for: nil, indexPath: indexPath) - - return contextMenuConfiguration(tableView, status: status, imagePreviewPresentableCell: imagePreviewPresentableCell, contextMenuConfigurationForRowAt: indexPath, point: point) - } - - private func contextMenuConfiguration( - _ tableView: UITableView, - status: Future<Status?, Never>, - imagePreviewPresentableCell presentable: ImagePreviewPresentableCell, - contextMenuConfigurationForRowAt indexPath: IndexPath, - point: CGPoint - ) -> UIContextMenuConfiguration? { - let imageViews = presentable.mosaicImageViewContainer.imageViews - guard !imageViews.isEmpty else { return nil } - - for (i, imageView) in imageViews.enumerated() { - let pointInImageView = imageView.convert(point, from: tableView) - guard imageView.point(inside: pointInImageView, with: nil) else { - continue - } - guard let image = imageView.image, image.size != CGSize(width: 1, height: 1) else { - // not provide preview until image ready - return nil - - } - // setup preview - let contextMenuImagePreviewViewModel = ContextMenuImagePreviewViewModel(aspectRatio: image.size, thumbnail: image) - status - .sink { status in - guard let status = (status?.reblog ?? status), - let media = status.mediaAttachments?.sorted(by:{ $0.index.compare($1.index) == .orderedAscending }), - i < media.count, let url = URL(string: media[i].url) else { - return - } - - contextMenuImagePreviewViewModel.url.value = url - } - .store(in: &contextMenuImagePreviewViewModel.disposeBag) - - // setup context menu - let contextMenuConfiguration = TimelineTableViewCellContextMenuConfiguration(identifier: nil) { () -> UIViewController? in - // know issue: preview size looks not as large as system default preview - let previewProvider = ContextMenuImagePreviewViewController() - previewProvider.viewModel = contextMenuImagePreviewViewModel - return previewProvider - } actionProvider: { _ -> UIMenu? in - let savePhotoAction = UIAction( - title: L10n.Common.Controls.Actions.savePhoto, image: UIImage(systemName: "square.and.arrow.down")!, identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off - ) { [weak self] _ in - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: save photo", ((#file as NSString).lastPathComponent), #line, #function) - guard let self = self else { return } - self.attachment(of: status, index: i) - .setFailureType(to: Error.self) - .compactMap { attachment -> AnyPublisher<Void, Error>? in - guard let attachment = attachment, let url = URL(string: attachment.url) else { return nil } - return self.context.photoLibraryService.save(imageSource: .url(url)) - } - .switchToLatest() - .sink(receiveCompletion: { [weak self] completion in - guard let self = self else { return } - switch completion { - case .failure(let error): - guard let error = error as? PhotoLibraryService.PhotoLibraryError, - case .noPermission = error else { return } - let alertController = SettingService.openSettingsAlertController(title: L10n.Common.Alerts.SavePhotoFailure.title, message: L10n.Common.Alerts.SavePhotoFailure.message) - self.coordinator.present(scene: .alertController(alertController: alertController), from: self, transition: .alertController(animated: true, completion: nil)) - case .finished: - break - } - }, receiveValue: { _ in - // do nothing - }) - .store(in: &self.context.disposeBag) - } - let copyPhotoAction = UIAction( - title: L10n.Common.Controls.Actions.copyPhoto, - image: UIImage(systemName: "doc.on.doc"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off - ) { [weak self] _ in - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: copy photo", ((#file as NSString).lastPathComponent), #line, #function) - guard let self = self else { return } - self.attachment(of: status, index: i) - .setFailureType(to: Error.self) - .compactMap { attachment -> AnyPublisher<Void, Error>? in - guard let attachment = attachment, let url = URL(string: attachment.url) else { return nil } - return self.context.photoLibraryService.copy(imageSource: .url(url)) - } - .switchToLatest() - .sink(receiveCompletion: { completion in - switch completion { - case .failure(let error): - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: copy photo fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) - case .finished: - break - } - }, receiveValue: { _ in - // do nothing - }) - .store(in: &self.context.disposeBag) - } - let shareAction = UIAction( - title: L10n.Common.Controls.Actions.share, image: UIImage(systemName: "square.and.arrow.up")!, identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off - ) { [weak self] _ in - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: share", ((#file as NSString).lastPathComponent), #line, #function) - guard let self = self else { return } - self.attachment(of: status, index: i) - .sink(receiveValue: { [weak self] attachment in - guard let self = self else { return } - guard let attachment = attachment, let url = URL(string: attachment.url) else { return } - let applicationActivities: [UIActivity] = [ - SafariActivity(sceneCoordinator: self.coordinator) - ] - let activityViewController = UIActivityViewController( - activityItems: [url], - applicationActivities: applicationActivities - ) - activityViewController.popoverPresentationController?.sourceView = imageView - self.present(activityViewController, animated: true, completion: nil) - }) - .store(in: &self.context.disposeBag) - } - let children = [savePhotoAction, copyPhotoAction, shareAction] - return UIMenu(title: "", image: nil, children: children) - } - contextMenuConfiguration.indexPath = indexPath - contextMenuConfiguration.index = i - return contextMenuConfiguration - } - - return nil - } - - private func attachment(of status: Future<Status?, Never>, index: Int) -> AnyPublisher<Attachment?, Never> { - status - .map { status in - guard let status = status?.reblog ?? status else { return nil } - guard let media = status.mediaAttachments?.sorted(by: { $0.index.compare($1.index) == .orderedAscending }) else { return nil } - guard index < media.count else { return nil } - return media[index] - } - .eraseToAnyPublisher() - } - - func handleTableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { - return _handleTableView(tableView, configuration: configuration) - } - - func handleTableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { - return _handleTableView(tableView, configuration: configuration) - } - - private func _handleTableView(_ tableView: UITableView, configuration: UIContextMenuConfiguration) -> UITargetedPreview? { - guard let configuration = configuration as? TimelineTableViewCellContextMenuConfiguration else { return nil } - guard let indexPath = configuration.indexPath, let index = configuration.index else { return nil } - guard let cell = tableView.cellForRow(at: indexPath) as? ImagePreviewPresentableCell else { - return nil - } - let imageViews = cell.mosaicImageViewContainer.imageViews - guard index < imageViews.count else { return nil } - let imageView = imageViews[index] - return UITargetedPreview(view: imageView, parameters: UIPreviewParameters()) - } - - func handleTableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { - guard let previewableViewController = self as? MediaPreviewableViewController else { return } - guard let configuration = configuration as? TimelineTableViewCellContextMenuConfiguration else { return } - guard let indexPath = configuration.indexPath, let index = configuration.index else { return } - guard let cell = tableView.cellForRow(at: indexPath) as? ImagePreviewPresentableCell else { return } - let imageViews = cell.mosaicImageViewContainer.imageViews - guard index < imageViews.count else { return } - let imageView = imageViews[index] - - let status = self.status(for: nil, indexPath: indexPath) - let initialFrame: CGRect? = { - guard let previewViewController = animator.previewViewController else { return nil } - return UIView.findContextMenuPreviewFrameInWindow(previewController: previewViewController) - }() - animator.preferredCommitStyle = .pop - animator.addCompletion { [weak self] in - guard let self = self else { return } - status - //.delay(for: .milliseconds(500), scheduler: DispatchQueue.main) - .sink { [weak self] status in - guard let self = self else { return } - guard let status = (status?.reblog ?? status) else { return } - - let meta = MediaPreviewViewModel.StatusImagePreviewMeta( - statusObjectID: status.objectID, - initialIndex: index, - preloadThumbnailImages: cell.mosaicImageViewContainer.thumbnails() - ) - let pushTransitionItem = MediaPreviewTransitionItem( - source: .mosaic(cell.mosaicImageViewContainer), - previewableViewController: previewableViewController - ) - pushTransitionItem.aspectRatio = { - if let image = imageView.image { - return image.size - } - guard let media = status.mediaAttachments?.sorted(by: { $0.index.compare($1.index) == .orderedAscending }) else { return nil } - guard index < media.count else { return nil } - let meta = media[index].meta - guard let width = meta?.original?.width, let height = meta?.original?.height else { return nil } - return CGSize(width: width, height: height) - }() - pushTransitionItem.sourceImageView = imageView - pushTransitionItem.initialFrame = { - if let initialFrame = initialFrame { - return initialFrame - } - return imageView.superview!.convert(imageView.frame, to: nil) - }() - pushTransitionItem.image = { - if let image = imageView.image { - return image - } - if index < cell.mosaicImageViewContainer.blurhashOverlayImageViews.count { - return cell.mosaicImageViewContainer.blurhashOverlayImageViews[index].image - } - - return nil - }() - let mediaPreviewViewModel = MediaPreviewViewModel( - context: self.context, - meta: meta, - pushTransitionItem: pushTransitionItem - ) - DispatchQueue.main.async { - self.coordinator.present(scene: .mediaPreview(viewModel: mediaPreviewViewModel), from: self, transition: .custom(transitioningDelegate: previewableViewController.mediaPreviewTransitionController)) - } - } - .store(in: &cell.disposeBag) - } - } - - - - -} - -extension UIView { - - // hack to retrieve preview view frame in window - fileprivate static func findContextMenuPreviewFrameInWindow( - previewController: UIViewController - ) -> CGRect? { - guard let window = previewController.view.window else { return nil } - - let targetViews = window.subviews - .map { $0.findSameSize(view: previewController.view) } - .flatMap { $0 } - for targetView in targetViews { - guard let targetViewSuperview = targetView.superview else { continue } - let frame = targetViewSuperview.convert(targetView.frame, to: nil) - guard frame.origin.x > 0, frame.origin.y > 0 else { continue } - return frame - } - - return nil - } - - private func findSameSize(view: UIView) -> [UIView] { - var views: [UIView] = [] - - if view.bounds.size == bounds.size { - views.append(self) - } - - for subview in subviews { - let targetViews = subview.findSameSize(view: view) - views.append(contentsOf: targetViews) - } - - return views - } - -} diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider.swift b/Mastodon/Protocol/StatusProvider/StatusProvider.swift deleted file mode 100644 index 3497fd7a8..000000000 --- a/Mastodon/Protocol/StatusProvider/StatusProvider.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// StatusProvider.swift -// Mastodon -// -// Created by sxiaojian on 2021/2/5. -// - -import UIKit -import Combine -import CoreData -import CoreDataStack - -#if ASDK -import AsyncDisplayKit -#endif - -protocol StatusProvider: NeedsDependency & DisposeBagCollectable & UIViewController { - // async - func status() -> Future<Status?, Never> - func status(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future<Status?, Never> - func status(for cell: UICollectionViewCell) -> Future<Status?, Never> - - // sync - var managedObjectContext: NSManagedObjectContext { get } - - @available(*, deprecated) - var tableViewDiffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>? { get } - @available(*, deprecated) - func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item? - @available(*, deprecated) - func items(indexPaths: [IndexPath]) -> [Item] - - func statusObjectItems(indexPaths: [IndexPath]) -> [StatusObjectItem] - - #if ASDK - func status(node: ASCellNode?, indexPath: IndexPath?) -> Status? - #endif -} - -#if ASDK -extension StatusProvider { - func status(node: ASCellNode?, indexPath: IndexPath?) -> Status? { - fatalError("Needs implement this") - } -} -#endif - -enum StatusObjectItem { - case status(objectID: NSManagedObjectID) - case homeTimelineIndex(objectID: NSManagedObjectID) - case mastodonNotification(objectID: NSManagedObjectID) // may not contains status -} diff --git a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift deleted file mode 100644 index d11870ed2..000000000 --- a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift +++ /dev/null @@ -1,647 +0,0 @@ -// -// StatusProviderFacade.swift -// Mastodon -// -// Created by sxiaojian on 2021/2/8. -// - -import os.log -import UIKit -import Combine -import CoreData -import CoreDataStack -import MastodonSDK -import Meta -import MetaTextKit - -#if ASDK -import AsyncDisplayKit -#endif - -enum StatusProviderFacade { } - -extension StatusProviderFacade { - - static func coordinateToStatusAuthorProfileScene(for target: Target, provider: StatusProvider) { - _coordinateToStatusAuthorProfileScene( - for: target, - provider: provider, - status: provider.status() - ) - } - - static func coordinateToStatusAuthorProfileScene(for target: Target, provider: StatusProvider, indexPath: IndexPath) { - _coordinateToStatusAuthorProfileScene( - for: target, - provider: provider, - status: provider.status(for: nil, indexPath: indexPath) - ) - } - - static func coordinateToStatusAuthorProfileScene(for target: Target, provider: StatusProvider, cell: UITableViewCell) { - _coordinateToStatusAuthorProfileScene( - for: target, - provider: provider, - status: provider.status(for: cell, indexPath: nil) - ) - } - - private static func _coordinateToStatusAuthorProfileScene(for target: Target, provider: StatusProvider, status: Future<Status?, Never>) { - status - .sink { [weak provider] status in - guard let provider = provider else { return } - let _status: Status? = { - switch target { - case .primary: return status?.reblog ?? status // original status - case .secondary: return status?.replyTo ?? status // reblog or reply to status - } - }() - guard let status = _status else { return } - - let mastodonUser = status.author - 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) - } - -} - -extension StatusProviderFacade { - - static func coordinateToStatusThreadScene(for target: Target, provider: StatusProvider, indexPath: IndexPath) { - _coordinateToStatusThreadScene( - for: target, - provider: provider, - status: provider.status(for: nil, indexPath: indexPath) - ) - } - - static func coordinateToStatusThreadScene(for target: Target, provider: StatusProvider, cell: UITableViewCell) { - _coordinateToStatusThreadScene( - for: target, - provider: provider, - status: provider.status(for: cell, indexPath: nil) - ) - } - - private static func _coordinateToStatusThreadScene(for target: Target, provider: StatusProvider, status: Future<Status?, Never>) { - status - .sink { [weak provider] status in - guard let provider = provider else { return } - let _status: Status? = { - switch target { - case .primary: return status?.reblog ?? status // original status - case .secondary: return status // reblog or status - } - }() - guard let status = _status else { return } - - let threadViewModel = CachedThreadViewModel(context: provider.context, status: status) - DispatchQueue.main.async { - if provider.navigationController == nil { - let from = provider.presentingViewController ?? provider - provider.dismiss(animated: true) { - provider.coordinator.present(scene: .thread(viewModel: threadViewModel), from: from, transition: .show) - } - } else { - provider.coordinator.present(scene: .thread(viewModel: threadViewModel), from: provider, transition: .show) - } - } - } - .store(in: &provider.disposeBag) - } - -} - -extension StatusProviderFacade { - - static func responseToStatusMetaTextAction(provider: StatusProvider, cell: UITableViewCell, metaText: MetaText, didSelectMeta meta: Meta) { - switch meta { - case .url(_, _, let url, _), - .mention(_, let url, _) where url.lowercased().hasPrefix("http"): - // note: - // some server mark the normal url as "u-url" class. highlighted content is a URL - guard let url = URL(string: url) else { return } - if let domain = provider.context.authenticationService.activeMastodonAuthenticationBox.value?.domain, url.host == domain, - url.pathComponents.count >= 4, - url.pathComponents[0] == "/", - url.pathComponents[1] == "web", - url.pathComponents[2] == "statuses" { - let statusID = url.pathComponents[3] - let threadViewModel = RemoteThreadViewModel(context: provider.context, statusID: statusID) - provider.coordinator.present(scene: .thread(viewModel: threadViewModel), from: nil, transition: .show) - } else { - provider.coordinator.present(scene: .safari(url: url), from: nil, transition: .safariPresent(animated: true, completion: nil)) - } - case .hashtag(_, let hashtag, _): - let hashtagTimelineViewModel = HashtagTimelineViewModel(context: provider.context, hashtag: hashtag) - provider.coordinator.present(scene: .hashtagTimeline(viewModel: hashtagTimelineViewModel), from: provider, transition: .show) - case .mention(_, let mention, let userInfo): - let href = userInfo?["href"] as? String - coordinateToStatusMentionProfileScene(for: .primary, provider: provider, cell: cell, mention: mention, href: href) - default: - break - } - } - - #if ASDK - private static func coordinateToStatusMentionProfileScene(for target: Target, provider: StatusProvider, node: ASCellNode, mention: String) { - guard let status = provider.status(node: node, indexPath: nil) else { return } - coordinateToStatusMentionProfileScene(for: target, provider: provider, status: status, mention: mention, href: nil) - } - #endif - - private static func coordinateToStatusMentionProfileScene(for target: Target, provider: StatusProvider, cell: UITableViewCell, mention: String, href: String?) { - provider.status(for: cell, indexPath: nil) - .sink { [weak provider] status in - guard let provider = provider else { return } - guard let status = status else { return } - coordinateToStatusMentionProfileScene(for: target, provider: provider, status: status, mention: mention, href: href) - } - .store(in: &provider.disposeBag) - } - - private static func coordinateToStatusMentionProfileScene(for target: Target, provider: StatusProvider, status: Status, mention: String, href: String?) { - guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { return } - let domain = activeMastodonAuthenticationBox.domain - - let status: Status = { - switch target { - case .primary: return status.reblog ?? status - case .secondary: return status - } - }() - - // cannot continue without meta - guard let mentionMeta = (status.mentions ?? Set()).first(where: { $0.username == mention }) else { - // present web page if possible - if let url = href.flatMap({ URL(string: $0) }) { - provider.coordinator.present(scene: .safari(url: url), from: provider, transition: .safariPresent(animated: true, completion: nil)) - } - return - } - - let userID = mentionMeta.id - - let profileViewModel: ProfileViewModel = { - // check if self - guard userID != activeMastodonAuthenticationBox.userID else { - return MeProfileViewModel(context: provider.context) - } - - let request = MastodonUser.sortedFetchRequest - request.fetchLimit = 1 - request.predicate = MastodonUser.predicate(domain: domain, id: userID) - let mastodonUser = provider.context.managedObjectContext.safeFetch(request).first - - if let mastodonUser = mastodonUser { - return CachedProfileViewModel(context: provider.context, mastodonUser: mastodonUser) - } else { - return RemoteProfileViewModel(context: provider.context, userID: userID) - } - }() - - DispatchQueue.main.async { - provider.coordinator.present(scene: .profile(viewModel: profileViewModel), from: provider, transition: .show) - } - } -} - -extension StatusProviderFacade { - - static func responseToStatusLikeAction(provider: StatusProvider) { - _responseToStatusLikeAction( - provider: provider, - status: provider.status() - ) - } - - static func responseToStatusLikeAction(provider: StatusProvider, cell: UITableViewCell) { - _responseToStatusLikeAction( - provider: provider, - status: provider.status(for: cell, indexPath: nil) - ) - } - - static func responseToStatusLikeAction(provider: StatusProvider, indexPath: IndexPath) { - _responseToStatusLikeAction( - provider: provider, - status: provider.status(for: nil, indexPath: indexPath) - ) - } - - private static func _responseToStatusLikeAction(provider: StatusProvider, status: Future<Status?, Never>) { - // prepare authentication - guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { - assertionFailure() - return - } - - // prepare current user infos - guard let _currentMastodonUser = provider.context.authenticationService.activeMastodonAuthentication.value?.user else { - assertionFailure() - return - } - let mastodonUserID = activeMastodonAuthenticationBox.userID - assert(_currentMastodonUser.id == mastodonUserID) - let mastodonUserObjectID = _currentMastodonUser.objectID - - guard let context = provider.context else { return } - - // haptic feedback generator - let generator = UISelectionFeedbackGenerator() - // let responseFeedbackGenerator = UINotificationFeedbackGenerator() - - status - .compactMap { status -> (NSManagedObjectID, Mastodon.API.Favorites.FavoriteKind)? in - guard let status = status?.reblog ?? status else { return nil } - let favoriteKind: Mastodon.API.Favorites.FavoriteKind = { - let isLiked = status.favouritedBy.flatMap { $0.contains(where: { $0.id == mastodonUserID }) } ?? false - return isLiked ? .destroy : .create - }() - return (status.objectID, favoriteKind) - } - .map { statusObjectID, favoriteKind -> AnyPublisher<(Status.ID, Mastodon.API.Favorites.FavoriteKind), Error> in - return context.apiService.favorite( - statusObjectID: statusObjectID, - mastodonUserObjectID: mastodonUserObjectID, - favoriteKind: favoriteKind - ) - .map { statusID in (statusID, favoriteKind) } - .eraseToAnyPublisher() - } - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - .switchToLatest() - .receive(on: DispatchQueue.main) - .handleEvents(receiveSubscription: { _ in - generator.prepare() - }, receiveOutput: { _, favoriteKind in - 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() - switch completion { - case .failure: - // TODO: handle error - break - case .finished: - break - } - }) - .map { statusID, favoriteKind in - return context.apiService.favorite( - statusID: statusID, - favoriteKind: favoriteKind, - mastodonAuthenticationBox: activeMastodonAuthenticationBox - ) - } - .switchToLatest() - .receive(on: DispatchQueue.main) - .sink { [weak provider] completion in - guard let _ = provider else { return } - switch completion { - case .failure(let 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) - os_log("%{public}s[%{public}ld], %{public}s: [Like] remote like request success", ((#file as NSString).lastPathComponent), #line, #function) - } - } receiveValue: { response in - // do nothing - } - .store(in: &provider.disposeBag) - } - -} - -extension StatusProviderFacade { - - static func responseToStatusReblogAction(provider: StatusProvider) { - _responseToStatusReblogAction( - provider: provider, - status: provider.status() - ) - } - - static func responseToStatusReblogAction(provider: StatusProvider, cell: UITableViewCell) { - _responseToStatusReblogAction( - provider: provider, - status: provider.status(for: cell, indexPath: nil) - ) - } - - static func responseToStatusReblogAction(provider: StatusProvider, indexPath: IndexPath) { - _responseToStatusReblogAction( - provider: provider, - status: provider.status(for: nil, indexPath: indexPath) - ) - } - - private static func _responseToStatusReblogAction(provider: StatusProvider, status: Future<Status?, Never>) { - // prepare authentication - guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { - assertionFailure() - return - } - - // prepare current user infos - guard let _currentMastodonUser = provider.context.authenticationService.activeMastodonAuthentication.value?.user else { - assertionFailure() - return - } - let mastodonUserID = activeMastodonAuthenticationBox.userID - assert(_currentMastodonUser.id == mastodonUserID) - let mastodonUserObjectID = _currentMastodonUser.objectID - - guard let context = provider.context else { return } - - // haptic feedback generator - let generator = UISelectionFeedbackGenerator() - // let responseFeedbackGenerator = UINotificationFeedbackGenerator() - - status - .compactMap { status -> (NSManagedObjectID, Mastodon.API.Reblog.ReblogKind)? in - guard let status = status?.reblog ?? status else { return nil } - let reblogKind: Mastodon.API.Reblog.ReblogKind = { - let isReblogged = status.rebloggedBy.flatMap { $0.contains(where: { $0.id == mastodonUserID }) } ?? false - return isReblogged ? .undoReblog : .reblog(query: .init(visibility: nil)) - }() - return (status.objectID, reblogKind) - } - .map { statusObjectID, reblogKind -> AnyPublisher<(Status.ID, Mastodon.API.Reblog.ReblogKind), Error> in - return context.apiService.reblog( - statusObjectID: statusObjectID, - mastodonUserObjectID: mastodonUserObjectID, - reblogKind: reblogKind - ) - .map { statusID in (statusID, reblogKind) } - .eraseToAnyPublisher() - } - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - .switchToLatest() - .receive(on: DispatchQueue.main) - .handleEvents(receiveSubscription: { _ in - generator.prepare() - }, receiveOutput: { _, reblogKind in - 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") - case .undoReblog: - 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() - switch completion { - case .failure: - // TODO: handle error - break - case .finished: - break - } - }) - .map { statusID, reblogKind in - return context.apiService.reblog( - statusID: statusID, - reblogKind: reblogKind, - mastodonAuthenticationBox: activeMastodonAuthenticationBox - ) - } - .switchToLatest() - .receive(on: DispatchQueue.main) - .sink { [weak provider] completion in - guard let _ = provider else { return } - switch completion { - case .failure(let 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) - os_log("%{public}s[%{public}ld], %{public}s: [Reblog] remote reblog request success", ((#file as NSString).lastPathComponent), #line, #function) - } - } receiveValue: { response in - // do nothing - } - .store(in: &provider.disposeBag) - } - -} - -extension StatusProviderFacade { - - static func responseToStatusReplyAction(provider: StatusProvider) { - _responseToStatusReplyAction( - provider: provider, - status: provider.status() - ) - } - - static func responseToStatusReplyAction(provider: StatusProvider, cell: UITableViewCell) { - _responseToStatusReplyAction( - provider: provider, - status: provider.status(for: cell, indexPath: nil) - ) - } - - static func responseToStatusReplyAction(provider: StatusProvider, indexPath: IndexPath) { - _responseToStatusReplyAction( - provider: provider, - status: provider.status(for: nil, indexPath: indexPath) - ) - } - - private static func _responseToStatusReplyAction(provider: StatusProvider, status: Future<Status?, Never>) { - status - .sink { [weak provider] status in - guard let provider = provider else { return } - guard let status = status?.reblog ?? status else { return } - - 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)) - } - .store(in: &provider.context.disposeBag) - - } - -} - -extension StatusProviderFacade { - - static func responseToStatusContentWarningRevealAction(provider: StatusProvider, cell: UITableViewCell) { - _responseToStatusContentWarningRevealAction( - dependency: provider, - status: provider.status(for: cell, indexPath: nil) - ) - } - - static func responseToStatusContentWarningRevealAction(provider: StatusProvider, indexPath: IndexPath) { - _responseToStatusContentWarningRevealAction( - dependency: provider, - status: provider.status(for: nil, indexPath: indexPath) - ) - } - - private static func _responseToStatusContentWarningRevealAction(dependency: NeedsDependency, status: Future<Status?, Never>) { - status - .compactMap { [weak dependency] status -> AnyPublisher<Status?, Never>? in - guard let dependency = dependency else { return nil } - guard let _status = status else { return nil } - let managedObjectContext = dependency.context.backgroundManagedObjectContext - return managedObjectContext.performChanges { - guard let status = managedObjectContext.object(with: _status.objectID) as? Status else { return } - let appStartUpTimestamp = dependency.context.documentStore.appStartUpTimestamp - let isRevealing: Bool = { - if dependency.context.documentStore.defaultRevealStatusDict[status.id] == true { - return true - } - if status.reblog.flatMap({ dependency.context.documentStore.defaultRevealStatusDict[$0.id] }) == true { - return true - } - if let revealedAt = status.revealedAt, revealedAt > appStartUpTimestamp { - return true - } - - return false - }() - // toggle reveal - dependency.context.documentStore.defaultRevealStatusDict[status.id] = false - status.update(isReveal: !isRevealing) - - if let reblog = status.reblog { - dependency.context.documentStore.defaultRevealStatusDict[reblog.id] = false - reblog.update(isReveal: !isRevealing) - } - - // pause video playback if isRevealing before toggle - if isRevealing, let attachment = (status.reblog ?? status).mediaAttachments?.first, - let playerViewModel = dependency.context.videoPlaybackService.dequeueVideoPlayerViewModel(for: attachment) { - playerViewModel.pause() - } - // resume GIF playback if NOT isRevealing before toggle - if !isRevealing, let attachment = (status.reblog ?? status).mediaAttachments?.first, - let playerViewModel = dependency.context.videoPlaybackService.dequeueVideoPlayerViewModel(for: attachment), playerViewModel.videoKind == .gif { - playerViewModel.play() - } - } - .map { result in - return status - } - .eraseToAnyPublisher() - } - .sink { _ in - // do nothing - } - .store(in: &dependency.context.disposeBag) - } - - static func responseToStatusContentWarningRevealAction(dependency: ReportViewController, cell: UITableViewCell) { - let status = Future<Status?, Never> { promise in - guard let diffableDataSource = dependency.viewModel.diffableDataSource, - let indexPath = dependency.tableView.indexPath(for: cell), - let item = diffableDataSource.itemIdentifier(for: indexPath) else { - promise(.success(nil)) - return - } - let managedObjectContext = dependency.viewModel.statusFetchedResultsController - .fetchedResultsController - .managedObjectContext - - switch item { - case .reportStatus(let objectID, _): - managedObjectContext.perform { - let status = managedObjectContext.object(with: objectID) as! Status - promise(.success(status)) - } - default: - promise(.success(nil)) - } - } - - _responseToStatusContentWarningRevealAction( - dependency: dependency, - status: status - ) - } -} - -extension StatusProviderFacade { - static func coordinateToStatusMediaPreviewScene(provider: StatusProvider & MediaPreviewableViewController, cell: UITableViewCell, mosaicImageView: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) { - provider.status(for: cell, indexPath: nil) - .sink { [weak provider] status in - guard let provider = provider else { return } - guard let source = status else { return } - - let status = source.reblog ?? source - - let meta = MediaPreviewViewModel.StatusImagePreviewMeta( - statusObjectID: status.objectID, - initialIndex: index, - preloadThumbnailImages: mosaicImageView.thumbnails() - ) - let pushTransitionItem = MediaPreviewTransitionItem( - source: .mosaic(mosaicImageView), - previewableViewController: provider - ) - pushTransitionItem.aspectRatio = { - if let image = imageView.image { - return image.size - } - guard let media = status.mediaAttachments?.sorted(by: { $0.index.compare($1.index) == .orderedAscending }) else { return nil } - guard index < media.count else { return nil } - let meta = media[index].meta - guard let width = meta?.original?.width, let height = meta?.original?.height else { return nil } - return CGSize(width: width, height: height) - }() - pushTransitionItem.sourceImageView = imageView - pushTransitionItem.initialFrame = { - let initialFrame = imageView.superview!.convert(imageView.frame, to: nil) - assert(initialFrame != .zero) - return initialFrame - }() - pushTransitionItem.image = { - if let image = imageView.image { - return image - } - if index < mosaicImageView.blurhashOverlayImageViews.count { - return mosaicImageView.blurhashOverlayImageViews[index].image - } - - return nil - }() - - let mediaPreviewViewModel = MediaPreviewViewModel( - context: provider.context, - meta: meta, - pushTransitionItem: pushTransitionItem - ) - DispatchQueue.main.async { - provider.coordinator.present(scene: .mediaPreview(viewModel: mediaPreviewViewModel), from: provider, transition: .custom(transitioningDelegate: provider.mediaPreviewTransitionController)) - } - } - .store(in: &provider.disposeBag) - } -} - -extension StatusProviderFacade { - enum Target { - case primary // original status - case secondary // wrapper status or reply (when needs. e.g tap header of status view) - } -} - diff --git a/Mastodon/Protocol/StatusTableViewControllerAspect.swift b/Mastodon/Protocol/StatusTableViewControllerAspect.swift deleted file mode 100644 index fbaf76650..000000000 --- a/Mastodon/Protocol/StatusTableViewControllerAspect.swift +++ /dev/null @@ -1,182 +0,0 @@ -// -// StatusTableViewControllerAspect.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-4-7. -// - -import UIKit -import AVKit -import GameController - -// Check List Last Updated -// - HomeViewController: 2021/7/15 -// - FavoriteViewController: 2021/4/30 -// - HashtagTimelineViewController: 2021/4/30 -// - UserTimelineViewController: 2021/4/30 -// - ThreadViewController: 2021/4/30 -// - SearchResultViewController: 2021/7/15 -// * StatusTableViewControllerAspect: 2021/7/15 - -// (Fake) Aspect protocol to group common protocol extension implementations -// Needs update related view controller when aspect interface changes - -/// Status related operations aspect -/// Please check the aspect methods (Option+Click) and add hook to implement features -/// - UI -/// - Media -/// - Data Source -protocol StatusTableViewControllerAspect: UIViewController { - var tableView: UITableView { get } -} - -// MARK: - UIViewController [A] - -// [A1] aspectViewWillAppear(_:) -extension StatusTableViewControllerAspect { - /// [UI] hook to deselect row in the transitioning for the table view - func aspectViewWillAppear(_ animated: Bool) { - if GCKeyboard.coalesced != nil, let backKeyCommandPressDate = UserDefaults.shared.backKeyCommandPressDate { - guard backKeyCommandPressDate.timeIntervalSinceNow <= -0.5 else { - // break if interval greater than 0.5s - return - } - } - tableView.deselectRow(with: transitionCoordinator, animated: animated) - } -} - -// [A2] aspectViewDidDisappear(_:) -extension StatusTableViewControllerAspect where Self: NeedsDependency { - /// [Media] hook to notify video service - func aspectViewDidDisappear(_ animated: Bool) { - context.videoPlaybackService.viewDidDisappear(from: self) - context.audioPlaybackService.viewDidDisappear(from: self) - } -} - -// MARK: - UITableViewDelegate [B] - -// [B1] aspectTableView(_:estimatedHeightForRowAt:) -extension StatusTableViewControllerAspect where Self: LoadMoreConfigurableTableViewContainer { - /// [Data Source] hook to notify table view bottom loader - func aspectScrollViewDidScroll(_ scrollView: UIScrollView) { - handleScrollViewDidScroll(scrollView) - } -} - -// [B2] aspectTableView(_:estimatedHeightForRowAt:) -extension StatusTableViewControllerAspect where Self: TableViewCellHeightCacheableContainer { - /// [UI] hook to estimate table view cell height from cache - func aspectTableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { - handleTableView(tableView, estimatedHeightForRowAt: indexPath) - } -} - -// [B3] aspectTableView(_:willDisplay:forRowAt:) -extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegate & StatusProvider { - func aspectTableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { - handleTableView(tableView, willDisplay: cell, forRowAt: indexPath) - } -} - -// [B4] aspectTableView(_:didEndDisplaying:forRowAt:) -extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegate & StatusProvider { - /// [Media] hook to notify video service - func aspectTableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { - handleTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) - } -} - -extension StatusTableViewControllerAspect where Self: TableViewCellHeightCacheableContainer { - /// [UI] hook to cache table view cell height - func aspectTableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { - cacheTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) - } -} - -extension StatusTableViewControllerAspect where Self: StatusProvider & StatusTableViewCellDelegate & TableViewCellHeightCacheableContainer { - /// [Media] hook to notify video service - /// [UI] hook to cache table view cell height - func aspectTableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { - handleTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) - cacheTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) - } -} - -// [B5] aspectTableView(_:didSelectRowAt:) -extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegate & StatusProvider { - /// [UI] hook to coordinator to thread - func aspectTableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - handleTableView(tableView, didSelectRowAt: indexPath) - } -} - -// [B6] aspectTableView(_:contextMenuConfigurationForRowAt:point:) -extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegate & StatusProvider { - // [UI] hook to display context menu for images - func aspectTableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { - return handleTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point) - } -} - -// [B7] aspectTableView(_:contextMenuConfigurationForRowAt:point:) -extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegate & StatusProvider { - // [UI] hook to configure context menu for images - func aspectTableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { - return handleTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration) - } -} - -// [B8] aspectTableView(_:previewForDismissingContextMenuWithConfiguration:) -extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegate & StatusProvider { - // [UI] hook to configure context menu for images - func aspectTableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { - return handleTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration) - } -} - -// [B9] aspectTableView(_:willPerformPreviewActionForMenuWith:animator:) -extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegate & StatusProvider { - // [UI] hook to configure context menu preview action - func aspectTableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { - handleTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator) - } -} - -// MARK: - UITableViewDataSourcePrefetching [C] - -// [C1] aspectTableView(:prefetchRowsAt) -extension StatusTableViewControllerAspect where Self: UITableViewDataSourcePrefetching & StatusTableViewCellDelegate & StatusProvider { - /// [Data Source] hook to prefetch status - func aspectTableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { - handleTableView(tableView, prefetchRowsAt: indexPaths) - } -} - -// [C2] aspectTableView(:prefetchRowsAt) -extension StatusTableViewControllerAspect where Self: UITableViewDataSourcePrefetching & StatusTableViewCellDelegate & StatusProvider { - /// [Data Source] hook to cancel prefetch status - func aspectTableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) { - handleTableView(tableView, cancelPrefetchingForRowsAt: indexPaths) - } -} - -// MARK: - AVPlayerViewControllerDelegate & NeedsDependency [D] - -// [D1] aspectPlayerViewController(_:willBeginFullScreenPresentationWithAnimationCoordinator:) -extension StatusTableViewControllerAspect where Self: AVPlayerViewControllerDelegate & NeedsDependency { - /// [Media] hook to mark transitioning to video service - func aspectPlayerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { - handlePlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator) - } -} - -// [D2] aspectPlayerViewController(_:willEndFullScreenPresentationWithAnimationCoordinator:) -extension StatusTableViewControllerAspect where Self: AVPlayerViewControllerDelegate & NeedsDependency { - /// [Media] hook to mark transitioning to video service - func aspectPlayerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { - handlePlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator) - } -} - diff --git a/Mastodon/Protocol/StatusTableViewControllerNavigateable.swift b/Mastodon/Protocol/StatusTableViewControllerNavigateable.swift index ad869fbd1..a35fae7b7 100644 --- a/Mastodon/Protocol/StatusTableViewControllerNavigateable.swift +++ b/Mastodon/Protocol/StatusTableViewControllerNavigateable.swift @@ -7,6 +7,8 @@ import os.log import UIKit +import MastodonAsset +import MastodonLocalization typealias StatusTableViewControllerNavigateable = StatusTableViewControllerNavigateableCore & StatusTableViewControllerNavigateableRelay diff --git a/Mastodon/Protocol/TableViewCellHeightCacheableContainer.swift b/Mastodon/Protocol/TableViewCellHeightCacheableContainer.swift deleted file mode 100644 index 8ae7398c9..000000000 --- a/Mastodon/Protocol/TableViewCellHeightCacheableContainer.swift +++ /dev/null @@ -1,38 +0,0 @@ -// -// TableViewCellHeightCacheableContainer.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-3-3. -// - -import UIKit - -protocol TableViewCellHeightCacheableContainer { - var cellFrameCache: NSCache<NSNumber, NSValue> { get } - func cacheTableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) - func handleTableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat -} - -extension TableViewCellHeightCacheableContainer where Self: StatusProvider { - - func cacheTableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { - guard let item = item(for: nil, indexPath: indexPath) else { return } - - let key = item.hashValue - let frame = cell.frame - cellFrameCache.setObject(NSValue(cgRect: frame), forKey: NSNumber(value: key)) - } - - func handleTableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { - guard let item = item(for: nil, indexPath: indexPath) else { return UITableView.automaticDimension } - guard let frame = cellFrameCache.object(forKey: NSNumber(value: item.hashValue))?.cgRectValue else { - if case .bottomLoader = item { - return TimelineLoaderTableViewCell.cellHeight - } else { - return UITableView.automaticDimension - } - } - - return ceil(frame.height) - } -} diff --git a/Mastodon/Protocol/TableViewControllerNavigateable.swift b/Mastodon/Protocol/TableViewControllerNavigateable.swift index a70ab7014..4189d0cfc 100644 --- a/Mastodon/Protocol/TableViewControllerNavigateable.swift +++ b/Mastodon/Protocol/TableViewControllerNavigateable.swift @@ -7,6 +7,8 @@ import os.log import UIKit +import MastodonAsset +import MastodonLocalization typealias TableViewControllerNavigateable = TableViewControllerNavigateableCore & TableViewControllerNavigateableRelay diff --git a/Mastodon/Protocol/UserProvider/UserProvider.swift b/Mastodon/Protocol/UserProvider/UserProvider.swift deleted file mode 100644 index f9939c740..000000000 --- a/Mastodon/Protocol/UserProvider/UserProvider.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// UserProvider.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-4-1. -// - -import Combine -import CoreData -import CoreDataStack -import UIKit - -protocol UserProvider: NeedsDependency & DisposeBagCollectable & UIViewController { - // async - func mastodonUser() -> Future<MastodonUser?, Never> - - func mastodonUser(for cell: UITableViewCell?) -> Future<MastodonUser?, Never> -} - -extension UserProvider where Self: StatusProvider { - func mastodonUser(for cell: UITableViewCell?) -> Future<MastodonUser?, Never> { - Future { [weak self] promise in - guard let self = self else { return } - self.status(for: cell, indexPath: nil) - .sink { status in - promise(.success(status?.authorForUserProvider)) - } - .store(in: &self.disposeBag) - } - } - - func mastodonUser() -> Future<MastodonUser?, Never> { - Future { promise in - promise(.success(nil)) - } - } -} diff --git a/Mastodon/Protocol/UserProvider/UserProviderFacade+UITableViewDelegate.swift b/Mastodon/Protocol/UserProvider/UserProviderFacade+UITableViewDelegate.swift deleted file mode 100644 index a6e3cf215..000000000 --- a/Mastodon/Protocol/UserProvider/UserProviderFacade+UITableViewDelegate.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// 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 deleted file mode 100644 index edbe311c7..000000000 --- a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift +++ /dev/null @@ -1,464 +0,0 @@ -// -// UserProviderFacade.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-4-1. -// - -import Combine -import CoreData -import CoreDataStack -import MastodonSDK -import UIKit - -enum UserProviderFacade {} - -extension UserProviderFacade { - static func toggleUserFollowRelationship( - provider: UserProvider - ) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> { - // prepare authentication - guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { - assertionFailure() - return Fail(error: APIService.APIError.implicit(.authenticationMissing)).eraseToAnyPublisher() - } - - return _toggleUserFollowRelationship( - context: provider.context, - activeMastodonAuthenticationBox: activeMastodonAuthenticationBox, - mastodonUser: provider.mastodonUser().eraseToAnyPublisher() - ) - } - - static func toggleUserFollowRelationship( - provider: UserProvider, - mastodonUser: MastodonUser - ) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> { - // prepare authentication - guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { - assertionFailure() - return Fail(error: APIService.APIError.implicit(.authenticationMissing)).eraseToAnyPublisher() - } - - return _toggleUserFollowRelationship( - context: provider.context, - activeMastodonAuthenticationBox: activeMastodonAuthenticationBox, - mastodonUser: Just(mastodonUser).eraseToAnyPublisher() - ) - } - - private static func _toggleUserFollowRelationship( - context: AppContext, - activeMastodonAuthenticationBox: MastodonAuthenticationBox, - mastodonUser: AnyPublisher<MastodonUser?, Never> - ) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> { - mastodonUser - .compactMap { mastodonUser -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error>? in - guard let mastodonUser = mastodonUser else { - return nil - } - - return context.apiService.toggleFollow( - for: mastodonUser, - activeMastodonAuthenticationBox: activeMastodonAuthenticationBox - ) - } - .switchToLatest() - .eraseToAnyPublisher() - } -} - -extension UserProviderFacade { - static func toggleUserBlockRelationship( - provider: UserProvider, - mastodonUser: MastodonUser - ) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> { - // prepare authentication - guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { - assertionFailure() - return Fail(error: APIService.APIError.implicit(.authenticationMissing)).eraseToAnyPublisher() - } - return _toggleUserBlockRelationship( - context: provider.context, - activeMastodonAuthenticationBox: activeMastodonAuthenticationBox, - mastodonUser: Just(mastodonUser).eraseToAnyPublisher() - ) - } - - static func toggleUserBlockRelationship( - provider: UserProvider, - cell: UITableViewCell? - ) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> { - // prepare authentication - guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { - assertionFailure() - return Fail(error: APIService.APIError.implicit(.authenticationMissing)).eraseToAnyPublisher() - } - if let cell = cell { - return _toggleUserBlockRelationship( - context: provider.context, - activeMastodonAuthenticationBox: activeMastodonAuthenticationBox, - mastodonUser: provider.mastodonUser(for: cell).eraseToAnyPublisher() - ) - } else { - return _toggleUserBlockRelationship( - context: provider.context, - activeMastodonAuthenticationBox: activeMastodonAuthenticationBox, - mastodonUser: provider.mastodonUser().eraseToAnyPublisher() - ) - } - } - - private static func _toggleUserBlockRelationship( - context: AppContext, - activeMastodonAuthenticationBox: MastodonAuthenticationBox, - mastodonUser: AnyPublisher<MastodonUser?, Never> - ) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> { - mastodonUser - .compactMap { mastodonUser -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error>? in - guard let mastodonUser = mastodonUser else { - return nil - } - - return context.apiService.toggleBlock( - for: mastodonUser, - activeMastodonAuthenticationBox: activeMastodonAuthenticationBox - ) - } - .switchToLatest() - .eraseToAnyPublisher() - } -} - -extension UserProviderFacade { - - static func toggleUserMuteRelationship( - provider: UserProvider, - mastodonUser: MastodonUser - ) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> { - // prepare authentication - guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { - assertionFailure() - return Fail(error: APIService.APIError.implicit(.authenticationMissing)).eraseToAnyPublisher() - } - return _toggleUserMuteRelationship( - context: provider.context, - activeMastodonAuthenticationBox: activeMastodonAuthenticationBox, - mastodonUser: Just(mastodonUser).eraseToAnyPublisher() - ) - } - - static func toggleUserMuteRelationship( - provider: UserProvider, - cell: UITableViewCell? - ) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> { - // prepare authentication - guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { - assertionFailure() - return Fail(error: APIService.APIError.implicit(.authenticationMissing)).eraseToAnyPublisher() - } - if let cell = cell { - return _toggleUserMuteRelationship( - context: provider.context, - activeMastodonAuthenticationBox: activeMastodonAuthenticationBox, - mastodonUser: provider.mastodonUser(for: cell).eraseToAnyPublisher() - ) - } else { - return _toggleUserMuteRelationship( - context: provider.context, - activeMastodonAuthenticationBox: activeMastodonAuthenticationBox, - mastodonUser: provider.mastodonUser().eraseToAnyPublisher() - ) - } - } - - private static func _toggleUserMuteRelationship( - context: AppContext, - activeMastodonAuthenticationBox: MastodonAuthenticationBox, - mastodonUser: AnyPublisher<MastodonUser?, Never> - ) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> { - mastodonUser - .compactMap { mastodonUser -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error>? in - guard let mastodonUser = mastodonUser else { - return nil - } - - return context.apiService.toggleMute( - for: mastodonUser, - activeMastodonAuthenticationBox: activeMastodonAuthenticationBox - ) - } - .switchToLatest() - .eraseToAnyPublisher() - } -} - -extension UserProviderFacade { - static func createProfileActionMenu( - for mastodonUser: MastodonUser, - isMyself: Bool, - isMuting: Bool, - isBlocking: Bool, - isInSameDomain: Bool, - isDomainBlocking: Bool, - provider: UserProvider, - cell: UITableViewCell?, - sourceView: UIView?, - barButtonItem: UIBarButtonItem?, - shareUser: MastodonUser?, - shareStatus: Status? - ) -> UIMenu { - var children: [UIMenuElement] = [] - 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, weak sourceView, weak barButtonItem] _ in - guard let provider = provider else { return } - let activityViewController = createActivityViewControllerForMastodonUser(mastodonUser: shareUser, dependency: provider) - provider.coordinator.present( - scene: .activityViewController( - activityViewController: activityViewController, - sourceView: sourceView, - barButtonItem: barButtonItem - ), - from: provider, - transition: .activityViewControllerPresent(animated: true, completion: nil) - ) - } - children.append(shareAction) - } - - 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, weak sourceView, weak barButtonItem] _ in - guard let provider = provider else { return } - let activityViewController = createActivityViewControllerForMastodonUser(status: shareStatus, dependency: provider) - provider.coordinator.present( - scene: .activityViewController( - activityViewController: activityViewController, - sourceView: sourceView, - barButtonItem: barButtonItem - ), - from: provider, - transition: .activityViewControllerPresent(animated: true, completion: nil) - ) - } - children.append(shareAction) - } - - if !isMyself { - // mute - let muteAction = UIAction( - title: isMuting ? L10n.Common.Controls.Friendship.unmuteUser(name) : L10n.Common.Controls.Friendship.mute, - image: isMuting ? UIImage(systemName: "speaker") : UIImage(systemName: "speaker.slash"), - discoverabilityTitle: isMuting ? nil : L10n.Common.Controls.Friendship.muteUser(name), - attributes: isMuting ? [] : .destructive, - state: .off - ) { [weak provider, weak cell] _ in - guard let provider = provider else { return } - - UserProviderFacade.toggleUserMuteRelationship( - provider: provider, - cell: cell - ) - .sink { _ in - // do nothing - } receiveValue: { _ in - // do nothing - } - .store(in: &provider.context.disposeBag) - } - if isMuting { - children.append(muteAction) - } else { - let muteMenu = UIMenu(title: L10n.Common.Controls.Friendship.muteUser(name), image: UIImage(systemName: "speaker.slash"), options: [], children: [muteAction]) - children.append(muteMenu) - } - } - - if !isMyself { - // block - let blockAction = UIAction( - title: isBlocking ? L10n.Common.Controls.Friendship.unblockUser(name) : L10n.Common.Controls.Friendship.block, - image: isBlocking ? UIImage(systemName: "hand.raised.slash") : UIImage(systemName: "hand.raised"), - discoverabilityTitle: isBlocking ? nil : L10n.Common.Controls.Friendship.blockUser(name), - attributes: isBlocking ? [] : .destructive, - state: .off - ) { [weak provider, weak cell] _ in - guard let provider = provider else { return } - - UserProviderFacade.toggleUserBlockRelationship( - provider: provider, - cell: cell - ) - .sink { _ in - // do nothing - } receiveValue: { _ in - // do nothing - } - .store(in: &provider.context.disposeBag) - } - if isBlocking { - children.append(blockAction) - } else { - let blockMenu = UIMenu(title: L10n.Common.Controls.Friendship.blockUser(name), image: UIImage(systemName: "hand.raised"), options: [], children: [blockAction]) - children.append(blockMenu) - } - } - - 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 - guard let provider = provider else { return } - guard let authenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { - return - } - let viewModel = ReportViewModel( - context: provider.context, - domain: authenticationBox.domain, - user: mastodonUser, - status: nil - ) - provider.coordinator.present( - scene: .report(viewModel: viewModel), - from: provider, - transition: .modal(animated: true, completion: nil) - ) - } - children.append(reportAction) - } - - 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, 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, 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 } - alertController.addAction(cancelAction) - 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) - provider.present(alertController, animated: true, completion: nil) - } - children.append(blockDomainAction) - } - } - - 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 - 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 } - alertController.addAction(cancelAction) - 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 - ) - .sink { _ in - // do nothing - } receiveValue: { _ in - // do nothing - } - .store(in: &provider.context.disposeBag) - } - alertController.addAction(deleteAction) - provider.present(alertController, animated: true, completion: nil) - } - children.append(deleteAction) - } - - return UIMenu(title: "", options: [], children: children) - } - - static func createActivityViewControllerForMastodonUser(mastodonUser: MastodonUser, dependency: NeedsDependency) -> UIActivityViewController { - let activityViewController = UIActivityViewController( - activityItems: mastodonUser.activityItems, - applicationActivities: [SafariActivity(sceneCoordinator: dependency.coordinator)] - ) - return activityViewController - } - - static func createActivityViewControllerForMastodonUser(status: Status, dependency: NeedsDependency) -> UIActivityViewController { - let activityViewController = UIActivityViewController( - activityItems: status.activityItems, - applicationActivities: [SafariActivity(sceneCoordinator: dependency.coordinator)] - ) - return activityViewController - } -} - -extension UserProviderFacade { - static func coordinatorToUserProfileScene(provider: UserProvider, user: Future<MastodonUser?, Never>) { - 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/Colors/brand.blue.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/brand.blue.colorset/Contents.json deleted file mode 100644 index a85c0e379..000000000 --- a/Mastodon/Resources/Assets.xcassets/Colors/brand.blue.colorset/Contents.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0xD9", - "green" : "0x90", - "red" : "0x2B" - } - }, - "idiom" : "universal" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0xE4", - "green" : "0x9D", - "red" : "0x3A" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/brand.blue.darken.20.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/brand.blue.darken.20.colorset/Contents.json deleted file mode 100644 index 6464e2d9d..000000000 --- a/Mastodon/Resources/Assets.xcassets/Colors/brand.blue.darken.20.colorset/Contents.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0xB0", - "green" : "0x73", - "red" : "0x1F" - } - }, - "idiom" : "universal" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0xC9", - "green" : "0x80", - "red" : "0x1B" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/danger.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/danger.colorset/Contents.json deleted file mode 100644 index b77cb3c75..000000000 --- a/Mastodon/Resources/Assets.xcassets/Colors/danger.colorset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "90", - "green" : "64", - "red" : "223" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/disabled.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/disabled.colorset/Contents.json deleted file mode 100644 index 303021b9f..000000000 --- a/Mastodon/Resources/Assets.xcassets/Colors/disabled.colorset/Contents.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "200", - "green" : "174", - "red" : "155" - } - }, - "idiom" : "universal" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0x64", - "green" : "0x5D", - "red" : "0x4F" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/inactive.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/inactive.colorset/Contents.json deleted file mode 100644 index ea5d9760a..000000000 --- a/Mastodon/Resources/Assets.xcassets/Colors/inactive.colorset/Contents.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0x8C", - "green" : "0x82", - "red" : "0x6E" - } - }, - "idiom" : "universal" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0x64", - "green" : "0x5D", - "red" : "0x4F" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/system.orange.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/system.orange.colorset/Contents.json deleted file mode 100644 index 0b0fa36c0..000000000 --- a/Mastodon/Resources/Assets.xcassets/Colors/system.orange.colorset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0x0A", - "green" : "0x9F", - "red" : "0xFF" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/background.cyan.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/background.cyan.colorset/Contents.json deleted file mode 100644 index cd6391d81..000000000 --- a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/background.cyan.colorset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "232", - "green" : "207", - "red" : "60" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Mastodon/Resources/Assets.xcassets/Settings/black.auto.imageset/Mixed_Black_Light.png b/Mastodon/Resources/Assets.xcassets/Settings/black.auto.imageset/Mixed_Black_Light.png deleted file mode 100644 index d27078d5d..000000000 Binary files a/Mastodon/Resources/Assets.xcassets/Settings/black.auto.imageset/Mixed_Black_Light.png and /dev/null differ diff --git a/Mastodon/Resources/Assets.xcassets/Settings/black.imageset/Home Black.png b/Mastodon/Resources/Assets.xcassets/Settings/black.imageset/Home Black.png deleted file mode 100644 index b7348ae9b..000000000 Binary files a/Mastodon/Resources/Assets.xcassets/Settings/black.imageset/Home Black.png and /dev/null differ diff --git a/Mastodon/Resources/Assets.xcassets/Settings/dark.auto.imageset/Mixed_Dark_Light.png b/Mastodon/Resources/Assets.xcassets/Settings/dark.auto.imageset/Mixed_Dark_Light.png deleted file mode 100644 index 450675915..000000000 Binary files a/Mastodon/Resources/Assets.xcassets/Settings/dark.auto.imageset/Mixed_Dark_Light.png and /dev/null differ diff --git a/Mastodon/Resources/Assets.xcassets/Settings/dark.imageset/Home Dark.png b/Mastodon/Resources/Assets.xcassets/Settings/dark.imageset/Home Dark.png deleted file mode 100644 index 46926d865..000000000 Binary files a/Mastodon/Resources/Assets.xcassets/Settings/dark.imageset/Home Dark.png and /dev/null differ diff --git a/Mastodon/Resources/Assets.xcassets/Settings/light.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Settings/light.imageset/Contents.json deleted file mode 100644 index beb5cfcfd..000000000 --- a/Mastodon/Resources/Assets.xcassets/Settings/light.imageset/Contents.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "images" : [ - { - "filename" : "Home Light.png", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Mastodon/Resources/Assets.xcassets/Settings/light.imageset/Home Light.png b/Mastodon/Resources/Assets.xcassets/Settings/light.imageset/Home Light.png deleted file mode 100644 index cafff0bb5..000000000 Binary files a/Mastodon/Resources/Assets.xcassets/Settings/light.imageset/Home Light.png and /dev/null differ diff --git a/Mastodon/Resources/Assets.xcassets/Theme/Mastodon/Background/table.view.cell.selection.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Theme/Mastodon/Background/table.view.cell.selection.background.colorset/Contents.json deleted file mode 100644 index 91b8281dc..000000000 --- a/Mastodon/Resources/Assets.xcassets/Theme/Mastodon/Background/table.view.cell.selection.background.colorset/Contents.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "235", - "green" : "229", - "red" : "221" - } - }, - "idiom" : "universal" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0x6E", - "green" : "0x57", - "red" : "0x4F" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} 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 deleted file mode 100644 index bfc2a11b2..000000000 --- a/Mastodon/Resources/Assets.xcassets/Theme/Mastodon/tab.bar.item.inactive.icon.color.colorset/Contents.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0x99", - "green" : "0x99", - "red" : "0x99" - } - }, - "idiom" : "universal" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0x99", - "green" : "0x99", - "red" : "0x99" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Mastodon/Resources/Assets.xcassets/Theme/system/Background/table.view.cell.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Theme/system/Background/table.view.cell.background.colorset/Contents.json deleted file mode 100644 index 6b983510e..000000000 --- a/Mastodon/Resources/Assets.xcassets/Theme/system/Background/table.view.cell.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", - "green" : "0", - "red" : "0" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Mastodon/Resources/Assets.xcassets/Theme/system/Background/tertiary.system.grouped.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Theme/system/Background/tertiary.system.grouped.background.colorset/Contents.json deleted file mode 100644 index ab65a98ec..000000000 --- a/Mastodon/Resources/Assets.xcassets/Theme/system/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" : "60", - "green" : "58", - "red" : "58" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} 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 deleted file mode 100644 index bfc2a11b2..000000000 --- a/Mastodon/Resources/Assets.xcassets/Theme/system/tab.bar.item.inactive.icon.color.colorset/Contents.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0x99", - "green" : "0x99", - "red" : "0x99" - } - }, - "idiom" : "universal" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0x99", - "green" : "0x99", - "red" : "0x99" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Mastodon/Resources/Assets.xcassets/Settings/dark.imageset/Contents.json b/Mastodon/Resources/Preview Assets.xcassets/Athens.imageset/Contents.json similarity index 77% rename from Mastodon/Resources/Assets.xcassets/Settings/dark.imageset/Contents.json rename to Mastodon/Resources/Preview Assets.xcassets/Athens.imageset/Contents.json index 01bdd059f..786051dd9 100644 --- a/Mastodon/Resources/Assets.xcassets/Settings/dark.imageset/Contents.json +++ b/Mastodon/Resources/Preview Assets.xcassets/Athens.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "Home Dark.png", + "filename" : "IMG_1010.jpg", "idiom" : "universal" } ], diff --git a/Mastodon/Resources/Preview Assets.xcassets/Athens.imageset/IMG_1010.jpg b/Mastodon/Resources/Preview Assets.xcassets/Athens.imageset/IMG_1010.jpg new file mode 100644 index 000000000..0fce96bdd Binary files /dev/null and b/Mastodon/Resources/Preview Assets.xcassets/Athens.imageset/IMG_1010.jpg differ diff --git a/Mastodon/Resources/ar.lproj/Localizable.strings b/Mastodon/Resources/ar.lproj/Localizable.strings deleted file mode 100644 index b878e0342..000000000 --- a/Mastodon/Resources/ar.lproj/Localizable.strings +++ /dev/null @@ -1,348 +0,0 @@ -"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" = "أكِّد للتخلص مِن مُحتوى مَنشور مؤلَّف."; -"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.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.FindPeople" = "ابحث عن أشخاص لمتابعتهم"; -"Common.Controls.Actions.ManuallySearch" = "البحث يدوياً بدلاً من ذلك"; -"Common.Controls.Actions.Next" = "التالي"; -"Common.Controls.Actions.Ok" = "حسنًا"; -"Common.Controls.Actions.Open" = "افتح"; -"Common.Controls.Actions.OpenInSafari" = "الفتح في Safari"; -"Common.Controls.Actions.Preview" = "مُعاينة"; -"Common.Controls.Actions.Previous" = "السابق"; -"Common.Controls.Actions.Remove" = "احذف"; -"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.Skip" = "تخطي"; -"Common.Controls.Actions.TakePhoto" = "التقاط صورة"; -"Common.Controls.Actions.TryAgain" = "المُحاولة مرة أُخرى"; -"Common.Controls.Actions.UnblockDomain" = "إلغاء حظر %@"; -"Common.Controls.Friendship.Block" = "حظر"; -"Common.Controls.Friendship.BlockDomain" = "حظر %@"; -"Common.Controls.Friendship.BlockUser" = "حظر %@"; -"Common.Controls.Friendship.Blocked" = "محظور"; -"Common.Controls.Friendship.EditInfo" = "تعديل المعلومات"; -"Common.Controls.Friendship.Follow" = "اتبع"; -"Common.Controls.Friendship.Following" = "مُتابَع"; -"Common.Controls.Friendship.Mute" = "أكتم"; -"Common.Controls.Friendship.MuteUser" = "أكتم %@"; -"Common.Controls.Friendship.Muted" = "مكتوم"; -"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.OpenSettings" = "أفتح الإعدادات"; -"Common.Controls.Keyboard.Common.ShowFavorites" = "إظهار المفضلة"; -"Common.Controls.Keyboard.Common.SwitchToTab" = "التبديل إلى %@"; -"Common.Controls.Keyboard.SegmentedControl.NextSection" = "القسم التالي"; -"Common.Controls.Keyboard.SegmentedControl.PreviousSection" = "القسم السابق"; -"Common.Controls.Keyboard.Timeline.NextStatus" = "المنشور التالي"; -"Common.Controls.Keyboard.Timeline.OpenAuthorProfile" = "افتح الملف التعريفي للمؤلف"; -"Common.Controls.Keyboard.Timeline.OpenRebloggerProfile" = "افتح الملف التعريفي لمشارِك المنشور"; -"Common.Controls.Keyboard.Timeline.OpenStatus" = "افتح المنشور"; -"Common.Controls.Keyboard.Timeline.PreviewImage" = "معاينة الصورة"; -"Common.Controls.Keyboard.Timeline.PreviousStatus" = "المنشور السابق"; -"Common.Controls.Keyboard.Timeline.ReplyStatus" = "رد على المنشور"; -"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" = "إيموجي"; -"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" = "مُصفَّى"; -"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" = "لا يُمكِنُكَ عَرض ملف %@ الشخصي -حتَّى يَرفَعَ الحَظر عَنك."; -"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" = "منتقي مخصص للإيموجي"; -"Scene.Compose.Accessibility.DisableContentWarning" = "تعطيل تحذير الحتوى"; -"Scene.Compose.Accessibility.EnableContentWarning" = "تنشيط تحذير المحتوى"; -"Scene.Compose.Accessibility.PostVisibilityMenu" = "قائمة ظهور المنشور"; -"Scene.Compose.Accessibility.RemovePoll" = "إزالة الاستطلاع"; -"Scene.Compose.Attachment.AttachmentBroken" = "هذا ال%@ مُعطَّل ويتعذَّر رفعه إلى ماستودون."; -"Scene.Compose.Attachment.DescriptionPhoto" = "صِف الصورة للمكفوفين..."; -"Scene.Compose.Attachment.DescriptionVideo" = "صِف المقطع المرئي للمكفوفين..."; -"Scene.Compose.Attachment.Photo" = "صورة"; -"Scene.Compose.Attachment.Video" = "فيديو"; -"Scene.Compose.AutoComplete.SpaceToAdd" = "انقر مساحة لإضافتِها"; -"Scene.Compose.ComposeAction" = "انشر"; -"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" = "المدة: %@"; -"Scene.Compose.Poll.OneDay" = "يوم واحد"; -"Scene.Compose.Poll.OneHour" = "ساعة واحدة"; -"Scene.Compose.Poll.OptionNumber" = "الخيار %ld"; -"Scene.Compose.Poll.SevenDays" = "7 أيام"; -"Scene.Compose.Poll.SixHours" = "6 ساعات"; -"Scene.Compose.Poll.ThirtyMinutes" = "30 دقيقة"; -"Scene.Compose.Poll.ThreeDays" = "3 أيام"; -"Scene.Compose.ReplyingToUser" = "رد على %@"; -"Scene.Compose.Title.NewPost" = "منشور جديد"; -"Scene.Compose.Title.NewReply" = "رد جديد"; -"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" = "فتح عميل البريد الإلكتروني"; -"Scene.ConfirmEmail.OpenEmailApp.Title" = "تحقَّق من بريدك الوارِد."; -"Scene.ConfirmEmail.Subtitle" = "لقد أرسلنا للتو رسالة بريد إلكتروني إلى %@، -اضغط على الرابط لتأكيد حسابك."; -"Scene.ConfirmEmail.Title" = "شيء واحد أخير."; -"Scene.Favorite.Title" = "مفضلتك"; -"Scene.Follower.Footer" = "لا يُمكِن عَرض المُتابِعين مِنَ الخوادم الأُخرى."; -"Scene.Following.Footer" = "لا يُمكِن عَرض المُتابَعات مِنَ الخوادم الأُخرى."; -"Scene.HomeTimeline.NavigationBarState.NewPosts" = "إظهار منشورات جديدة"; -"Scene.HomeTimeline.NavigationBarState.Offline" = "غير متصل"; -"Scene.HomeTimeline.NavigationBarState.Published" = "تم نشره!"; -"Scene.HomeTimeline.NavigationBarState.Publishing" = "جارٍ نشر المشاركة…"; -"Scene.HomeTimeline.Title" = "الخيط الرئيسي"; -"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" = "متابِع"; -"Scene.Profile.Dashboard.Following" = "مُتابَع"; -"Scene.Profile.Dashboard.Posts" = "منشورات"; -"Scene.Profile.Fields.AddRow" = "إضافة صف"; -"Scene.Profile.Fields.Placeholder.Content" = "المحتوى"; -"Scene.Profile.Fields.Placeholder.Label" = "التسمية"; -"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Message" = "أكِّد لرفع حظر %@"; -"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Title" = "إلغاء حظر الحساب"; -"Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Message" = "أكِّد لرفع كتمْ %@"; -"Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Title" = "إلغاء كتم الحساب"; -"Scene.Profile.SegmentedControl.Media" = "وسائط"; -"Scene.Profile.SegmentedControl.Posts" = "منشورات"; -"Scene.Profile.SegmentedControl.Replies" = "ردود"; -"Scene.Register.Error.Item.Agreement" = "الاتفاقية"; -"Scene.Register.Error.Item.Email" = "البريد الإلكتروني"; -"Scene.Register.Error.Item.Locale" = "اللغة المحلية"; -"Scene.Register.Error.Item.Password" = "الكلمة السرية"; -"Scene.Register.Error.Item.Reason" = "السبب"; -"Scene.Register.Error.Item.Username" = "اسم المستخدم"; -"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" = "البريد الإلكتروني"; -"Scene.Register.Input.Invite.RegistrationUserInviteRequest" = "لماذا ترغب في الانضمام؟"; -"Scene.Register.Input.Password.Hint" = "يجب أن تكون كلمتك السرية متكونة من ثمانية أحرف على الأقل"; -"Scene.Register.Input.Password.Placeholder" = "الكلمة السرية"; -"Scene.Register.Input.Username.DuplicatePrompt" = "اسم المستخدم هذا غير متوفر."; -"Scene.Register.Input.Username.Placeholder" = "اسم المستخدم"; -"Scene.Register.Title" = "أخبرنا عنك."; -"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" = "اكتب أو الصق تعليقات إضافيَّة"; -"Scene.Report.Title" = "ابلغ عن %@"; -"Scene.Search.Recommend.Accounts.Description" = "قد ترغب في متابعة هذه الحسابات"; -"Scene.Search.Recommend.Accounts.Follow" = "تابع"; -"Scene.Search.Recommend.Accounts.Title" = "حسابات قد تعجبك"; -"Scene.Search.Recommend.ButtonText" = "طالع الكل"; -"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" = "مَحو"; -"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" = "أكاديمي"; -"Scene.ServerPicker.Button.Category.Activism" = "للنشطاء"; -"Scene.ServerPicker.Button.Category.All" = "الكل"; -"Scene.ServerPicker.Button.Category.AllAccessiblityDescription" = "الفئة: الكل"; -"Scene.ServerPicker.Button.Category.Art" = "فن"; -"Scene.ServerPicker.Button.Category.Food" = "الطعام"; -"Scene.ServerPicker.Button.Category.Furry" = "فروي"; -"Scene.ServerPicker.Button.Category.Games" = "ألعاب"; -"Scene.ServerPicker.Button.Category.General" = "عام"; -"Scene.ServerPicker.Button.Category.Journalism" = "صحافة"; -"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" = "حدث خطأٌ ما أثناء تحميل البيانات. تحقَّق من اتصالك بالإنترنت."; -"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" = "اِختر خادِم، -أي خادِم."; -"Scene.ServerRules.Button.Confirm" = "انا أوافق"; -"Scene.ServerRules.PrivacyPolicy" = "سياسة الخصوصية"; -"Scene.ServerRules.Prompt" = "إن اخترت المواصلة، فإنك تخضع لشروط الخدمة وسياسة الخصوصية لـ %@."; -"Scene.ServerRules.Subtitle" = "تم سنّ هذه القواعد من قبل مشرفي %@."; -"Scene.ServerRules.TermsOfService" = "شروط الخدمة"; -"Scene.ServerRules.Title" = "بعض القواعد الأساسية."; -"Scene.Settings.Footer.MastodonDescription" = "ماستدون برنامج مفتوح المصدر. يمكنك المساهمة، أو الإبلاغ عن تقارير الأخطاء على GitHub في %@ (%@)"; -"Scene.Settings.Keyboard.CloseSettingsWindow" = "إغلاق نافذة الإعدادات"; -"Scene.Settings.Section.Appearance.Automatic" = "تلقائي"; -"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" = "المنطقة المملة"; -"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" = "أي شخص"; -"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" = "النمط الأسود الداكِن الحقيقي"; -"Scene.Settings.Section.Preference.UsingDefaultBrowser" = "اِستخدام المتصفح الافتراضي لفتح الروابط"; -"Scene.Settings.Section.SpicyZone.Clear" = "مسح ذاكرة التخزين المؤقت للوسائط"; -"Scene.Settings.Section.SpicyZone.Signout" = "تسجيل الخروج"; -"Scene.Settings.Section.SpicyZone.Title" = "المنطقة الحارة"; -"Scene.Settings.Title" = "الإعدادات"; -"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/eu-ES.lproj/InfoPlist.strings b/Mastodon/Resources/eu-ES.lproj/InfoPlist.strings new file mode 100644 index 000000000..710865573 --- /dev/null +++ b/Mastodon/Resources/eu-ES.lproj/InfoPlist.strings @@ -0,0 +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 diff --git a/Mastodon/Resources/ku-TR.lproj/InfoPlist.strings b/Mastodon/Resources/ku-TR.lproj/InfoPlist.strings deleted file mode 100644 index 669ecfacf..000000000 --- a/Mastodon/Resources/ku-TR.lproj/InfoPlist.strings +++ /dev/null @@ -1,4 +0,0 @@ -"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.lproj/InfoPlist.strings b/Mastodon/Resources/ku.lproj/InfoPlist.strings new file mode 100644 index 000000000..710865573 --- /dev/null +++ b/Mastodon/Resources/ku.lproj/InfoPlist.strings @@ -0,0 +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 diff --git a/Mastodon/Resources/sv-FI.lproj/InfoPlist.strings b/Mastodon/Resources/sv-FI.lproj/InfoPlist.strings new file mode 100644 index 000000000..710865573 --- /dev/null +++ b/Mastodon/Resources/sv-FI.lproj/InfoPlist.strings @@ -0,0 +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 diff --git a/Mastodon/Scene/Account/AccountListViewModel.swift b/Mastodon/Scene/Account/AccountListViewModel.swift index 1977b90ec..83d0240f8 100644 --- a/Mastodon/Scene/Account/AccountListViewModel.swift +++ b/Mastodon/Scene/Account/AccountListViewModel.swift @@ -112,11 +112,13 @@ extension AccountListViewModel { let user = authentication.user // avatar - cell.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: user.avatarImageURL())) + cell.avatarButton.avatarImageView.configure( + configuration: .init(url: user.avatarImageURL()) + ) // name do { - let content = MastodonContent(content: user.displayNameWithFallback, emojis: user.emojiMeta) + let content = MastodonContent(content: user.displayNameWithFallback, emojis: user.emojis.asDictionary) let metaContent = try MastodonMetaContent.convert(document: content) cell.nameLabel.configure(content: metaContent) } catch { diff --git a/Mastodon/Scene/Account/AccountViewController.swift b/Mastodon/Scene/Account/AccountViewController.swift index fce9c7320..42c9e1d62 100644 --- a/Mastodon/Scene/Account/AccountViewController.swift +++ b/Mastodon/Scene/Account/AccountViewController.swift @@ -10,6 +10,8 @@ import UIKit import Combine import CoreDataStack import PanModal +import MastodonAsset +import MastodonLocalization final class AccountListViewController: UIViewController, NeedsDependency { @@ -113,10 +115,14 @@ extension AccountListViewController { .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 } + + // the presentingViewController may deinit. + // Hold it and check the window to prevent PanModel crash + guard let presentingViewController = presentingViewController else { return } + guard self.view.window != nil else { return } + self.hasLoaded = true - self.panModalSetNeedsLayoutUpdate() + self.panModalSetNeedsLayoutUpdate() // <<< may crash the app self.panModalTransition(to: .shortForm) } .store(in: &disposeBag) diff --git a/Mastodon/Scene/Account/Cell/AccountListTableViewCell.swift b/Mastodon/Scene/Account/Cell/AccountListTableViewCell.swift index f6ab75877..2b480464d 100644 --- a/Mastodon/Scene/Account/Cell/AccountListTableViewCell.swift +++ b/Mastodon/Scene/Account/Cell/AccountListTableViewCell.swift @@ -9,6 +9,7 @@ import UIKit import Combine import FLAnimatedImage import MetaTextKit +import MastodonUI final class AccountListTableViewCell: UITableViewCell { @@ -31,6 +32,7 @@ final class AccountListTableViewCell: UITableViewCell { super.prepareForReuse() disposeBag.removeAll() + avatarButton.avatarImageView.image = nil } override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { @@ -125,10 +127,3 @@ extension AccountListTableViewCell { } } - -// 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 index 0873c1390..c641434e6 100644 --- a/Mastodon/Scene/Account/Cell/AddAccountTableViewCell.swift +++ b/Mastodon/Scene/Account/Cell/AddAccountTableViewCell.swift @@ -8,6 +8,8 @@ import UIKit import Combine import MetaTextKit +import MastodonAsset +import MastodonLocalization final class AddAccountTableViewCell: UITableViewCell { diff --git a/Mastodon/Scene/Account/View/BadgeButton.swift b/Mastodon/Scene/Account/View/BadgeButton.swift index 6d92a8471..785053be9 100644 --- a/Mastodon/Scene/Account/View/BadgeButton.swift +++ b/Mastodon/Scene/Account/View/BadgeButton.swift @@ -6,6 +6,8 @@ // import UIKit +import MastodonAsset +import MastodonLocalization final class BadgeButton: UIButton { @@ -24,10 +26,10 @@ final class BadgeButton: UIButton { 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) + setBackgroundColor(.systemBackground, for: .normal) + setTitleColor(.label, for: .normal) - contentEdgeInsets = UIEdgeInsets(top: 5, left: 5, bottom: 5, right: 5) + contentEdgeInsets = UIEdgeInsets(top: 6, left: 6, bottom: 6, right: 6) } override func layoutSubviews() { diff --git a/Mastodon/Scene/Account/View/DragIndicatorView.swift b/Mastodon/Scene/Account/View/DragIndicatorView.swift index 5efa141bc..9e0ab77d5 100644 --- a/Mastodon/Scene/Account/View/DragIndicatorView.swift +++ b/Mastodon/Scene/Account/View/DragIndicatorView.swift @@ -6,6 +6,8 @@ // import UIKit +import MastodonAsset +import MastodonLocalization final class DragIndicatorView: UIView { diff --git a/Mastodon/Scene/Compose/AutoComplete/AutoCompleteViewModel+State.swift b/Mastodon/Scene/Compose/AutoComplete/AutoCompleteViewModel+State.swift index 4e59ce082..ebda78a1e 100644 --- a/Mastodon/Scene/Compose/AutoComplete/AutoCompleteViewModel+State.swift +++ b/Mastodon/Scene/Compose/AutoComplete/AutoCompleteViewModel+State.swift @@ -11,7 +11,16 @@ import GameplayKit import MastodonSDK extension AutoCompleteViewModel { - class State: GKState { + class State: GKState, NamingState { + + let logger = Logger(subsystem: "AutoCompleteViewModel.State", category: "StateMachine") + + let id = UUID() + + var name: String { + String(describing: Self.self) + } + weak var viewModel: AutoCompleteViewModel? init(viewModel: AutoCompleteViewModel) { @@ -19,7 +28,18 @@ extension AutoCompleteViewModel { } 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) + super.didEnter(from: previousState) + let previousState = previousState as? AutoCompleteViewModel.State + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [\(self.id.uuidString)] enter \(self.name), previous: \(previousState?.name ?? "<nil>")") + } + + @MainActor + func enter(state: State.Type) { + stateMachine?.enter(state) + } + + deinit { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [\(self.id.uuidString)] \(self.name)") } } } @@ -67,32 +87,29 @@ extension AutoCompleteViewModel.State { switch searchType { case .emoji: - Loading.fetchLocalEmoji( - searchText: searchText, - viewModel: viewModel, - stateMachine: stateMachine - ) + Task { + await fetchLocalEmoji(searchText: searchText) + } default: - Loading.queryRemoteEnitity( - searchText: searchText, - viewModel: viewModel, - stateMachine: stateMachine - ) + Task { + await queryRemoteEnitity(searchText: searchText) + } } } - private static func fetchLocalEmoji( - searchText: String, - viewModel: AutoCompleteViewModel, - stateMachine: GKStateMachine - ) { + private func fetchLocalEmoji(searchText: String) async { + guard let viewModel = viewModel else { + await enter(state: Fail.self) + return + } + guard let customEmojiViewModel = viewModel.customEmojiViewModel.value else { - stateMachine.enter(Fail.self) + await enter(state: Fail.self) return } guard let emojiTrie = customEmojiViewModel.emojiTrie.value else { - stateMachine.enter(Fail.self) + await enter(state: Fail.self) return } @@ -105,20 +122,21 @@ extension AutoCompleteViewModel.State { let items: [AutoCompleteItem] = matchingEmojis.map { emoji in AutoCompleteItem.emoji(emoji: emoji) } - stateMachine.enter(Idle.self) + + await enter(state: Idle.self) viewModel.autoCompleteItems.value = items } - private static func queryRemoteEnitity( - searchText: String, - viewModel: AutoCompleteViewModel, - stateMachine: GKStateMachine - ) { - guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { - stateMachine.enter(Fail.self) + private func queryRemoteEnitity(searchText: String) async { + guard let viewModel = viewModel else { + await enter(state: Fail.self) + return + } + + guard let authenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { + await enter(state: Fail.self) return } - let domain = activeMastodonAuthenticationBox.domain let searchText = viewModel.inputText.value let searchType = AutoCompleteViewModel.SearchType(inputText: searchText) ?? .default @@ -131,30 +149,27 @@ extension AutoCompleteViewModel.State { offset: nil, following: nil ) - viewModel.context.apiService.search( - domain: domain, - query: query, - mastodonAuthenticationBox: 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: auto-complete fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) - stateMachine.enter(Fail.self) - case .finished: - break - } - } receiveValue: { response in + + do { + let response = try await viewModel.context.apiService.search( + query: query, + authenticationBox: authenticationBox + ) + + await enter(state: Idle.self) + guard viewModel.inputText.value == searchText else { return } // discard if not matching var items: [AutoCompleteItem] = [] items.append(contentsOf: response.value.accounts.map { AutoCompleteItem.account(account: $0) }) items.append(contentsOf: response.value.hashtags.map { AutoCompleteItem.hashtag(tag: $0) }) - stateMachine.enter(Idle.self) + viewModel.autoCompleteItems.value = items + + } catch { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): auto-complete fail: \(error.localizedDescription)") + await enter(state: Fail.self) } - .store(in: &viewModel.disposeBag) } private func reset(searchText: String) { diff --git a/Mastodon/Scene/Compose/AutoComplete/Cell/AutoCompleteTableViewCell.swift b/Mastodon/Scene/Compose/AutoComplete/Cell/AutoCompleteTableViewCell.swift index c1e7ab6a4..b7c8fcecc 100644 --- a/Mastodon/Scene/Compose/AutoComplete/Cell/AutoCompleteTableViewCell.swift +++ b/Mastodon/Scene/Compose/AutoComplete/Cell/AutoCompleteTableViewCell.swift @@ -8,6 +8,10 @@ import UIKit import FLAnimatedImage import MetaTextKit +import MastodonAsset +import MastodonLocalization +import MastodonUI + final class AutoCompleteTableViewCell: UITableViewCell { @@ -29,7 +33,7 @@ final class AutoCompleteTableViewCell: UITableViewCell { return stackView }() - let avatarImageView = FLAnimatedImageView() + let avatarImageView = AvatarImageView() let titleLabel: MetaLabel = { let label = MetaLabel(style: .autoCompletion) @@ -125,13 +129,6 @@ extension AutoCompleteTableViewCell { } -// MARK: - AvatarConfigurableView -extension AutoCompleteTableViewCell: AvatarConfigurableView { - static var configurableAvatarImageSize: CGSize { avatarImageSize } - static var configurableAvatarImageCornerRadius: CGFloat { avatarImageCornerRadius } - var configurableAvatarImageView: FLAnimatedImageView? { avatarImageView } -} - #if canImport(SwiftUI) && DEBUG import SwiftUI diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusAttachmentCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusAttachmentCollectionViewCell.swift index fee6ce753..76f011121 100644 --- a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusAttachmentCollectionViewCell.swift +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusAttachmentCollectionViewCell.swift @@ -9,6 +9,8 @@ import os.log import UIKit import Combine import MastodonUI +import MastodonAsset +import MastodonLocalization protocol ComposeStatusAttachmentCollectionViewCellDelegate: AnyObject { func composeStatusAttachmentCollectionViewCell(_ cell: ComposeStatusAttachmentCollectionViewCell, removeButtonDidPressed button: UIButton) diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollExpiresOptionCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollExpiresOptionCollectionViewCell.swift index e4569356f..7d976bfdf 100644 --- a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollExpiresOptionCollectionViewCell.swift +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollExpiresOptionCollectionViewCell.swift @@ -8,6 +8,8 @@ import os.log import UIKit import Combine +import MastodonAsset +import MastodonLocalization protocol ComposeStatusPollExpiresOptionCollectionViewCellDelegate: AnyObject { func composeStatusPollExpiresOptionCollectionViewCell(_ cell: ComposeStatusPollExpiresOptionCollectionViewCell, didSelectExpiresOption expiresOption: ComposeStatusPollItem.PollExpiresOptionAttribute.ExpiresOption) diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionAppendEntryCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionAppendEntryCollectionViewCell.swift index 7c8a6135f..e2702e7c6 100644 --- a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionAppendEntryCollectionViewCell.swift +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionAppendEntryCollectionViewCell.swift @@ -7,6 +7,8 @@ import os.log import UIKit +import MastodonAsset +import MastodonLocalization protocol ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate: AnyObject { func composeStatusPollOptionAppendEntryCollectionViewCellDidPressed(_ cell: ComposeStatusPollOptionAppendEntryCollectionViewCell) diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionCollectionViewCell.swift index ae90cd7b6..7ea43f154 100644 --- a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionCollectionViewCell.swift +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionCollectionViewCell.swift @@ -8,6 +8,8 @@ import os.log import UIKit import Combine +import MastodonAsset +import MastodonLocalization protocol ComposeStatusPollOptionCollectionViewCellDelegate: AnyObject { func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, textFieldDidBeginEditing textField: UITextField) diff --git a/Mastodon/Scene/Compose/CollectionViewCell/CustomEmojiPickerHeaderCollectionReusableView.swift b/Mastodon/Scene/Compose/CollectionViewCell/CustomEmojiPickerHeaderCollectionReusableView.swift index 30d5986ab..a43a57703 100644 --- a/Mastodon/Scene/Compose/CollectionViewCell/CustomEmojiPickerHeaderCollectionReusableView.swift +++ b/Mastodon/Scene/Compose/CollectionViewCell/CustomEmojiPickerHeaderCollectionReusableView.swift @@ -6,6 +6,8 @@ // import UIKit +import MastodonAsset +import MastodonLocalization final class CustomEmojiPickerHeaderCollectionReusableView: UICollectionReusableView { diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index 5968df428..f5dfc8ba3 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -9,11 +9,13 @@ import os.log import UIKit import Combine import PhotosUI -import MastodonSDK import MetaTextKit import MastodonMeta import Meta import MastodonUI +import MastodonAsset +import MastodonLocalization +import MastodonSDK final class ComposeViewController: UIViewController, NeedsDependency { @@ -40,22 +42,37 @@ final class ComposeViewController: UIViewController, NeedsDependency { let barButtonItem = UIBarButtonItem(customView: characterCountLabel) return barButtonItem }() + let publishButton: UIButton = { let button = RoundedEdgesButton(type: .custom) - button.setTitle(L10n.Scene.Compose.composeAction, for: .normal) - button.titleLabel?.font = .systemFont(ofSize: 14, weight: .bold) - button.setBackgroundImage(.placeholder(color: Asset.Colors.brandBlue.color), for: .normal) - button.setBackgroundImage(.placeholder(color: Asset.Colors.brandBlue.color.withAlphaComponent(0.5)), for: .highlighted) - button.setBackgroundImage(.placeholder(color: Asset.Colors.Button.disabled.color), for: .disabled) - button.setTitleColor(.white, for: .normal) + button.cornerRadius = 10 button.contentEdgeInsets = UIEdgeInsets(top: 6, left: 16, bottom: 5, right: 16) // set 28pt height - button.adjustsImageWhenHighlighted = false + button.titleLabel?.font = .systemFont(ofSize: 14, weight: .bold) + button.setTitle(L10n.Scene.Compose.composeAction, for: .normal) return button }() private(set) lazy var publishBarButtonItem: UIBarButtonItem = { - let barButtonItem = UIBarButtonItem(customView: publishButton) + configurePublishButtonApperance() + let shadowBackgroundContainer = ShadowBackgroundContainer() + publishButton.translatesAutoresizingMaskIntoConstraints = false + shadowBackgroundContainer.addSubview(publishButton) + NSLayoutConstraint.activate([ + publishButton.topAnchor.constraint(equalTo: shadowBackgroundContainer.topAnchor), + publishButton.leadingAnchor.constraint(equalTo: shadowBackgroundContainer.leadingAnchor), + publishButton.trailingAnchor.constraint(equalTo: shadowBackgroundContainer.trailingAnchor), + publishButton.bottomAnchor.constraint(equalTo: shadowBackgroundContainer.bottomAnchor), + ]) + let barButtonItem = UIBarButtonItem(customView: shadowBackgroundContainer) return barButtonItem }() + + private func configurePublishButtonApperance() { + publishButton.adjustsImageWhenHighlighted = false + publishButton.setBackgroundImage(.placeholder(color: Asset.Colors.Label.primary.color), for: .normal) + publishButton.setBackgroundImage(.placeholder(color: Asset.Colors.Label.primary.color.withAlphaComponent(0.5)), for: .highlighted) + publishButton.setBackgroundImage(.placeholder(color: Asset.Colors.Button.disabled.color), for: .disabled) + publishButton.setTitleColor(Asset.Colors.Label.primaryReverse.color, for: .normal) + } let tableView: ComposeTableView = { let tableView = ComposeTableView() @@ -115,9 +132,7 @@ final class ComposeViewController: UIViewController, NeedsDependency { let viewController = AutoCompleteViewController() viewController.viewModel = AutoCompleteViewModel(context: context) viewController.delegate = self - viewModel.customEmojiViewModel - .assign(to: \.value, on: viewController.viewModel.customEmojiViewModel) - .store(in: &disposeBag) + viewController.viewModel.customEmojiViewModel.value = viewModel.customEmojiViewModel return viewController }() @@ -155,7 +170,7 @@ extension ComposeViewController { } .store(in: &disposeBag) - viewModel.title + viewModel.$title .receive(on: DispatchQueue.main) .sink { [weak self] title in guard let self = self else { return } @@ -229,9 +244,9 @@ extension ComposeViewController { composeStatusPollExpiresOptionCollectionViewCellDelegate: self ) - viewModel.composeStatusAttribute.composeContent + viewModel.composeStatusAttribute.$composeContent .removeDuplicates() - .receive(on: RunLoop.main) + .receive(on: DispatchQueue.main) .sink { [weak self] _ in guard let self = self else { return } guard self.view.window != nil else { return } @@ -262,8 +277,8 @@ extension ComposeViewController { ) Publishers.CombineLatest3( keyboardEventPublishers, - viewModel.isCustomEmojiComposing, - viewModel.autoCompleteInfo + viewModel.$isCustomEmojiComposing, + viewModel.$autoCompleteInfo ) .sink(receiveValue: { [weak self] keyboardEvents, isCustomEmojiComposing, autoCompleteInfo in guard let self = self else { return } @@ -339,11 +354,11 @@ extension ComposeViewController { .store(in: &disposeBag) // bind auto-complete - viewModel.autoCompleteInfo + viewModel.$autoCompleteInfo .receive(on: DispatchQueue.main) .sink { [weak self] info in guard let self = self else { return } - guard let textEditorView = self.textEditorView() else { return } + let textEditorView = self.textEditorView if self.autoCompleteViewController.view.superview == nil { self.autoCompleteViewController.view.frame = self.view.bounds // add to container view. seealso: `viewDidLayoutSubviews()` @@ -364,13 +379,13 @@ extension ComposeViewController { .store(in: &disposeBag) // bind publish bar button state - viewModel.isPublishBarButtonItemEnabled + viewModel.$isPublishBarButtonItemEnabled .receive(on: DispatchQueue.main) - .assign(to: \.isEnabled, on: publishBarButtonItem) + .assign(to: \.isEnabled, on: publishButton) .store(in: &disposeBag) // bind media button toolbar state - viewModel.isMediaToolbarButtonEnabled + viewModel.$isMediaToolbarButtonEnabled .receive(on: DispatchQueue.main) .sink { [weak self] isMediaToolbarButtonEnabled in guard let self = self else { return } @@ -380,7 +395,7 @@ extension ComposeViewController { .store(in: &disposeBag) // bind poll button toolbar state - viewModel.isPollToolbarButtonEnabled + viewModel.$isPollToolbarButtonEnabled .receive(on: DispatchQueue.main) .sink { [weak self] isPollToolbarButtonEnabled in guard let self = self else { return } @@ -390,8 +405,8 @@ extension ComposeViewController { .store(in: &disposeBag) Publishers.CombineLatest( - viewModel.isPollComposing, - viewModel.isPollToolbarButtonEnabled + viewModel.$isPollComposing, + viewModel.$isPollToolbarButtonEnabled ) .receive(on: DispatchQueue.main) .sink { [weak self] isPollComposing, isPollToolbarButtonEnabled in @@ -409,7 +424,7 @@ extension ComposeViewController { .store(in: &disposeBag) // bind image picker toolbar state - viewModel.attachmentServices + viewModel.$attachmentServices .receive(on: DispatchQueue.main) .sink { [weak self] attachmentServices in guard let self = self else { return } @@ -421,7 +436,7 @@ extension ComposeViewController { .store(in: &disposeBag) // bind content warning button state - viewModel.isContentWarningComposing + viewModel.$isContentWarningComposing .receive(on: DispatchQueue.main) .sink { [weak self] isContentWarningComposing in guard let self = self else { return } @@ -433,7 +448,7 @@ extension ComposeViewController { // bind visibility toolbar UI Publishers.CombineLatest( - viewModel.selectedStatusVisibility, + viewModel.$selectedStatusVisibility, viewModel.traitCollectionDidChangePublisher ) .receive(on: DispatchQueue.main) @@ -446,7 +461,7 @@ extension ComposeViewController { } .store(in: &disposeBag) - viewModel.characterCount + viewModel.$characterCount .receive(on: DispatchQueue.main) .sink { [weak self] characterCount in guard let self = self else { return } @@ -477,14 +492,7 @@ extension ComposeViewController { .store(in: &disposeBag) // bind custom emoji picker UI - viewModel.customEmojiViewModel - .map { viewModel -> AnyPublisher<[Mastodon.Entity.Emoji], Never> in - guard let viewModel = viewModel else { - return Just([]).eraseToAnyPublisher() - } - return viewModel.emojis.eraseToAnyPublisher() - } - .switchToLatest() + viewModel.customEmojiViewModel?.emojis .receive(on: DispatchQueue.main) .sink(receiveValue: { [weak self] emojis in guard let self = self else { return } @@ -498,8 +506,8 @@ extension ComposeViewController { // setup snap behavior Publishers.CombineLatest( - viewModel.repliedToCellFrame, - viewModel.collectionViewState + viewModel.$repliedToCellFrame, + viewModel.$collectionViewState ) .receive(on: DispatchQueue.main) .sink { [weak self] repliedToCellFrame, collectionViewState in @@ -531,15 +539,11 @@ extension ComposeViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - - // using index to make table view layout - // otherwise, the content offset will be wrong - guard let indexPath = tableView.indexPath(for: viewModel.composeStatusContentTableViewCell), - let cell = tableView.cellForRow(at: indexPath) as? ComposeStatusContentTableViewCell else { - assertionFailure() - return - } - cell.metaText.textView.becomeFirstResponder() + + // update MetaText without trigger call underlaying `UITextStorage.processEditing` + _ = textEditorView.processEditing(textEditorView.textStorage) + + markTextEditorViewBecomeFirstResponser() } override func viewDidAppear(_ animated: Bool) { @@ -551,15 +555,17 @@ extension ComposeViewController { override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) + configurePublishButtonApperance() viewModel.traitCollectionDidChangePublisher.send() } override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() + updateAutoCompleteViewControllerLayout() } - func updateAutoCompleteViewControllerLayout() { + private func updateAutoCompleteViewControllerLayout() { // pin autoCompleteViewController frame to current view if let containerView = autoCompleteViewController.view.superview { let viewFrameInWindow = containerView.convert(autoCompleteViewController.view.frame, to: view) @@ -574,12 +580,12 @@ extension ComposeViewController { extension ComposeViewController { - private func textEditorView() -> MetaText? { + private var textEditorView: MetaText { return viewModel.composeStatusContentTableViewCell.metaText } private func markTextEditorViewBecomeFirstResponser() { - textEditorView()?.textView.becomeFirstResponder() + textEditorView.textView.becomeFirstResponder() } private func contentWarningEditorTextView() -> UITextView? { @@ -651,7 +657,7 @@ extension ComposeViewController { } private func resetImagePicker() { - let selectionLimit = max(1, viewModel.maxMediaAttachments - viewModel.attachmentServices.value.count) + let selectionLimit = max(1, viewModel.maxMediaAttachments - viewModel.attachmentServices.count) let configuration = ComposeViewController.createPhotoLibraryPickerConfiguration(selectionLimit: selectionLimit) photoLibraryPicker = createImagePicker(configuration: configuration) } @@ -663,20 +669,30 @@ extension ComposeViewController { } private func setupBackgroundColor(theme: Theme) { - view.backgroundColor = theme.systemElevatedBackgroundColor - tableView.backgroundColor = theme.systemElevatedBackgroundColor + let backgroundColor = UIColor(dynamicProvider: { traitCollection in + switch traitCollection.userInterfaceStyle { + case .light: + return .systemBackground + default: + return theme.systemElevatedBackgroundColor + } + }) + view.backgroundColor = backgroundColor + tableView.backgroundColor = backgroundColor composeToolbarBackgroundView.backgroundColor = theme.composeToolbarBackgroundColor } + // keyboard shortcutBar private func setupInputAssistantItem(item: UITextInputAssistantItem) { - let groups = [UIBarButtonItemGroup(barButtonItems: [ + let barButtonItems = [ composeToolbarView.mediaBarButtonItem, composeToolbarView.pollBarButtonItem, composeToolbarView.contentWarningBarButtonItem, composeToolbarView.visibilityBarButtonItem, - ], representativeItem: nil)] + ] + let group = UIBarButtonItemGroup(barButtonItems: barButtonItems, representativeItem: nil) - item.trailingBarButtonGroups = groups + item.trailingBarButtonGroups = [group] } private func configureToolbarDisplay(keyboardHasShortcutBar: Bool) { @@ -705,7 +721,7 @@ extension ComposeViewController { @objc private func cancelBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - guard viewModel.shouldDismiss.value else { + guard viewModel.shouldDismiss else { showDismissConfirmAlertController() return } @@ -740,7 +756,7 @@ extension ComposeViewController: MetaTextDelegate { let string = metaText.textStorage.string let content = MastodonContent( content: string, - emojis: viewModel.customEmojiViewModel.value?.emojiMapping.value ?? [:] + emojis: viewModel.customEmojiViewModel?.emojiMapping.value ?? [:] ) let metaContent = MastodonMetaContent.convert(text: content) return metaContent @@ -754,26 +770,20 @@ extension ComposeViewController: UITextViewDelegate { 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 { + switch textView { + case textEditorView.textView: // update model - guard let metaText = textEditorView() else { return } + let metaText = self.textEditorView let backedString = metaText.backedString - viewModel.composeStatusAttribute.composeContent.value = backedString + viewModel.composeStatusAttribute.composeContent = backedString logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): \(backedString)") - + // configure auto completion setupAutoComplete(for: textView) + default: + assertionFailure() } } @@ -794,7 +804,7 @@ extension ComposeViewController: UITextViewDelegate { private func setupAutoComplete(for textView: UITextView) { guard var autoCompletion = ComposeViewController.scanAutoCompleteInfo(textView: textView) else { - viewModel.autoCompleteInfo.value = nil + viewModel.autoCompleteInfo = nil return } os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: auto complete %s (%s)", ((#file as NSString).lastPathComponent), #line, #function, String(autoCompletion.toHighlightEndString), String(autoCompletion.toCursorString)) @@ -805,9 +815,9 @@ extension ComposeViewController: UITextViewDelegate { let textContainer = textView.layoutManager.textContainers[0] let textBoundingRect = textView.layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) - let retryLayoutTimes = viewModel.autoCompleteRetryLayoutTimes.value + let retryLayoutTimes = viewModel.autoCompleteRetryLayoutTimes guard textBoundingRect.size != .zero else { - viewModel.autoCompleteRetryLayoutTimes.value += 1 + viewModel.autoCompleteRetryLayoutTimes += 1 // avoid infinite loop guard retryLayoutTimes < 3 else { return } // needs retry calculate layout when the rect position changing @@ -816,7 +826,7 @@ extension ComposeViewController: UITextViewDelegate { } return } - viewModel.autoCompleteRetryLayoutTimes.value = 0 + viewModel.autoCompleteRetryLayoutTimes = 0 // get symbol bounding rect textView.layoutManager.characterRange(forGlyphRange: NSRange(autoCompletion.symbolRange, in: textView.text), actualGlyphRange: &glyphRange) @@ -825,7 +835,7 @@ extension ComposeViewController: UITextViewDelegate { // set bounding rect and trigger layout autoCompletion.textBoundingRect = textBoundingRect autoCompletion.symbolBoundingRect = symbolBoundingRect - viewModel.autoCompleteInfo.value = autoCompletion + viewModel.autoCompleteInfo = autoCompletion } private static func scanAutoCompleteInfo(textView: UITextView) -> AutoCompleteInfo? { @@ -883,19 +893,21 @@ extension ComposeViewController: UITextViewDelegate { } func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { - if textView === textEditorView()?.textView { + switch textView { + case textEditorView.textView: return false + default: + return true } - - return true } func textView(_ textView: UITextView, shouldInteractWith textAttachment: NSTextAttachment, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { - if textView === textEditorView()?.textView { + switch textView { + case textEditorView.textView: return false + default: + return true } - - return true } } @@ -903,30 +915,41 @@ extension ComposeViewController: UITextViewDelegate { // MARK: - ComposeToolbarViewDelegate extension ComposeViewController: ComposeToolbarViewDelegate { - func composeToolbarView(_ composeToolbarView: ComposeToolbarView, cameraButtonDidPressed sender: Any, mediaSelectionType type: ComposeToolbarView.MediaSelectionType) { + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, mediaButtonDidPressed sender: Any, mediaSelectionType type: ComposeToolbarView.MediaSelectionType) { switch type { case .photoLibrary: present(photoLibraryPicker, animated: true, completion: nil) case .camera: present(imagePickerController, animated: true, completion: nil) case .browse: + #if SNAPSHOT + guard let image = UIImage(named: "Athens") else { return } + + let attachmentService = MastodonAttachmentService( + context: context, + image: image, + initialAuthenticationBox: viewModel.authenticationBox + ) + viewModel.attachmentServices = viewModel.attachmentServices + [attachmentService] + #else present(documentPickerController, animated: true, completion: nil) + #endif } } func composeToolbarView(_ composeToolbarView: ComposeToolbarView, pollButtonDidPressed sender: Any) { // toggle poll composing state - viewModel.isPollComposing.value.toggle() + viewModel.isPollComposing.toggle() // cancel custom picker input - viewModel.isCustomEmojiComposing.value = false + viewModel.isCustomEmojiComposing = false // setup initial poll option if needs - if viewModel.isPollComposing.value, viewModel.pollOptionAttributes.value.isEmpty { - viewModel.pollOptionAttributes.value = [ComposeStatusPollItem.PollOptionAttribute(), ComposeStatusPollItem.PollOptionAttribute()] + if viewModel.isPollComposing, viewModel.pollOptionAttributes.isEmpty { + viewModel.pollOptionAttributes = [ComposeStatusPollItem.PollOptionAttribute(), ComposeStatusPollItem.PollOptionAttribute()] } - if viewModel.isPollComposing.value { + if viewModel.isPollComposing { // Magic RunLoop DispatchQueue.main.async { self.markFirstPollOptionCollectionViewCellBecomeFirstResponser() @@ -937,31 +960,31 @@ extension ComposeViewController: ComposeToolbarViewDelegate { } func composeToolbarView(_ composeToolbarView: ComposeToolbarView, emojiButtonDidPressed sender: Any) { - viewModel.isCustomEmojiComposing.value.toggle() + viewModel.isCustomEmojiComposing.toggle() } func composeToolbarView(_ composeToolbarView: ComposeToolbarView, contentWarningButtonDidPressed sender: Any) { // cancel custom picker input - viewModel.isCustomEmojiComposing.value = false + viewModel.isCustomEmojiComposing = false // restore first responder for text editor when content warning dismiss - if viewModel.isContentWarningComposing.value { + if viewModel.isContentWarningComposing { if contentWarningEditorTextView()?.isFirstResponder == true { markTextEditorViewBecomeFirstResponser() } } // toggle composing status - viewModel.isContentWarningComposing.value.toggle() + viewModel.isContentWarningComposing.toggle() // active content warning after toggled - if viewModel.isContentWarningComposing.value { + if viewModel.isContentWarningComposing { contentWarningEditorTextView()?.becomeFirstResponder() } } func composeToolbarView(_ composeToolbarView: ComposeToolbarView, visibilityButtonDidPressed sender: Any, visibilitySelectionType type: ComposeToolbarView.VisibilitySelectionType) { - viewModel.selectedStatusVisibility.value = type + viewModel.selectedStatusVisibility = type } } @@ -971,7 +994,7 @@ extension ComposeViewController { func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) { guard scrollView === tableView else { return } - let repliedToCellFrame = viewModel.repliedToCellFrame.value + let repliedToCellFrame = viewModel.repliedToCellFrame guard repliedToCellFrame != .zero else { return } // try to find some patterns: @@ -984,7 +1007,7 @@ extension ComposeViewController { // scrollView.adjustedContentInset.bottom: \(scrollView.adjustedContentInset.bottom) // """) - switch viewModel.collectionViewState.value { + switch viewModel.collectionViewState { case .fold: os_log("%{public}s[%{public}ld], %{public}s: fold", ((#file as NSString).lastPathComponent), #line, #function) guard velocity.y < 0 else { return } @@ -992,7 +1015,7 @@ extension ComposeViewController { if offsetY < -44 { tableView.contentInset.top = 0 targetContentOffset.pointee = CGPoint(x: 0, y: -scrollView.adjustedContentInset.top) - viewModel.collectionViewState.value = .expand + viewModel.collectionViewState = .expand } case .expand: @@ -1007,11 +1030,11 @@ extension ComposeViewController { if topOffset > 44 { // do not interrupt user scrolling - viewModel.collectionViewState.value = .fold + viewModel.collectionViewState = .fold } else if bottomOffset > 44 { tableView.contentInset.top = -repliedToCellFrame.height targetContentOffset.pointee = CGPoint(x: 0, y: -repliedToCellFrame.height) - viewModel.collectionViewState.value = .fold + viewModel.collectionViewState = .fold } } } @@ -1057,7 +1080,7 @@ extension ComposeViewController: UIAdaptivePresentationControllerDelegate { } func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool { - return viewModel.shouldDismiss.value + return viewModel.shouldDismiss } func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) { @@ -1081,11 +1104,11 @@ extension ComposeViewController: PHPickerViewControllerDelegate { let service = MastodonAttachmentService( context: context, pickerResult: result, - initialAuthenticationBox: viewModel.activeAuthenticationBox.value + initialAuthenticationBox: viewModel.authenticationBox ) return service } - viewModel.attachmentServices.value = viewModel.attachmentServices.value + attachmentServices + viewModel.attachmentServices = viewModel.attachmentServices + attachmentServices } } @@ -1100,9 +1123,9 @@ extension ComposeViewController: UIImagePickerControllerDelegate & UINavigationC let attachmentService = MastodonAttachmentService( context: context, image: image, - initialAuthenticationBox: viewModel.activeAuthenticationBox.value + initialAuthenticationBox: viewModel.authenticationBox ) - viewModel.attachmentServices.value = viewModel.attachmentServices.value + [attachmentService] + viewModel.attachmentServices = viewModel.attachmentServices + [attachmentService] } func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { @@ -1119,9 +1142,9 @@ extension ComposeViewController: UIDocumentPickerDelegate { let attachmentService = MastodonAttachmentService( context: context, documentURL: url, - initialAuthenticationBox: viewModel.activeAuthenticationBox.value + initialAuthenticationBox: viewModel.authenticationBox ) - viewModel.attachmentServices.value = viewModel.attachmentServices.value + [attachmentService] + viewModel.attachmentServices = viewModel.attachmentServices + [attachmentService] } } @@ -1134,11 +1157,11 @@ extension ComposeViewController: ComposeStatusAttachmentCollectionViewCellDelega guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } guard case let .attachment(attachmentService) = item else { return } - var attachmentServices = viewModel.attachmentServices.value + var attachmentServices = viewModel.attachmentServices guard let index = attachmentServices.firstIndex(of: attachmentService) else { return } let removedItem = attachmentServices[index] attachmentServices.remove(at: index) - viewModel.attachmentServices.value = attachmentServices + viewModel.attachmentServices = attachmentServices // cancel task removedItem.disposeBag.removeAll() @@ -1168,7 +1191,7 @@ extension ComposeViewController: ComposeStatusPollOptionCollectionViewCellDelega guard let item = dataSource.itemIdentifier(for: indexPath) else { return } guard case let .pollOption(attribute) = item else { return } - var pollAttributes = viewModel.pollOptionAttributes.value + var pollAttributes = viewModel.pollOptionAttributes guard let index = pollAttributes.firstIndex(of: attribute) else { return } // mark previous (fallback to next) item of removed middle poll option become first responder @@ -1201,7 +1224,7 @@ extension ComposeViewController: ComposeStatusPollOptionCollectionViewCellDelega pollAttributes.remove(at: index) // update data source - viewModel.pollOptionAttributes.value = pollAttributes + viewModel.pollOptionAttributes = pollAttributes } // handle keyboard return event for poll option input @@ -1260,7 +1283,7 @@ extension ComposeViewController: ComposeStatusContentTableViewCellDelegate { // MARK: - AutoCompleteViewControllerDelegate extension ComposeViewController: AutoCompleteViewControllerDelegate { func autoCompleteViewController(_ viewController: AutoCompleteViewController, didSelectItem item: AutoCompleteItem) { - guard let info = viewModel.autoCompleteInfo.value else { return } + guard let info = viewModel.autoCompleteInfo else { return } let _replacedText: String? = { var text: String switch item { @@ -1278,17 +1301,14 @@ extension ComposeViewController: AutoCompleteViewControllerDelegate { return text }() guard let replacedText = _replacedText else { return } - - guard let textEditorView = textEditorView(), - let text = textEditorView.textView.text else { return } - + guard let text = textEditorView.textView.text else { return } let range = NSRange(info.toHighlightEndRange, in: text) textEditorView.textStorage.replaceCharacters(in: range, with: replacedText) DispatchQueue.main.async { - textEditorView.textView.insertText(" ") // trigger textView delegate update + self.textEditorView.textView.insertText(" ") // trigger textView delegate update } - viewModel.autoCompleteInfo.value = nil + viewModel.autoCompleteInfo = nil switch item { case .emoji, .bottomLoader: @@ -1418,13 +1438,13 @@ extension ComposeViewController { case .toggleContentWarning: composeToolbarView.contentWarningButton.sendActions(for: .touchUpInside) case .selectVisibilityPublic: - viewModel.selectedStatusVisibility.value = .public + viewModel.selectedStatusVisibility = .public // case .selectVisibilityUnlisted: // viewModel.selectedStatusVisibility.value = .unlisted case .selectVisibilityPrivate: - viewModel.selectedStatusVisibility.value = .private + viewModel.selectedStatusVisibility = .private case .selectVisibilityDirect: - viewModel.selectedStatusVisibility.value = .direct + viewModel.selectedStatusVisibility = .direct } } diff --git a/Mastodon/Scene/Compose/ComposeViewModel+DataSource.swift b/Mastodon/Scene/Compose/ComposeViewModel+DataSource.swift index 7fd07bf83..c638eb769 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel+DataSource.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel+DataSource.swift @@ -12,6 +12,8 @@ import CoreDataStack import MastodonSDK import MastodonMeta import MetaTextKit +import MastodonAsset +import MastodonLocalization extension ComposeViewModel { @@ -25,12 +27,20 @@ extension ComposeViewModel { composeStatusPollOptionAppendEntryCollectionViewCellDelegate: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate, composeStatusPollExpiresOptionCollectionViewCellDelegate: ComposeStatusPollExpiresOptionCollectionViewCellDelegate ) { + // UI + bind() + // content + bind(cell: composeStatusContentTableViewCell, tableView: tableView) composeStatusContentTableViewCell.metaText.delegate = metaTextDelegate composeStatusContentTableViewCell.metaText.textView.delegate = metaTextViewDelegate + // attachment + bind(cell: composeStatusAttachmentTableViewCell, tableView: tableView) composeStatusAttachmentTableViewCell.composeStatusAttachmentCollectionViewCellDelegate = composeStatusAttachmentCollectionViewCellDelegate + // poll + bind(cell: composeStatusPollTableViewCell, tableView: tableView) composeStatusPollTableViewCell.delegate = self composeStatusPollTableViewCell.customEmojiPickerInputViewModel = customEmojiPickerInputViewModel composeStatusPollTableViewCell.composeStatusPollOptionCollectionViewCellDelegate = composeStatusPollOptionCollectionViewCellDelegate @@ -38,43 +48,349 @@ extension ComposeViewModel { composeStatusPollTableViewCell.composeStatusPollExpiresOptionCollectionViewCellDelegate = composeStatusPollExpiresOptionCollectionViewCellDelegate // setup data source - tableView.dataSource = self + tableView.dataSource = self + } + + func setupCustomEmojiPickerDiffableDataSource( + for collectionView: UICollectionView, + dependency: NeedsDependency + ) { + let diffableDataSource = CustomEmojiPickerSection.collectionViewDiffableDataSource( + for: collectionView, + dependency: dependency + ) + self.customEmojiPickerDiffableDataSource = diffableDataSource - composeStatusAttachmentTableViewCell.collectionViewHeightDidUpdate + let _domain = customEmojiViewModel?.domain + customEmojiViewModel?.emojis .receive(on: DispatchQueue.main) - .sink { [weak self] _ in + .sink { [weak self, weak diffableDataSource] emojis in guard let _ = self else { return } - tableView.beginUpdates() - tableView.endUpdates() + guard let diffableDataSource = diffableDataSource else { return } + + var snapshot = NSDiffableDataSourceSnapshot<CustomEmojiPickerSection, CustomEmojiPickerItem>() + let domain = _domain?.uppercased() ?? " " + let customEmojiSection = CustomEmojiPickerSection.emoji(name: domain) + snapshot.appendSections([customEmojiSection]) + let items: [CustomEmojiPickerItem] = { + var items = [CustomEmojiPickerItem]() + for emoji in emojis where emoji.visibleInPicker { + let attribute = CustomEmojiPickerItem.CustomEmojiAttribute(emoji: emoji) + let item = CustomEmojiPickerItem.emoji(attribute: attribute) + items.append(item) + } + return items + }() + snapshot.appendItems(items, toSection: customEmojiSection) + + diffableDataSource.apply(snapshot) } .store(in: &disposeBag) + } + +} - attachmentServices - .removeDuplicates() +// MARK: - UITableViewDataSource +extension ComposeViewModel: UITableViewDataSource { + + enum Section: CaseIterable { + case repliedTo + case status + case attachment + case poll + } + + func numberOfSections(in tableView: UITableView) -> Int { + return Section.allCases.count + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + switch Section.allCases[section] { + case .repliedTo: + switch composeKind { + case .reply: return 1 + default: return 0 + } + case .status: return 1 + case .attachment: return 1 + case .poll: return 1 + } + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + switch Section.allCases[indexPath.section] { + case .repliedTo: + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ComposeRepliedToStatusContentTableViewCell.self), for: indexPath) as! ComposeRepliedToStatusContentTableViewCell + guard case let .reply(record) = composeKind else { return cell } + + // bind frame publisher + cell.framePublisher + .receive(on: DispatchQueue.main) + .assign(to: \.repliedToCellFrame, on: self) + .store(in: &cell.disposeBag) + + // set initial width + if cell.statusView.frame.width == .zero { + cell.statusView.frame.size.width = tableView.frame.width + } + + // configure status + context.managedObjectContext.performAndWait { + guard let replyTo = record.object(in: context.managedObjectContext) else { return } + cell.statusView.configure(status: replyTo) + } + + return cell + case .status: + return composeStatusContentTableViewCell + case .attachment: + return composeStatusAttachmentTableViewCell + case .poll: + return composeStatusPollTableViewCell + } + } +} + +// MARK: - ComposeStatusPollTableViewCellDelegate +extension ComposeViewModel: ComposeStatusPollTableViewCellDelegate { + func composeStatusPollTableViewCell(_ cell: ComposeStatusPollTableViewCell, pollOptionAttributesDidReorder options: [ComposeStatusPollItem.PollOptionAttribute]) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + + self.pollOptionAttributes = options + } +} + +extension ComposeViewModel { + private func bind() { + $isCustomEmojiComposing + .assign(to: \.value, on: customEmojiPickerInputViewModel.isCustomEmojiComposing) + .store(in: &disposeBag) + + $isContentWarningComposing + .assign(to: \.isContentWarningComposing, on: composeStatusAttribute) + .store(in: &disposeBag) + + // bind compose toolbar UI state + Publishers.CombineLatest( + $isPollComposing, + $attachmentServices + ) + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] isPollComposing, attachmentServices in + guard let self = self else { return } + let shouldMediaDisable = isPollComposing || attachmentServices.count >= self.maxMediaAttachments + let shouldPollDisable = attachmentServices.count > 0 + + self.isMediaToolbarButtonEnabled = !shouldMediaDisable + self.isPollToolbarButtonEnabled = !shouldPollDisable + }) + .store(in: &disposeBag) + + // calculate `Idempotency-Key` + let content = Publishers.CombineLatest3( + composeStatusAttribute.$isContentWarningComposing, + composeStatusAttribute.$contentWarningContent, + composeStatusAttribute.$composeContent + ) + .map { isContentWarningComposing, contentWarningContent, composeContent -> String in + if isContentWarningComposing { + return contentWarningContent + (composeContent ?? "") + } else { + return composeContent ?? "" + } + } + let attachmentIDs = $attachmentServices.map { attachments -> String in + let attachmentIDs = attachments.compactMap { $0.attachment.value?.id } + return attachmentIDs.joined(separator: ",") + } + let pollOptionsAndDuration = Publishers.CombineLatest3( + $isPollComposing, + $pollOptionAttributes, + pollExpiresOptionAttribute.expiresOption + ) + .map { isPollComposing, pollOptionAttributes, expiresOption -> String in + guard isPollComposing else { + return "" + } + + let pollOptions = pollOptionAttributes.map { $0.option.value }.joined(separator: ",") + return pollOptions + expiresOption.rawValue + } + + Publishers.CombineLatest4( + content, + attachmentIDs, + pollOptionsAndDuration, + $selectedStatusVisibility + ) + .map { content, attachmentIDs, pollOptionsAndDuration, selectedStatusVisibility -> String in + var hasher = Hasher() + hasher.combine(content) + hasher.combine(attachmentIDs) + hasher.combine(pollOptionsAndDuration) + hasher.combine(selectedStatusVisibility.visibility.rawValue) + let hashValue = hasher.finalize() + return "\(hashValue)" + } + .assign(to: \.value, on: idempotencyKey) + .store(in: &disposeBag) + + // bind modal dismiss state + composeStatusAttribute.$composeContent .receive(on: DispatchQueue.main) - .sink { [weak self] attachmentServices in - guard let self = self else { return } - guard self.isViewAppeared else { return } + .map { [weak self] content in + let content = content ?? "" + if content.isEmpty { + return true + } + // if preInsertedContent plus a space is equal to the content, simply dismiss the modal + if let preInsertedContent = self?.preInsertedContent { + return content == preInsertedContent + } + return false + } + .assign(to: &$shouldDismiss) + + // bind compose bar button item UI state + let isComposeContentEmpty = composeStatusAttribute.$composeContent + .map { ($0 ?? "").isEmpty } + let isComposeContentValid = $characterCount + .compactMap { [weak self] characterCount -> Bool in + guard let self = self else { return characterCount <= 500 } + return characterCount <= self.composeContentLimit + } + let isMediaEmpty = $attachmentServices + .map { $0.isEmpty } + let isMediaUploadAllSuccess = $attachmentServices + .map { services in + services.allSatisfy { $0.uploadStateMachineSubject.value is MastodonAttachmentService.UploadState.Finish } + } + let isPollAttributeAllValid = $pollOptionAttributes + .map { pollAttributes in + pollAttributes.allSatisfy { attribute -> Bool in + !attribute.option.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + } + + let isPublishBarButtonItemEnabledPrecondition1 = Publishers.CombineLatest4( + isComposeContentEmpty, + isComposeContentValid, + isMediaEmpty, + isMediaUploadAllSuccess + ) + .map { isComposeContentEmpty, isComposeContentValid, isMediaEmpty, isMediaUploadAllSuccess -> Bool in + if isMediaEmpty { + return isComposeContentValid && !isComposeContentEmpty + } else { + return isComposeContentValid && isMediaUploadAllSuccess + } + } + .eraseToAnyPublisher() + + let isPublishBarButtonItemEnabledPrecondition2 = Publishers.CombineLatest4( + isComposeContentEmpty, + isComposeContentValid, + $isPollComposing, + isPollAttributeAllValid + ) + .map { isComposeContentEmpty, isComposeContentValid, isPollComposing, isPollAttributeAllValid -> Bool in + if isPollComposing { + return isComposeContentValid && !isComposeContentEmpty && isPollAttributeAllValid + } else { + return isComposeContentValid && !isComposeContentEmpty + } + } + .eraseToAnyPublisher() + + Publishers.CombineLatest( + isPublishBarButtonItemEnabledPrecondition1, + isPublishBarButtonItemEnabledPrecondition2 + ) + .map { $0 && $1 } + .assign(to: &$isPublishBarButtonItemEnabled) + } +} - let cell = self.composeStatusAttachmentTableViewCell - guard let dataSource = cell.dataSource else { return } - - var snapshot = NSDiffableDataSourceSnapshot<ComposeStatusAttachmentSection, ComposeStatusAttachmentItem>() - snapshot.appendSections([.main]) - let items = attachmentServices.map { ComposeStatusAttachmentItem.attachment(attachmentService: $0) } - snapshot.appendItems(items, toSection: .main) - - if #available(iOS 15.0, *) { - dataSource.applySnapshotUsingReloadData(snapshot) - } else { - dataSource.apply(snapshot, animatingDifferences: false) +extension ComposeViewModel { + private func bind( + cell: ComposeStatusContentTableViewCell, + tableView: UITableView + ) { + // bind status content character count + Publishers.CombineLatest3( + composeStatusAttribute.$composeContent, + composeStatusAttribute.$isContentWarningComposing, + composeStatusAttribute.$contentWarningContent + ) + .map { composeContent, isContentWarningComposing, contentWarningContent -> Int in + let composeContent = composeContent ?? "" + var count = composeContent.count + if isContentWarningComposing { + count += contentWarningContent.count + } + return count + } + .assign(to: &$characterCount) + + // bind content warning + composeStatusAttribute.$isContentWarningComposing + .receive(on: DispatchQueue.main) + .sink { [weak cell, weak tableView] isContentWarningComposing in + guard let cell = cell else { return } + guard let tableView = tableView else { return } + + // self size input cell + cell.statusContentWarningEditorView.isHidden = !isContentWarningComposing + cell.statusContentWarningEditorView.alpha = 0 + UIView.animate(withDuration: 0.33, delay: 0, options: [.curveEaseOut]) { + cell.statusContentWarningEditorView.alpha = 1 + tableView.beginUpdates() + tableView.endUpdates() + } completion: { _ in + // do nothing } } .store(in: &disposeBag) + + cell.contentWarningContent + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak tableView, weak self] text in + guard let self = self else { return } + // bind input data + self.composeStatusAttribute.contentWarningContent = text + // self size input cell + guard let tableView = tableView else { return } + UIView.performWithoutAnimation { + tableView.beginUpdates() + tableView.endUpdates() + } + } + .store(in: &cell.disposeBag) + + // configure custom emoji picker + ComposeStatusSection.configureCustomEmojiPicker( + viewModel: customEmojiPickerInputViewModel, + customEmojiReplaceableTextInput: cell.metaText.textView, + disposeBag: &disposeBag + ) + ComposeStatusSection.configureCustomEmojiPicker( + viewModel: customEmojiPickerInputViewModel, + customEmojiReplaceableTextInput: cell.statusContentWarningEditorView.textView, + disposeBag: &disposeBag + ) + } +} + +extension ComposeViewModel { + private func bind( + cell: ComposeStatusPollTableViewCell, + tableView: UITableView + ) { Publishers.CombineLatest( - isPollComposing, - pollOptionAttributes + $isPollComposing, + $pollOptionAttributes ) .receive(on: DispatchQueue.main) .sink { [weak self] isPollComposing, pollOptionAttributes in @@ -107,212 +423,91 @@ extension ComposeViewModel { } } .store(in: &disposeBag) - } - - func setupCustomEmojiPickerDiffableDataSource( - for collectionView: UICollectionView, - dependency: NeedsDependency - ) { - let diffableDataSource = CustomEmojiPickerSection.collectionViewDiffableDataSource( - for: collectionView, - dependency: dependency - ) - self.customEmojiPickerDiffableDataSource = diffableDataSource - customEmojiViewModel - .sink { [weak self, weak diffableDataSource] customEmojiViewModel in + // bind delegate + $pollOptionAttributes + .sink { [weak self] pollAttributes in guard let self = self else { return } - guard let diffableDataSource = diffableDataSource else { return } - guard let customEmojiViewModel = customEmojiViewModel else { - self.customEmojiViewModelSubscription = nil - let snapshot = NSDiffableDataSourceSnapshot<CustomEmojiPickerSection, CustomEmojiPickerItem>() - diffableDataSource.apply(snapshot) - return - } - - self.customEmojiViewModelSubscription = customEmojiViewModel.emojis - .receive(on: DispatchQueue.main) - .sink { [weak self, weak diffableDataSource] emojis in - guard let _ = self else { return } - guard let diffableDataSource = diffableDataSource else { return } - var snapshot = NSDiffableDataSourceSnapshot<CustomEmojiPickerSection, CustomEmojiPickerItem>() - let customEmojiSection = CustomEmojiPickerSection.emoji(name: customEmojiViewModel.domain.uppercased()) - snapshot.appendSections([customEmojiSection]) - let items: [CustomEmojiPickerItem] = { - var items = [CustomEmojiPickerItem]() - for emoji in emojis where emoji.visibleInPicker { - let attribute = CustomEmojiPickerItem.CustomEmojiAttribute(emoji: emoji) - let item = CustomEmojiPickerItem.emoji(attribute: attribute) - items.append(item) - } - return items - }() - snapshot.appendItems(items, toSection: customEmojiSection) - diffableDataSource.apply(snapshot) - } + pollAttributes.forEach { $0.delegate = self } } .store(in: &disposeBag) } - } -// MARK: - UITableViewDataSource -extension ComposeViewModel: UITableViewDataSource { - - enum Section: CaseIterable { - case repliedTo - case status - case attachment - case poll - } - - func numberOfSections(in tableView: UITableView) -> Int { - return Section.allCases.count - } - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - switch Section.allCases[section] { - case .repliedTo: - switch composeKind { - case .reply: return 1 - default: return 0 +extension ComposeViewModel { + private func bind( + cell: ComposeStatusAttachmentTableViewCell, + tableView: UITableView + ) { + cell.collectionViewHeightDidUpdate + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let _ = self else { return } + tableView.beginUpdates() + tableView.endUpdates() } - case .status: return 1 - case .attachment: - return 1 - case .poll: - return 1 - } - } + .store(in: &disposeBag) - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - switch Section.allCases[indexPath.section] { - case .repliedTo: - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ComposeRepliedToStatusContentTableViewCell.self), for: indexPath) as! ComposeRepliedToStatusContentTableViewCell - guard case let .reply(statusObjectID) = composeKind else { return cell } - cell.framePublisher - .receive(on: DispatchQueue.main) - .assign(to: \.value, on: self.repliedToCellFrame) - .store(in: &cell.disposeBag) - let managedObjectContext = context.managedObjectContext - managedObjectContext.performAndWait { - guard let replyTo = managedObjectContext.object(with: statusObjectID) as? Status else { - return - } - let status = replyTo.reblog ?? replyTo + $attachmentServices + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] attachmentServices in + guard let self = self else { return } + guard self.isViewAppeared else { return } - // set avatar - cell.statusView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: status.author.avatarImageURL())) - // set name, username - do { - let mastodonContent = MastodonContent(content: status.author.displayNameWithFallback, emojis: status.author.emojiMeta) - let metaContent = try MastodonMetaContent.convert(document: mastodonContent) - cell.statusView.nameLabel.configure(content: metaContent) - } catch { - let metaContent = PlaintextMetaContent(string: status.author.displayNameWithFallback) - cell.statusView.nameLabel.configure(content: metaContent) - } - cell.statusView.usernameLabel.text = "@" + status.author.acct - // set text - let content = MastodonContent(content: status.content, emojis: status.emojiMeta) - do { - let metaContent = try MastodonMetaContent.convert(document: content) - cell.statusView.contentMetaText.configure(content: metaContent) - } catch { - cell.statusView.contentMetaText.textView.text = " " - assertionFailure() - } - // set date - cell.statusView.dateLabel.text = status.createdAt.localizedSlowedTimeAgoSinceNow - } - return cell - case .status: - let cell = self.composeStatusContentTableViewCell - // configure header - let managedObjectContext = context.managedObjectContext - managedObjectContext.performAndWait { - guard case let .reply(replyToStatusObjectID) = self.composeKind, - let replyTo = managedObjectContext.object(with: replyToStatusObjectID) as? Status else { - cell.statusView.headerContainerView.isHidden = true - return - } - cell.statusView.headerContainerView.isHidden = false - 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 - return L10n.Scene.Compose.replyingToUser(name) - }() - do { - let mastodonContent = MastodonContent(content: headerText, emojis: replyTo.author.emojiMeta) - let metaContent = try MastodonMetaContent.convert(document: mastodonContent) - cell.statusView.headerInfoLabel.configure(content: metaContent) - } catch { - let metaContent = PlaintextMetaContent(string: headerText) - cell.statusView.headerInfoLabel.configure(content: metaContent) + let cell = self.composeStatusAttachmentTableViewCell + guard let dataSource = cell.dataSource else { return } + + var snapshot = NSDiffableDataSourceSnapshot<ComposeStatusAttachmentSection, ComposeStatusAttachmentItem>() + snapshot.appendSections([.main]) + let items = attachmentServices.map { ComposeStatusAttachmentItem.attachment(attachmentService: $0) } + snapshot.appendItems(items, toSection: .main) + + if #available(iOS 15.0, *) { + dataSource.applySnapshotUsingReloadData(snapshot) + } else { + dataSource.apply(snapshot, animatingDifferences: false) } } - // configure author - ComposeStatusSection.configureStatusContent(cell: cell, attribute: composeStatusAttribute) - // configure content. bind text in UITextViewDelegate - if let composeContent = composeStatusAttribute.composeContent.value { - cell.metaText.textView.text = composeContent - } - // configure content warning - cell.statusContentWarningEditorView.textView.text = composeStatusAttribute.contentWarningContent.value - // bind content warning - composeStatusAttribute.isContentWarningComposing - .receive(on: DispatchQueue.main) - .sink { [weak cell, weak tableView] isContentWarningComposing in - guard let cell = cell else { return } - guard let tableView = tableView else { return } - // self size input cell - cell.statusContentWarningEditorView.isHidden = !isContentWarningComposing - cell.statusContentWarningEditorView.alpha = 0 - UIView.animate(withDuration: 0.33, delay: 0, options: [.curveEaseOut]) { - cell.statusContentWarningEditorView.alpha = 1 - tableView.beginUpdates() - tableView.endUpdates() - } completion: { _ in - // do nothing + .store(in: &disposeBag) + + // setup attribute updater + $attachmentServices + .receive(on: DispatchQueue.main) + .debounce(for: 0.3, scheduler: DispatchQueue.main) + .sink { attachmentServices in + // drive service upload state + // make image upload in the queue + for attachmentService in attachmentServices { + // skip when prefix N task when task finish OR fail OR uploading + guard let currentState = attachmentService.uploadStateMachine.currentState else { break } + if currentState is MastodonAttachmentService.UploadState.Fail { + continue + } + if currentState is MastodonAttachmentService.UploadState.Finish { + continue + } + if currentState is MastodonAttachmentService.UploadState.Processing { + continue + } + if currentState is MastodonAttachmentService.UploadState.Uploading { + break + } + // trigger uploading one by one + if currentState is MastodonAttachmentService.UploadState.Initial { + attachmentService.uploadStateMachine.enter(MastodonAttachmentService.UploadState.Uploading.self) + break } } - .store(in: &cell.disposeBag) - cell.contentWarningContent - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink { [weak tableView, weak self] text in - guard let self = self else { return } - // bind input data - self.composeStatusAttribute.contentWarningContent.value = text - - // self size input cell - guard let tableView = tableView else { return } - UIView.performWithoutAnimation { - tableView.beginUpdates() - tableView.endUpdates() - } - } - .store(in: &cell.disposeBag) - // configure custom emoji picker - ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplaceableTextInput: cell.metaText.textView, disposeBag: &cell.disposeBag) - ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplaceableTextInput: cell.statusContentWarningEditorView.textView, disposeBag: &cell.disposeBag) - return cell - case .attachment: - let cell = self.composeStatusAttachmentTableViewCell - return cell - case .poll: - let cell = self.composeStatusPollTableViewCell - return cell - } - } -} - -// MARK: - ComposeStatusPollTableViewCellDelegate -extension ComposeViewModel: ComposeStatusPollTableViewCellDelegate { - func composeStatusPollTableViewCell(_ cell: ComposeStatusPollTableViewCell, pollOptionAttributesDidReorder options: [ComposeStatusPollItem.PollOptionAttribute]) { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - - self.pollOptionAttributes.value = options + } + .store(in: &disposeBag) + + // bind delegate + $attachmentServices + .sink { [weak self] attachmentServices in + guard let self = self else { return } + attachmentServices.forEach { $0.delegate = self } + } + .store(in: &disposeBag) } } diff --git a/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift b/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift index 8f739315d..761391814 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift @@ -45,44 +45,41 @@ extension ComposeViewModel.PublishState { override func didEnter(from previousState: GKState?) { super.didEnter(from: previousState) guard let viewModel = viewModel, let stateMachine = stateMachine else { return } - guard let mastodonAuthenticationBox = viewModel.activeAuthenticationBox.value else { - stateMachine.enter(Fail.self) - return - } viewModel.updatePublishDate() - let domain = mastodonAuthenticationBox.domain - let attachmentServices = viewModel.attachmentServices.value + let authenticationBox = viewModel.authenticationBox + let domain = authenticationBox.domain + let attachmentServices = viewModel.attachmentServices let mediaIDs = attachmentServices.compactMap { attachmentService in attachmentService.attachment.value?.id } let pollOptions: [String]? = { - guard viewModel.isPollComposing.value else { return nil } - return viewModel.pollOptionAttributes.value.map { attribute in attribute.option.value } + guard viewModel.isPollComposing else { return nil } + return viewModel.pollOptionAttributes.map { attribute in attribute.option.value } }() let pollExpiresIn: Int? = { - guard viewModel.isPollComposing.value else { return nil } + guard viewModel.isPollComposing else { return nil } return viewModel.pollExpiresOptionAttribute.expiresOption.value.seconds }() let inReplyToID: Mastodon.Entity.Status.ID? = { - guard case let .reply(repliedToStatusObjectID) = viewModel.composeKind else { return nil } + guard case let .reply(status) = viewModel.composeKind else { return nil } var id: Mastodon.Entity.Status.ID? viewModel.context.managedObjectContext.performAndWait { - guard let replyTo = viewModel.context.managedObjectContext.object(with: repliedToStatusObjectID) as? Status else { return } + guard let replyTo = status.object(in: viewModel.context.managedObjectContext) else { return } id = replyTo.id } return id }() - let sensitive: Bool = viewModel.isContentWarningComposing.value + let sensitive: Bool = viewModel.isContentWarningComposing let spoilerText: String? = { - let text = viewModel.composeStatusAttribute.contentWarningContent.value.trimmingCharacters(in: .whitespacesAndNewlines) + let text = viewModel.composeStatusAttribute.contentWarningContent.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { return nil } return text }() - let visibility = viewModel.selectedStatusVisibility.value.visibility + let visibility = viewModel.selectedStatusVisibility.visibility let updateMediaQuerySubscriptions: [AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Attachment>, Error>] = { var subscriptions: [AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Attachment>, Error>] = [] @@ -100,7 +97,7 @@ extension ComposeViewModel.PublishState { domain: domain, attachmentID: attachmentID, query: query, - mastodonAuthenticationBox: mastodonAuthenticationBox + mastodonAuthenticationBox: authenticationBox ) subscriptions.append(subscription) } @@ -111,9 +108,9 @@ extension ComposeViewModel.PublishState { publishingSubscription = Publishers.MergeMany(updateMediaQuerySubscriptions) .collect() - .flatMap { attachments -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Status>, Error> in + .asyncMap { attachments -> Mastodon.Response.Content<Mastodon.Entity.Status> in let query = Mastodon.API.Statuses.PublishStatusQuery( - status: viewModel.composeStatusAttribute.composeContent.value, + status: viewModel.composeStatusAttribute.composeContent, mediaIDs: mediaIDs.isEmpty ? nil : mediaIDs, pollOptions: pollOptions, pollExpiresIn: pollExpiresIn, @@ -122,11 +119,11 @@ extension ComposeViewModel.PublishState { spoilerText: spoilerText, visibility: visibility ) - return viewModel.context.apiService.publishStatus( + return try await viewModel.context.apiService.publishStatus( domain: domain, idempotencyKey: idempotencyKey, query: query, - mastodonAuthenticationBox: mastodonAuthenticationBox + authenticationBox: authenticationBox ) } .receive(on: DispatchQueue.main) diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index 8cb54d88a..162043064 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -12,9 +12,14 @@ import CoreData import CoreDataStack import GameplayKit import MastodonSDK +import MastodonAsset +import MastodonLocalization +import MastodonMeta +import MastodonUI final class ComposeViewModel: NSObject { + let logger = Logger(subsystem: "ComposeViewModel", category: "ViewModel") var disposeBag = Set<AnyCancellable>() @@ -23,17 +28,19 @@ final class ComposeViewModel: NSObject { // input let context: AppContext let composeKind: ComposeStatusSection.ComposeKind - let composeStatusAttribute = ComposeStatusItem.ComposeStatusAttribute() - let isPollComposing = CurrentValueSubject<Bool, Never>(false) - let isCustomEmojiComposing = CurrentValueSubject<Bool, Never>(false) - let isContentWarningComposing = CurrentValueSubject<Bool, Never>(false) - let selectedStatusVisibility: CurrentValueSubject<ComposeToolbarView.VisibilitySelectionType, Never> - let activeAuthentication: CurrentValueSubject<MastodonAuthentication?, Never> - let activeAuthenticationBox: CurrentValueSubject<MastodonAuthenticationBox?, Never> + let authenticationBox: MastodonAuthenticationBox + + + @Published var isPollComposing = false + @Published var isCustomEmojiComposing = false + @Published var isContentWarningComposing = false + + @Published var selectedStatusVisibility: ComposeToolbarView.VisibilitySelectionType + @Published var repliedToCellFrame: CGRect = .zero + @Published var autoCompleteRetryLayoutTimes = 0 + @Published var autoCompleteInfo: ComposeViewController.AutoCompleteInfo? = nil + let traitCollectionDidChangePublisher = CurrentValueSubject<Void, Never>(Void()) // use CurrentValueSubject to make initial event emit - let repliedToCellFrame = CurrentValueSubject<CGRect, Never>(.zero) - let autoCompleteRetryLayoutTimes = CurrentValueSubject<Int, Never>(0) - let autoCompleteInfo = CurrentValueSubject<ComposeViewController.AutoCompleteInfo?, Never>(nil) var isViewAppeared = false // output @@ -55,12 +62,13 @@ final class ComposeViewModel: NSObject { return max(2, maxOptions) } + let composeStatusAttribute = ComposeStatusItem.ComposeStatusAttribute() let composeStatusContentTableViewCell = ComposeStatusContentTableViewCell() let composeStatusAttachmentTableViewCell = ComposeStatusAttachmentTableViewCell() let composeStatusPollTableViewCell = ComposeStatusPollTableViewCell() - var dataSource: UITableViewDiffableDataSource<ComposeStatusSection, ComposeStatusItem>! - var customEmojiPickerDiffableDataSource: UICollectionViewDiffableDataSource<CustomEmojiPickerSection, CustomEmojiPickerItem>! + // var dataSource: UITableViewDiffableDataSource<ComposeStatusSection, ComposeStatusItem>? + var customEmojiPickerDiffableDataSource: UICollectionViewDiffableDataSource<CustomEmojiPickerSection, CustomEmojiPickerItem>? private(set) lazy var publishStateMachine: GKStateMachine = { // exclude timeline middle fetcher state let stateMachine = GKStateMachine(states: [ @@ -80,53 +88,63 @@ final class ComposeViewModel: NSObject { var idempotencyKey = CurrentValueSubject<String, Never>(UUID().uuidString) // UI & UX - let title: CurrentValueSubject<String, Never> - let shouldDismiss = CurrentValueSubject<Bool, Never>(true) - let isPublishBarButtonItemEnabled = CurrentValueSubject<Bool, Never>(false) - let isMediaToolbarButtonEnabled = CurrentValueSubject<Bool, Never>(true) - let isPollToolbarButtonEnabled = CurrentValueSubject<Bool, Never>(true) - let characterCount = CurrentValueSubject<Int, Never>(0) - let collectionViewState = CurrentValueSubject<CollectionViewState, Never>(.fold) + @Published var title: String + @Published var shouldDismiss = true + @Published var isPublishBarButtonItemEnabled = false + @Published var isMediaToolbarButtonEnabled = true + @Published var isPollToolbarButtonEnabled = true + @Published var characterCount = 0 + @Published var collectionViewState: CollectionViewState = .fold // for hashtag: "#<hashtag> " // for mention: "@<mention> " - private(set) var preInsertedContent: String? + var preInsertedContent: String? // custom emojis - var customEmojiViewModelSubscription: AnyCancellable? - let customEmojiViewModel = CurrentValueSubject<EmojiService.CustomEmojiViewModel?, Never>(nil) + let customEmojiViewModel: EmojiService.CustomEmojiViewModel? let customEmojiPickerInputViewModel = CustomEmojiPickerInputViewModel() - let isLoadingCustomEmoji = CurrentValueSubject<Bool, Never>(false) + @Published var isLoadingCustomEmoji = false // attachment - let attachmentServices = CurrentValueSubject<[MastodonAttachmentService], Never>([]) + @Published var attachmentServices: [MastodonAttachmentService] = [] // polls - let pollOptionAttributes = CurrentValueSubject<[ComposeStatusPollItem.PollOptionAttribute], Never>([]) + @Published var pollOptionAttributes: [ComposeStatusPollItem.PollOptionAttribute] = [] let pollExpiresOptionAttribute = ComposeStatusPollItem.PollExpiresOptionAttribute() init( context: AppContext, - composeKind: ComposeStatusSection.ComposeKind + composeKind: ComposeStatusSection.ComposeKind, + authenticationBox: MastodonAuthenticationBox ) { self.context = context self.composeKind = composeKind - switch composeKind { - case .post, .hashtag, .mention: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newPost) - case .reply: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newReply) - } + self.authenticationBox = authenticationBox + self.title = { + switch composeKind { + case .post, .hashtag, .mention: return L10n.Scene.Compose.Title.newPost + case .reply: return L10n.Scene.Compose.Title.newReply + } + }() self.selectedStatusVisibility = { // default private when user locked - var visibility: ComposeToolbarView.VisibilitySelectionType = context.authenticationService.activeMastodonAuthentication.value?.user.locked == true ? .private : .public + var visibility: ComposeToolbarView.VisibilitySelectionType = { + guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value, + let author = authenticationBox.authenticationRecord.object(in: context.managedObjectContext)?.user + else { + return .public + } + return author.locked ? .private : .public + }() // set visibility for reply post switch composeKind { - case .reply(let repliedToStatusObjectID): + case .reply(let record): context.managedObjectContext.performAndWait { - guard let status = try? context.managedObjectContext.existingObject(with: repliedToStatusObjectID) as? Status else { + guard let status = record.object(in: context.managedObjectContext) else { assertionFailure() return } - guard let repliedStatusVisibility = status.visibilityEnum else { return } + let repliedStatusVisibility = status.visibility switch repliedStatusVisibility { case .public, .unlisted: // keep default @@ -143,323 +161,25 @@ final class ComposeViewModel: NSObject { default: break } - return CurrentValueSubject(visibility) + return visibility }() - 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 + self.instanceConfiguration = { + var configuration: Mastodon.Entity.Instance.Configuration? = nil + context.managedObjectContext.performAndWait { + guard let authentication = authenticationBox.authenticationRecord.object(in: context.managedObjectContext) + else { + return + } + configuration = authentication.instance?.configuration + } + return configuration + }() + self.customEmojiViewModel = context.emojiService.dequeueCustomEmojiViewModel(for: authenticationBox.domain) super.init() // end init - switch composeKind { - case .reply(let repliedToStatusObjectID): - context.managedObjectContext.performAndWait { - guard let status = context.managedObjectContext.object(with: repliedToStatusObjectID) as? Status else { return } - let composeAuthor: MastodonUser? = { - guard let objectID = self.activeAuthentication.value?.user.objectID else { return nil } - guard let author = context.managedObjectContext.object(with: objectID) as? MastodonUser else { return nil } - return author - }() - - var mentionAccts: [String] = [] - if composeAuthor?.id != status.author.id { - mentionAccts.append("@" + status.author.acct) - } - let mentions = (status.mentions ?? Set()) - .sorted(by: { $0.index.intValue < $1.index.intValue }) - .filter { $0.id != composeAuthor?.id } - for mention in mentions { - let acct = "@" + mention.acct - guard !mentionAccts.contains(acct) else { continue } - mentionAccts.append(acct) - } - for acct in mentionAccts { - UITextChecker.learnWord(acct) - } - if let spoilerText = status.spoilerText, !spoilerText.isEmpty { - self.isContentWarningComposing.value = true - self.composeStatusAttribute.contentWarningContent.value = spoilerText - } - - let initialComposeContent = mentionAccts.joined(separator: " ") - let preInsertedContent: String? = initialComposeContent.isEmpty ? nil : initialComposeContent + " " - self.preInsertedContent = preInsertedContent - self.composeStatusAttribute.composeContent.value = preInsertedContent - } - case .hashtag(let hashtag): - let initialComposeContent = "#" + hashtag - UITextChecker.learnWord(initialComposeContent) - let preInsertedContent = initialComposeContent + " " - self.preInsertedContent = preInsertedContent - self.composeStatusAttribute.composeContent.value = preInsertedContent - case .mention(let mastodonUserObjectID): - context.managedObjectContext.performAndWait { - let mastodonUser = context.managedObjectContext.object(with: mastodonUserObjectID) as! MastodonUser - let initialComposeContent = "@" + mastodonUser.acct - UITextChecker.learnWord(initialComposeContent) - let preInsertedContent = initialComposeContent + " " - self.preInsertedContent = preInsertedContent - self.composeStatusAttribute.composeContent.value = preInsertedContent - } - case .post: - self.preInsertedContent = nil - } - - isCustomEmojiComposing - .assign(to: \.value, on: customEmojiPickerInputViewModel.isCustomEmojiComposing) - .store(in: &disposeBag) - - isContentWarningComposing - .assign(to: \.value, on: composeStatusAttribute.isContentWarningComposing) - .store(in: &disposeBag) - - // bind active authentication - context.authenticationService.activeMastodonAuthentication - .assign(to: \.value, on: activeAuthentication) - .store(in: &disposeBag) - context.authenticationService.activeMastodonAuthenticationBox - .assign(to: \.value, on: activeAuthenticationBox) - .store(in: &disposeBag) - - // bind avatar and names - activeAuthentication - .sink { [weak self] mastodonAuthentication in - guard let self = self else { return } - let mastodonUser = mastodonAuthentication?.user - let username = mastodonUser?.username ?? " " - - self.composeStatusAttribute.avatarURL.value = mastodonUser?.avatarImageURL() - self.composeStatusAttribute.displayName.value = { - guard let displayName = mastodonUser?.displayName, !displayName.isEmpty else { - return username - } - return displayName - }() - self.composeStatusAttribute.emojiMeta.value = mastodonUser?.emojiMeta ?? [:] - self.composeStatusAttribute.username.value = username - } - .store(in: &disposeBag) - - // bind character count - Publishers.CombineLatest3( - composeStatusAttribute.composeContent.eraseToAnyPublisher(), - composeStatusAttribute.isContentWarningComposing.eraseToAnyPublisher(), - composeStatusAttribute.contentWarningContent.eraseToAnyPublisher() - ) - .map { composeContent, isContentWarningComposing, contentWarningContent -> Int in - let composeContent = composeContent ?? "" - var count = composeContent.count - if isContentWarningComposing { - count += contentWarningContent.count - } - return count - } - .assign(to: \.value, on: characterCount) - .store(in: &disposeBag) - - // bind compose bar button item UI state - let isComposeContentEmpty = composeStatusAttribute.composeContent - .map { ($0 ?? "").isEmpty } - let isComposeContentValid = characterCount - .compactMap { [weak self] characterCount -> Bool in - guard let self = self else { return characterCount <= 500 } - return characterCount <= self.composeContentLimit - } - let isMediaEmpty = attachmentServices - .map { $0.isEmpty } - let isMediaUploadAllSuccess = attachmentServices - .map { services in - services.allSatisfy { $0.uploadStateMachineSubject.value is MastodonAttachmentService.UploadState.Finish } - } - let isPollAttributeAllValid = pollOptionAttributes - .map { pollAttributes in - pollAttributes.allSatisfy { attribute -> Bool in - !attribute.option.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - } - } - - let isPublishBarButtonItemEnabledPrecondition1 = Publishers.CombineLatest4( - isComposeContentEmpty, - isComposeContentValid, - isMediaEmpty, - isMediaUploadAllSuccess - ) - .map { isComposeContentEmpty, isComposeContentValid, isMediaEmpty, isMediaUploadAllSuccess -> Bool in - if isMediaEmpty { - return isComposeContentValid && !isComposeContentEmpty - } else { - return isComposeContentValid && isMediaUploadAllSuccess - } - } - .eraseToAnyPublisher() - - let isPublishBarButtonItemEnabledPrecondition2 = Publishers.CombineLatest4( - isComposeContentEmpty, - isComposeContentValid, - isPollComposing, - isPollAttributeAllValid - ) - .map { isComposeContentEmpty, isComposeContentValid, isPollComposing, isPollAttributeAllValid -> Bool in - if isPollComposing { - return isComposeContentValid && !isComposeContentEmpty && isPollAttributeAllValid - } else { - return isComposeContentValid && !isComposeContentEmpty - } - } - .eraseToAnyPublisher() - - Publishers.CombineLatest( - isPublishBarButtonItemEnabledPrecondition1, - isPublishBarButtonItemEnabledPrecondition2 - ) - .map { $0 && $1 } - .assign(to: \.value, on: isPublishBarButtonItemEnabled) - .store(in: &disposeBag) - - // bind modal dismiss state - composeStatusAttribute.composeContent - .receive(on: DispatchQueue.main) - .map { [weak self] content in - let content = content ?? "" - if content.isEmpty { - return true - } - // if preInsertedContent plus a space is equal to the content, simply dismiss the modal - if let preInsertedContent = self?.preInsertedContent { - return content == preInsertedContent - } - return false - } - .assign(to: \.value, on: shouldDismiss) - .store(in: &disposeBag) - - // bind custom emojis - context.authenticationService.activeMastodonAuthenticationBox - .receive(on: DispatchQueue.main) - .sink { [weak self] activeMastodonAuthenticationBox in - guard let self = self else { return } - guard let activeMastodonAuthenticationBox = activeMastodonAuthenticationBox else { return } - let domain = activeMastodonAuthenticationBox.domain - - // trigger dequeue to preload emojis - self.customEmojiViewModel.value = self.context.emojiService.dequeueCustomEmojiViewModel(for: domain) - } - .store(in: &disposeBag) - - // setup attribute updater - attachmentServices - .receive(on: DispatchQueue.main) - .debounce(for: 0.3, scheduler: DispatchQueue.main) - .sink { attachmentServices in - // drive service upload state - // make image upload in the queue - for attachmentService in attachmentServices { - // skip when prefix N task when task finish OR fail OR uploading - guard let currentState = attachmentService.uploadStateMachine.currentState else { break } - if currentState is MastodonAttachmentService.UploadState.Fail { - continue - } - if currentState is MastodonAttachmentService.UploadState.Finish { - continue - } - if currentState is MastodonAttachmentService.UploadState.Processing { - continue - } - if currentState is MastodonAttachmentService.UploadState.Uploading { - break - } - // trigger uploading one by one - if currentState is MastodonAttachmentService.UploadState.Initial { - attachmentService.uploadStateMachine.enter(MastodonAttachmentService.UploadState.Uploading.self) - break - } - } - } - .store(in: &disposeBag) - - // bind delegate - attachmentServices - .sink { [weak self] attachmentServices in - guard let self = self else { return } - attachmentServices.forEach { $0.delegate = self } - } - .store(in: &disposeBag) - - pollOptionAttributes - .sink { [weak self] pollAttributes in - guard let self = self else { return } - pollAttributes.forEach { $0.delegate = self } - } - .store(in: &disposeBag) - - // bind compose toolbar UI state - Publishers.CombineLatest( - isPollComposing.eraseToAnyPublisher(), - attachmentServices.eraseToAnyPublisher() - ) - .receive(on: DispatchQueue.main) - .sink(receiveValue: { [weak self] isPollComposing, attachmentServices in - guard let self = self else { return } - let shouldMediaDisable = isPollComposing || attachmentServices.count >= self.maxMediaAttachments - let shouldPollDisable = attachmentServices.count > 0 - - self.isMediaToolbarButtonEnabled.value = !shouldMediaDisable - self.isPollToolbarButtonEnabled.value = !shouldPollDisable - }) - .store(in: &disposeBag) - - // calculate `Idempotency-Key` - let content = Publishers.CombineLatest3( - composeStatusAttribute.isContentWarningComposing, - composeStatusAttribute.contentWarningContent, - composeStatusAttribute.composeContent - ) - .map { isContentWarningComposing, contentWarningContent, composeContent -> String in - if isContentWarningComposing { - return contentWarningContent + (composeContent ?? "") - } else { - return composeContent ?? "" - } - } - let attachmentIDs = attachmentServices.map { attachments -> String in - let attachmentIDs = attachments.compactMap { $0.attachment.value?.id } - return attachmentIDs.joined(separator: ",") - } - let pollOptionsAndDuration = Publishers.CombineLatest3( - isPollComposing, - pollOptionAttributes, - pollExpiresOptionAttribute.expiresOption - ) - .map { isPollComposing, pollOptionAttributes, expiresOption -> String in - guard isPollComposing else { - return "" - } - - let pollOptions = pollOptionAttributes.map { $0.option.value }.joined(separator: ",") - return pollOptions + expiresOption.rawValue - } - - Publishers.CombineLatest4( - content, - attachmentIDs, - pollOptionsAndDuration, - selectedStatusVisibility - ) - .map { content, attachmentIDs, pollOptionsAndDuration, selectedStatusVisibility -> String in - var hasher = Hasher() - hasher.combine(content) - hasher.combine(attachmentIDs) - hasher.combine(pollOptionsAndDuration) - hasher.combine(selectedStatusVisibility.visibility.rawValue) - let hashValue = hasher.finalize() - return "\(hashValue)" - } - .assign(to: \.value, on: idempotencyKey) - .store(in: &disposeBag) - + setup(cell: composeStatusContentTableViewCell) } deinit { @@ -477,10 +197,10 @@ extension ComposeViewModel { extension ComposeViewModel { func createNewPollOptionIfPossible() { - guard pollOptionAttributes.value.count < maxPollOptions else { return } + guard pollOptionAttributes.count < maxPollOptions else { return } let attribute = ComposeStatusPollItem.PollOptionAttribute() - pollOptionAttributes.value = pollOptionAttributes.value + [attribute] + pollOptionAttributes = pollOptionAttributes + [attribute] } func updatePublishDate() { @@ -512,7 +232,7 @@ extension ComposeViewModel { // - up to 1 video // - up to N photos func checkAttachmentPrecondition() throws { - let attachmentServices = self.attachmentServices.value + let attachmentServices = self.attachmentServices guard !attachmentServices.isEmpty else { return } var photoAttachmentServices: [MastodonAttachmentService] = [] var videoAttachmentServices: [MastodonAttachmentService] = [] @@ -545,7 +265,7 @@ extension ComposeViewModel { extension ComposeViewModel: MastodonAttachmentServiceDelegate { func mastodonAttachmentService(_ service: MastodonAttachmentService, uploadStateDidChange state: MastodonAttachmentService.UploadState?) { // trigger new output event - attachmentServices.value = attachmentServices.value + attachmentServices = attachmentServices } } @@ -553,6 +273,115 @@ extension ComposeViewModel: MastodonAttachmentServiceDelegate { extension ComposeViewModel: ComposePollAttributeDelegate { func composePollAttribute(_ attribute: ComposeStatusPollItem.PollOptionAttribute, pollOptionDidChange: String?) { // trigger update - pollOptionAttributes.value = pollOptionAttributes.value + pollOptionAttributes = pollOptionAttributes + } +} + +extension ComposeViewModel { + private func setup( + cell: ComposeStatusContentTableViewCell + ) { + setupStatusHeader(cell: cell) + setupStatusAuthor(cell: cell) + setupStatusContent(cell: cell) + } + + private func setupStatusHeader( + cell: ComposeStatusContentTableViewCell + ) { + // configure header + let managedObjectContext = context.managedObjectContext + managedObjectContext.performAndWait { + guard case let .reply(record) = self.composeKind, + let replyTo = record.object(in: managedObjectContext) + else { + cell.statusView.viewModel.header = .none + return + } + + let info: StatusView.ViewModel.Header.ReplyInfo + do { + let content = MastodonContent( + content: replyTo.author.displayNameWithFallback, + emojis: replyTo.author.emojis.asDictionary + ) + let metaContent = try MastodonMetaContent.convert(document: content) + info = .init(header: metaContent) + } catch { + let metaContent = PlaintextMetaContent(string: replyTo.author.displayNameWithFallback) + info = .init(header: metaContent) + } + cell.statusView.viewModel.header = .reply(info: info) + } + } + + private func setupStatusAuthor( + cell: ComposeStatusContentTableViewCell + ) { + self.context.managedObjectContext.performAndWait { + guard let author = authenticationBox.authenticationRecord.object(in: self.context.managedObjectContext)?.user else { return } + cell.statusView.configureAuthor(author: author) + } + } + + private func setupStatusContent( + cell: ComposeStatusContentTableViewCell + ) { + switch composeKind { + case .reply(let record): + context.managedObjectContext.performAndWait { + guard let status = record.object(in: context.managedObjectContext) else { return } + let author = self.authenticationBox.authenticationRecord.object(in: context.managedObjectContext)?.user + + var mentionAccts: [String] = [] + if author?.id != status.author.id { + mentionAccts.append("@" + status.author.acct) + } + let mentions = status.mentions + .filter { author?.id != $0.id } + for mention in mentions { + let acct = "@" + mention.acct + guard !mentionAccts.contains(acct) else { continue } + mentionAccts.append(acct) + } + for acct in mentionAccts { + UITextChecker.learnWord(acct) + } + if let spoilerText = status.spoilerText, !spoilerText.isEmpty { + self.isContentWarningComposing = true + self.composeStatusAttribute.contentWarningContent = spoilerText + } + + let initialComposeContent = mentionAccts.joined(separator: " ") + let preInsertedContent: String? = initialComposeContent.isEmpty ? nil : initialComposeContent + " " + self.preInsertedContent = preInsertedContent + self.composeStatusAttribute.composeContent = preInsertedContent + } + case .hashtag(let hashtag): + let initialComposeContent = "#" + hashtag + UITextChecker.learnWord(initialComposeContent) + let preInsertedContent = initialComposeContent + " " + self.preInsertedContent = preInsertedContent + self.composeStatusAttribute.composeContent = preInsertedContent + case .mention(let record): + context.managedObjectContext.performAndWait { + guard let user = record.object(in: context.managedObjectContext) else { return } + let initialComposeContent = "@" + user.acct + UITextChecker.learnWord(initialComposeContent) + let preInsertedContent = initialComposeContent + " " + self.preInsertedContent = preInsertedContent + self.composeStatusAttribute.composeContent = preInsertedContent + } + case .post: + self.preInsertedContent = nil + } + + // configure content warning + if let composeContent = composeStatusAttribute.composeContent { + cell.metaText.textView.text = composeContent + } + + // configure content warning + cell.statusContentWarningEditorView.textView.text = composeStatusAttribute.contentWarningContent } } diff --git a/Mastodon/Scene/Compose/TableViewCell/ComposeRepliedToStatusContentTableViewCell.swift b/Mastodon/Scene/Compose/TableViewCell/ComposeRepliedToStatusContentTableViewCell.swift index 4ba68cedd..f15675b24 100644 --- a/Mastodon/Scene/Compose/TableViewCell/ComposeRepliedToStatusContentTableViewCell.swift +++ b/Mastodon/Scene/Compose/TableViewCell/ComposeRepliedToStatusContentTableViewCell.swift @@ -12,7 +12,7 @@ final class ComposeRepliedToStatusContentTableViewCell: UITableViewCell { var disposeBag = Set<AnyCancellable>() - let statusView = ReplicaStatusView() + let statusView = StatusView() let framePublisher = PassthroughSubject<CGRect, Never>() @@ -20,6 +20,7 @@ final class ComposeRepliedToStatusContentTableViewCell: UITableViewCell { super.prepareForReuse() disposeBag.removeAll() + statusView.prepareForReuse() } override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { @@ -49,12 +50,11 @@ extension ComposeRepliedToStatusContentTableViewCell { contentView.addSubview(statusView) NSLayoutConstraint.activate([ statusView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 20).identifier("statusView.top to ComposeRepliedToStatusContentCollectionViewCell.contentView.top"), - statusView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), - contentView.layoutMarginsGuide.trailingAnchor.constraint(equalTo: statusView.trailingAnchor), + statusView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: statusView.trailingAnchor), contentView.bottomAnchor.constraint(equalTo: statusView.bottomAnchor, constant: 10).identifier("ComposeRepliedToStatusContentCollectionViewCell.contentView.bottom to statusView.bottom"), ]) - - statusView.headerContainerView.isHidden = true + statusView.setup(style: .composeStatusReplica) } } diff --git a/Mastodon/Scene/Compose/TableViewCell/ComposeStatusAttachmentTableViewCell.swift b/Mastodon/Scene/Compose/TableViewCell/ComposeStatusAttachmentTableViewCell.swift index 6d2bbe93a..85c36fae0 100644 --- a/Mastodon/Scene/Compose/TableViewCell/ComposeStatusAttachmentTableViewCell.swift +++ b/Mastodon/Scene/Compose/TableViewCell/ComposeStatusAttachmentTableViewCell.swift @@ -8,6 +8,8 @@ import UIKit import Combine import AlamofireImage +import MastodonAsset +import MastodonLocalization final class ComposeStatusAttachmentTableViewCell: UITableViewCell { diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentTableViewCell.swift b/Mastodon/Scene/Compose/TableViewCell/ComposeStatusContentTableViewCell.swift similarity index 92% rename from Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentTableViewCell.swift rename to Mastodon/Scene/Compose/TableViewCell/ComposeStatusContentTableViewCell.swift index f44d29a68..4c3d37169 100644 --- a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentTableViewCell.swift +++ b/Mastodon/Scene/Compose/TableViewCell/ComposeStatusContentTableViewCell.swift @@ -10,6 +10,9 @@ import UIKit import Combine import MetaTextKit import UITextView_Placeholder +import MastodonAsset +import MastodonLocalization +import MastodonUI protocol ComposeStatusContentTableViewCellDelegate: AnyObject { func composeStatusContentTableViewCell(_ cell: ComposeStatusContentTableViewCell, textViewShouldBeginEditing textView: UITextView) -> Bool @@ -17,12 +20,12 @@ protocol ComposeStatusContentTableViewCellDelegate: AnyObject { final class ComposeStatusContentTableViewCell: UITableViewCell { - let logger = Logger(subsystem: "ComposeStatusContentTableViewCell", category: "UI") + let logger = Logger(subsystem: "ComposeStatusContentTableViewCell", category: "View") var disposeBag = Set<AnyCancellable>() weak var delegate: ComposeStatusContentTableViewCellDelegate? - let statusView = ReplicaStatusView() + let statusView = StatusView() let statusContentWarningEditorView = StatusContentWarningEditorView() @@ -114,10 +117,11 @@ extension ComposeStatusContentTableViewCell { statusContainerView.addSubview(statusView) NSLayoutConstraint.activate([ statusView.topAnchor.constraint(equalTo: statusContainerView.topAnchor, constant: 20), - statusView.leadingAnchor.constraint(equalTo: statusContainerView.layoutMarginsGuide.leadingAnchor), - statusView.trailingAnchor.constraint(equalTo: statusContainerView.layoutMarginsGuide.trailingAnchor), + statusView.leadingAnchor.constraint(equalTo: statusContainerView.leadingAnchor), + statusView.trailingAnchor.constraint(equalTo: statusContainerView.trailingAnchor), statusView.bottomAnchor.constraint(equalTo: statusContainerView.bottomAnchor), ]) + statusView.setup(style: .composeStatusAuthor) containerStackView.addArrangedSubview(textEditorViewContainerView) metaText.textView.translatesAutoresizingMaskIntoConstraints = false @@ -127,14 +131,9 @@ extension ComposeStatusContentTableViewCell { metaText.textView.leadingAnchor.constraint(equalTo: textEditorViewContainerView.layoutMarginsGuide.leadingAnchor), metaText.textView.trailingAnchor.constraint(equalTo: textEditorViewContainerView.layoutMarginsGuide.trailingAnchor), metaText.textView.bottomAnchor.constraint(equalTo: textEditorViewContainerView.bottomAnchor), - metaText.textView.heightAnchor.constraint(greaterThanOrEqualToConstant: 88).priority(.defaultHigh), + metaText.textView.heightAnchor.constraint(greaterThanOrEqualToConstant: 64).priority(.defaultHigh), ]) statusContentWarningEditorView.textView.delegate = self - - statusView.nameTrialingDotLabel.isHidden = true - statusView.dateLabel.isHidden = true - statusContentWarningEditorView.isHidden = true - statusView.statusContainerStackView.isHidden = true } } @@ -162,7 +161,10 @@ extension ComposeStatusContentTableViewCell: UITextViewDelegate { logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): text: \(textView.text ?? "<nil>")") guard textView === statusContentWarningEditorView.textView else { return } // replace line break with space - textView.text = textView.text.replacingOccurrences(of: "\n", with: " ") + // needs check input state to prevent break the IME + if textView.markedTextRange == nil { + textView.text = textView.text.replacingOccurrences(of: "\n", with: " ") + } contentWarningContent.send(textView.text) } diff --git a/Mastodon/Scene/Compose/TableViewCell/ComposeStatusPollTableViewCell.swift b/Mastodon/Scene/Compose/TableViewCell/ComposeStatusPollTableViewCell.swift index ac8d5094f..f33a35c3e 100644 --- a/Mastodon/Scene/Compose/TableViewCell/ComposeStatusPollTableViewCell.swift +++ b/Mastodon/Scene/Compose/TableViewCell/ComposeStatusPollTableViewCell.swift @@ -8,6 +8,8 @@ import os.log import UIKit import Combine +import MastodonAsset +import MastodonLocalization protocol ComposeStatusPollTableViewCellDelegate: AnyObject { func composeStatusPollTableViewCell(_ cell: ComposeStatusPollTableViewCell, pollOptionAttributesDidReorder options: [ComposeStatusPollItem.PollOptionAttribute]) @@ -26,7 +28,6 @@ final class ComposeStatusPollTableViewCell: UITableViewCell { weak var composeStatusPollOptionAppendEntryCollectionViewCellDelegate: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate? weak var composeStatusPollExpiresOptionCollectionViewCellDelegate: ComposeStatusPollExpiresOptionCollectionViewCellDelegate? - private static func createLayout() -> UICollectionViewLayout { let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44)) let item = NSCollectionLayoutItem(layoutSize: itemSize) diff --git a/Mastodon/Scene/Compose/View/AttachmentContainerView+EmptyStateView.swift b/Mastodon/Scene/Compose/View/AttachmentContainerView+EmptyStateView.swift index b441fa253..1d32931af 100644 --- a/Mastodon/Scene/Compose/View/AttachmentContainerView+EmptyStateView.swift +++ b/Mastodon/Scene/Compose/View/AttachmentContainerView+EmptyStateView.swift @@ -7,6 +7,8 @@ import UIKit import MastodonUI +import MastodonAsset +import MastodonLocalization extension AttachmentContainerView { final class EmptyStateView: UIView { diff --git a/Mastodon/Scene/Compose/View/AttachmentContainerView.swift b/Mastodon/Scene/Compose/View/AttachmentContainerView.swift index faa085593..4743c9527 100644 --- a/Mastodon/Scene/Compose/View/AttachmentContainerView.swift +++ b/Mastodon/Scene/Compose/View/AttachmentContainerView.swift @@ -7,6 +7,8 @@ import UIKit import UITextView_Placeholder +import MastodonAsset +import MastodonLocalization final class AttachmentContainerView: UIView { diff --git a/Mastodon/Scene/Compose/View/ComposeToolbarView.swift b/Mastodon/Scene/Compose/View/ComposeToolbarView.swift index 6b06973a2..4ed84be7c 100644 --- a/Mastodon/Scene/Compose/View/ComposeToolbarView.swift +++ b/Mastodon/Scene/Compose/View/ComposeToolbarView.swift @@ -9,9 +9,11 @@ import os.log import UIKit import Combine import MastodonSDK +import MastodonAsset +import MastodonLocalization protocol ComposeToolbarViewDelegate: AnyObject { - func composeToolbarView(_ composeToolbarView: ComposeToolbarView, cameraButtonDidPressed sender: Any, mediaSelectionType type: ComposeToolbarView.MediaSelectionType) + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, mediaButtonDidPressed 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) @@ -302,21 +304,21 @@ extension ComposeToolbarView { let photoLibraryAction = UIAction(title: L10n.Scene.Compose.MediaSelection.photoLibrary, image: UIImage(systemName: "rectangle.on.rectangle"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in guard let self = self else { return } os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: mediaSelectionType: .photoLibrary", ((#file as NSString).lastPathComponent), #line, #function) - self.delegate?.composeToolbarView(self, cameraButtonDidPressed: self.mediaButton, mediaSelectionType: .photoLibrary) + self.delegate?.composeToolbarView(self, mediaButtonDidPressed: self.mediaButton, mediaSelectionType: .photoLibrary) } children.append(photoLibraryAction) if UIImagePickerController.isSourceTypeAvailable(.camera) { let cameraAction = UIAction(title: L10n.Scene.Compose.MediaSelection.camera, image: UIImage(systemName: "camera"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { [weak self] _ in guard let self = self else { return } os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: mediaSelectionType: .camera", ((#file as NSString).lastPathComponent), #line, #function) - self.delegate?.composeToolbarView(self, cameraButtonDidPressed: self.mediaButton, mediaSelectionType: .camera) + self.delegate?.composeToolbarView(self, mediaButtonDidPressed: self.mediaButton, mediaSelectionType: .camera) }) children.append(cameraAction) } let browseAction = UIAction(title: L10n.Scene.Compose.MediaSelection.browse, image: UIImage(systemName: "ellipsis"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in guard let self = self else { return } os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: mediaSelectionType: .browse", ((#file as NSString).lastPathComponent), #line, #function) - self.delegate?.composeToolbarView(self, cameraButtonDidPressed: self.mediaButton, mediaSelectionType: .browse) + self.delegate?.composeToolbarView(self, mediaButtonDidPressed: self.mediaButton, mediaSelectionType: .browse) } children.append(browseAction) diff --git a/Mastodon/Scene/Compose/View/ReplicaStatusView.swift b/Mastodon/Scene/Compose/View/ReplicaStatusView.swift deleted file mode 100644 index 6f0527d55..000000000 --- a/Mastodon/Scene/Compose/View/ReplicaStatusView.swift +++ /dev/null @@ -1,261 +0,0 @@ -// -// ReplicaStatusView.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-6-29. -// - -import os.log -import UIKit -import FLAnimatedImage -import MetaTextKit - -final class ReplicaStatusView: UIView { - - static let avatarImageSize = CGSize(width: 42, height: 42) - static let avatarImageCornerRadius: CGFloat = 4 - static let avatarToLabelSpacing: CGFloat = 5 - static let contentWarningBlurRadius: CGFloat = 12 - static let containerStackViewSpacing: CGFloat = 10 - - let containerStackView = UIStackView() - let headerContainerView = UIView() - let authorContainerView = UIView() - - static let reblogIconImage: UIImage = { - let font = UIFont.systemFont(ofSize: 13, weight: .medium) - let configuration = UIImage.SymbolConfiguration(font: font) - let image = UIImage(systemName: "arrow.2.squarepath", withConfiguration: configuration)!.withTintColor(Asset.Colors.Label.secondary.color) - return image - }() - - static let replyIconImage: UIImage = { - let font = UIFont.systemFont(ofSize: 13, weight: .medium) - let configuration = UIImage.SymbolConfiguration(font: font) - let image = UIImage(systemName: "arrowshape.turn.up.left.fill", withConfiguration: configuration)!.withTintColor(Asset.Colors.Label.secondary.color) - return image - }() - - static func iconAttributedString(image: UIImage) -> NSAttributedString { - let attributedString = NSMutableAttributedString() - let imageTextAttachment = NSTextAttachment() - let imageAttribute = NSAttributedString(attachment: imageTextAttachment) - imageTextAttachment.image = image - attributedString.append(imageAttribute) - return attributedString - } - - let headerIconLabel: MetaLabel = { - let label = MetaLabel(style: .statusHeader) - let attributedString = StatusView.iconAttributedString(image: StatusView.reblogIconImage) - label.configure(attributedString: attributedString) - return label - }() - - let headerInfoLabel = MetaLabel(style: .statusHeader) - - let avatarView: UIView = { - let view = UIView() - view.isAccessibilityElement = true - view.accessibilityTraits = .button - view.accessibilityLabel = L10n.Common.Controls.Status.showUserProfile - return view - }() - let avatarImageView = FLAnimatedImageView() - - let nameLabel = MetaLabel(style: .statusName) - - let nameTrialingDotLabel: UILabel = { - let label = UILabel() - label.textColor = Asset.Colors.Label.secondary.color - label.font = .systemFont(ofSize: 17) - label.text = "·" - label.isAccessibilityElement = false - return label - }() - - let usernameLabel: UILabel = { - let label = UILabel() - label.font = .systemFont(ofSize: 15, weight: .regular) - label.textColor = Asset.Colors.Label.secondary.color - label.text = "@alice" - label.isAccessibilityElement = false - return label - }() - - let dateLabel: UILabel = { - let label = UILabel() - label.font = .systemFont(ofSize: 13, weight: .regular) - label.textColor = Asset.Colors.Label.secondary.color - label.text = "1d" - return label - }() - - let contentMetaText: MetaText = { - let metaText = MetaText() - metaText.textView.backgroundColor = .clear - metaText.textView.isEditable = false - metaText.textView.isSelectable = false - metaText.textView.isScrollEnabled = false - metaText.textView.textContainer.lineFragmentPadding = 0 - metaText.textView.textContainerInset = .zero - metaText.textView.layer.masksToBounds = false - - metaText.paragraphStyle = { - let style = NSMutableParagraphStyle() - style.lineSpacing = 5 - style.paragraphSpacing = 8 - return style - }() - metaText.textAttributes = [ - .font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .regular)), - .foregroundColor: Asset.Colors.Label.primary.color, - ] - metaText.linkAttributes = [ - .font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold)), - .foregroundColor: Asset.Colors.brandBlue.color, - ] - return metaText - }() - - let statusContainerStackView = UIStackView() - - override init(frame: CGRect) { - super.init(frame: frame) - _init() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - _init() - } - -} - -extension ReplicaStatusView { - private func _init() { - // container: [reblog | author | status | action toolbar] - // note: do not set spacing for nested stackView to avoid SDK layout conflict issue - containerStackView.axis = .vertical - // containerStackView.spacing = 10 - containerStackView.translatesAutoresizingMaskIntoConstraints = false - addSubview(containerStackView) - NSLayoutConstraint.activate([ - containerStackView.topAnchor.constraint(equalTo: topAnchor), - containerStackView.leadingAnchor.constraint(equalTo: leadingAnchor), - trailingAnchor.constraint(equalTo: containerStackView.trailingAnchor), - bottomAnchor.constraint(equalTo: containerStackView.bottomAnchor), - ]) - containerStackView.setContentHuggingPriority(.required - 1, for: .vertical) - containerStackView.setContentCompressionResistancePriority(.required - 1, for: .vertical) - - // header container: [icon | info] - let headerContainerStackView = UIStackView() - headerContainerStackView.axis = .horizontal - headerContainerStackView.spacing = 4 - headerContainerStackView.addArrangedSubview(headerIconLabel) - headerContainerStackView.addArrangedSubview(headerInfoLabel) - headerIconLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) - - headerContainerStackView.translatesAutoresizingMaskIntoConstraints = false - headerContainerView.addSubview(headerContainerStackView) - NSLayoutConstraint.activate([ - headerContainerStackView.topAnchor.constraint(equalTo: headerContainerView.topAnchor), - headerContainerStackView.leadingAnchor.constraint(equalTo: headerContainerView.leadingAnchor), - headerContainerStackView.trailingAnchor.constraint(equalTo: headerContainerView.trailingAnchor), - headerContainerView.bottomAnchor.constraint(equalTo: headerContainerStackView.bottomAnchor, constant: ReplicaStatusView.containerStackViewSpacing).priority(.defaultHigh), - ]) - containerStackView.addArrangedSubview(headerContainerView) - defer { - containerStackView.bringSubviewToFront(headerContainerView) - } - - // author container: [avatar | author meta container | reveal button] - let authorContainerStackView = UIStackView() - authorContainerStackView.axis = .horizontal - authorContainerStackView.spacing = ReplicaStatusView.avatarToLabelSpacing - authorContainerStackView.distribution = .fill - - // avatar - avatarView.translatesAutoresizingMaskIntoConstraints = false - authorContainerStackView.addArrangedSubview(avatarView) - NSLayoutConstraint.activate([ - avatarView.widthAnchor.constraint(equalToConstant: ReplicaStatusView.avatarImageSize.width).priority(.required - 1), - avatarView.heightAnchor.constraint(equalToConstant: ReplicaStatusView.avatarImageSize.height).priority(.required - 1), - ]) - avatarImageView.translatesAutoresizingMaskIntoConstraints = false - avatarView.addSubview(avatarImageView) - NSLayoutConstraint.activate([ - avatarImageView.topAnchor.constraint(equalTo: avatarView.topAnchor), - avatarImageView.leadingAnchor.constraint(equalTo: avatarView.leadingAnchor), - avatarImageView.trailingAnchor.constraint(equalTo: avatarView.trailingAnchor), - avatarImageView.bottomAnchor.constraint(equalTo: avatarView.bottomAnchor), - ]) - - // author meta container: [title container | subtitle container] - let authorMetaContainerStackView = UIStackView() - authorContainerStackView.addArrangedSubview(authorMetaContainerStackView) - authorMetaContainerStackView.axis = .vertical - authorMetaContainerStackView.spacing = 4 - - // title container: [display name | "·" | date | padding] - let titleContainerStackView = UIStackView() - authorMetaContainerStackView.addArrangedSubview(titleContainerStackView) - titleContainerStackView.axis = .horizontal - titleContainerStackView.spacing = 4 - nameLabel.translatesAutoresizingMaskIntoConstraints = false - titleContainerStackView.addArrangedSubview(nameLabel) - NSLayoutConstraint.activate([ - nameLabel.heightAnchor.constraint(equalToConstant: 22).priority(.defaultHigh), - ]) - titleContainerStackView.alignment = .firstBaseline - titleContainerStackView.addArrangedSubview(nameTrialingDotLabel) - titleContainerStackView.addArrangedSubview(dateLabel) - let padding = UIView() - titleContainerStackView.addArrangedSubview(padding) // padding - nameLabel.setContentHuggingPriority(.defaultHigh + 1, for: .horizontal) - nameTrialingDotLabel.setContentHuggingPriority(.defaultHigh + 2, for: .horizontal) - nameTrialingDotLabel.setContentCompressionResistancePriority(.required - 2, for: .horizontal) - dateLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) - dateLabel.setContentCompressionResistancePriority(.required - 1, for: .horizontal) - padding.setContentHuggingPriority(.defaultLow - 1, for: .horizontal) - padding.setContentCompressionResistancePriority(.defaultLow - 1, for: .horizontal) - - // subtitle container: [username] - let subtitleContainerStackView = UIStackView() - authorMetaContainerStackView.addArrangedSubview(subtitleContainerStackView) - subtitleContainerStackView.axis = .horizontal - subtitleContainerStackView.addArrangedSubview(usernameLabel) - - authorContainerStackView.translatesAutoresizingMaskIntoConstraints = false - authorContainerView.addSubview(authorContainerStackView) - NSLayoutConstraint.activate([ - authorContainerStackView.topAnchor.constraint(equalTo: authorContainerView.topAnchor), - authorContainerStackView.leadingAnchor.constraint(equalTo: authorContainerView.leadingAnchor), - authorContainerStackView.trailingAnchor.constraint(equalTo: authorContainerView.trailingAnchor), - authorContainerView.bottomAnchor.constraint(equalTo: authorContainerStackView.bottomAnchor, constant: ReplicaStatusView.containerStackViewSpacing).priority(.defaultHigh), - ]) - containerStackView.addArrangedSubview(authorContainerView) - - // status container: [status] - containerStackView.addArrangedSubview(statusContainerStackView) - statusContainerStackView.axis = .vertical - statusContainerStackView.spacing = 10 - - // avoid overlay behind other views - defer { - containerStackView.bringSubviewToFront(authorContainerView) - } - - // status - statusContainerStackView.addArrangedSubview(contentMetaText.textView) - contentMetaText.textView.setContentCompressionResistancePriority(.required - 1, for: .vertical) - } -} - -// MARK: - AvatarConfigurableView -extension ReplicaStatusView: AvatarConfigurableView { - static var configurableAvatarImageSize: CGSize { return Self.avatarImageSize } - static var configurableAvatarImageCornerRadius: CGFloat { return 4 } - var configurableAvatarImageView: FLAnimatedImageView? { avatarImageView } -} diff --git a/Mastodon/Scene/Compose/View/StatusContentWarningEditorView.swift b/Mastodon/Scene/Compose/View/StatusContentWarningEditorView.swift index 1ce274a55..83900c762 100644 --- a/Mastodon/Scene/Compose/View/StatusContentWarningEditorView.swift +++ b/Mastodon/Scene/Compose/View/StatusContentWarningEditorView.swift @@ -7,6 +7,8 @@ import UIKit import MastodonUI +import MastodonAsset +import MastodonLocalization final class StatusContentWarningEditorView: UIView { diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+DataSourceProvider.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+DataSourceProvider.swift new file mode 100644 index 000000000..6cd97fcca --- /dev/null +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+DataSourceProvider.swift @@ -0,0 +1,34 @@ +// +// HashtagTimelineViewController+DataSourceProvider.swift +// Mastodon +// +// Created by MainasuK on 2022-1-19. +// + +import UIKit + +extension HashtagTimelineViewController: DataSourceProvider { + func item(from source: DataSourceItem.Source) async -> DataSourceItem? { + var _indexPath = source.indexPath + if _indexPath == nil, let cell = source.tableViewCell { + _indexPath = await self.indexPath(for: cell) + } + guard let indexPath = _indexPath else { return nil } + + guard let item = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else { + return nil + } + + switch item { + case .status(let record): + return .status(record: record) + default: + return nil + } + } + + @MainActor + private func indexPath(for cell: UITableViewCell) async -> IndexPath? { + return tableView.indexPath(for: cell) + } +} diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+Provider.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+Provider.swift deleted file mode 100644 index d7beaca6f..000000000 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+Provider.swift +++ /dev/null @@ -1,95 +0,0 @@ -// -// HashtagTimelineViewController+Provider.swift -// Mastodon -// -// Created by BradGao on 2021/3/31. -// - -import os.log -import UIKit -import Combine -import CoreData -import CoreDataStack - -// MARK: - StatusProvider -extension HashtagTimelineViewController: StatusProvider { - - func status() -> Future<Status?, Never> { - return Future { promise in promise(.success(nil)) } - } - - func status(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future<Status?, Never> { - return Future { promise in - 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 .status(let objectID, _): - let managedObjectContext = self.viewModel.context.managedObjectContext - managedObjectContext.perform { - let status = managedObjectContext.object(with: objectID) as? Status - promise(.success(status)) - } - default: - promise(.success(nil)) - } - } - } - - func status(for cell: UICollectionViewCell) -> Future<Status?, Never> { - return Future { promise in promise(.success(nil)) } - } - - var managedObjectContext: NSManagedObjectContext { - return viewModel.context.managedObjectContext - } - - var tableViewDiffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>? { - return viewModel.diffableDataSource - } - - func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item? { - guard let diffableDataSource = self.viewModel.diffableDataSource else { - assertionFailure() - return nil - } - - guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }), - let item = diffableDataSource.itemIdentifier(for: indexPath) else { - return nil - } - - return item - } - - func items(indexPaths: [IndexPath]) -> [Item] { - guard let diffableDataSource = self.viewModel.diffableDataSource else { - assertionFailure() - return [] - } - - var items: [Item] = [] - for indexPath in indexPaths { - guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { continue } - items.append(item) - } - return items - } - - func statusObjectItems(indexPaths: [IndexPath]) -> [StatusObjectItem] { - guard let diffableDataSource = self.viewModel.diffableDataSource else { return [] } - let items = indexPaths.compactMap { diffableDataSource.itemIdentifier(for: $0)?.statusObjectItem } - return items - } - -} - -extension HashtagTimelineViewController: UserProvider {} diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift index 72f084fad..b3a8ca040 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift @@ -11,24 +11,29 @@ import AVKit import Combine import GameplayKit import CoreData +import MastodonAsset +import MastodonLocalization -class HashtagTimelineViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { +final class HashtagTimelineViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { + + let logger = Logger(subsystem: "HashtagTimelineViewController", category: "ViewController") + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } - var disposeBag = Set<AnyCancellable>() - - var viewModel: HashtagTimelineViewModel! - let mediaPreviewTransitionController = MediaPreviewTransitionController() - + + var disposeBag = Set<AnyCancellable>() + var viewModel: HashtagTimelineViewModel! + let composeBarButtonItem: UIBarButtonItem = { let barButtonItem = UIBarButtonItem() - // barButtonItem.tintColor = Asset.Colors.brandBlue.color barButtonItem.image = UIImage(systemName: "square.and.pencil")?.withRenderingMode(.alwaysTemplate) return barButtonItem }() + let titleView = DoubleTitleLabelNavigationBarTitleView() + let tableView: UITableView = { let tableView = ControlContainableTableView() tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.self)) @@ -41,10 +46,6 @@ class HashtagTimelineViewController: UIViewController, NeedsDependency, MediaPre return tableView }() - let refreshControl = UIRefreshControl() - - let titleView = DoubleTitleLabelNavigationBarTitleView() - deinit { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function) } @@ -55,8 +56,9 @@ extension HashtagTimelineViewController { override func viewDidLoad() { super.viewDidLoad() - title = "#\(viewModel.hashtag)" - titleView.update(title: viewModel.hashtag, subtitle: nil) + let _title = "#\(viewModel.hashtag)" + title = _title + titleView.update(title: _title, subtitle: nil) navigationItem.titleView = titleView view.backgroundColor = ThemeService.shared.currentTheme.value.secondarySystemBackgroundColor @@ -69,13 +71,9 @@ extension HashtagTimelineViewController { .store(in: &disposeBag) navigationItem.rightBarButtonItem = composeBarButtonItem - composeBarButtonItem.target = self composeBarButtonItem.action = #selector(HashtagTimelineViewController.composeBarButtonItemPressed(_:)) - tableView.refreshControl = refreshControl - refreshControl.addTarget(self, action: #selector(HashtagTimelineViewController.refreshControlValueChanged(_:)), for: .valueChanged) - tableView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(tableView) NSLayoutConstraint.activate([ @@ -85,28 +83,20 @@ extension HashtagTimelineViewController { tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), ]) - viewModel.tableView = tableView - viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self tableView.delegate = self - tableView.prefetchDataSource = self +// tableView.prefetchDataSource = self viewModel.setupDiffableDataSource( - for: tableView, - dependency: self, - statusTableViewCellDelegate: self, - timelineMiddleLoaderTableViewCellDelegate: self + tableView: tableView, + statusTableViewCellDelegate: self ) - - // bind refresh control - viewModel.isFetchingLatestTimeline + + // setup batch fetch + viewModel.listBatchFetchViewModel.setup(scrollView: tableView) + viewModel.listBatchFetchViewModel.shouldFetch .receive(on: DispatchQueue.main) - .sink { [weak self] isFetching in + .sink { [weak self] _ in guard let self = self else { return } - if !isFetching { - UIView.animate(withDuration: 0.5) { [weak self] in - guard let self = self else { return } - self.refreshControl.endRefreshing() - } - } + self.viewModel.loadOldestStateMachine.enter(HashtagTimelineViewModel.LoadOldestState.Loading.self) } .store(in: &disposeBag) @@ -121,31 +111,12 @@ extension HashtagTimelineViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - aspectViewWillAppear(animated) - - viewModel.fetchTag() - if viewModel.loadLatestStateMachine.currentState is HashtagTimelineViewModel.LoadLatestState.Initial { - viewModel.loadLatestStateMachine.enter(HashtagTimelineViewModel.LoadLatestState.Loading.self) - } + tableView.deselectRow(with: transitionCoordinator, animated: animated) } + +} - override func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - - aspectViewDidDisappear(animated) - } - - override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { - super.viewWillTransition(to: size, with: coordinator) - - coordinator.animate { _ in - // do nothing - } completion: { _ in - // fix AutoLayout cell height not update after rotate issue - self.viewModel.cellFrameCache.removeAllObjects() - self.tableView.reloadData() - } - } +extension HashtagTimelineViewController { private func updatePromptTitle() { var subtitle: String? @@ -176,65 +147,52 @@ extension HashtagTimelineViewController { @objc private func composeBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - let composeViewModel = ComposeViewModel(context: context, composeKind: .hashtag(hashtag: viewModel.hashtag)) + guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } + let composeViewModel = ComposeViewModel( + context: context, + composeKind: .hashtag(hashtag: viewModel.hashtag), + authenticationBox: authenticationBox + ) coordinator.present(scene: .compose(viewModel: composeViewModel), from: self, transition: .modal(animated: true, completion: nil)) } - - @objc private func refreshControlValueChanged(_ sender: UIRefreshControl) { - guard viewModel.loadLatestStateMachine.enter(HashtagTimelineViewModel.LoadLatestState.Loading.self) else { - sender.endRefreshing() - return - } - } -} -// MARK: - StatusTableViewControllerAspect -extension HashtagTimelineViewController: StatusTableViewControllerAspect { } +} // MARK: - TableViewCellHeightCacheableContainer -extension HashtagTimelineViewController: TableViewCellHeightCacheableContainer { - var cellFrameCache: NSCache<NSNumber, NSValue> { - return viewModel.cellFrameCache - } -} +//extension HashtagTimelineViewController: TableViewCellHeightCacheableContainer { +// var cellFrameCache: NSCache<NSNumber, NSValue> { +// return viewModel.cellFrameCache +// } +//} -// MARK: - UIScrollViewDelegate -extension HashtagTimelineViewController { - func scrollViewDidScroll(_ scrollView: UIScrollView) { - aspectScrollViewDidScroll(scrollView) - } -} +//// MARK: - UIScrollViewDelegate +//extension HashtagTimelineViewController { +// func scrollViewDidScroll(_ scrollView: UIScrollView) { +// aspectScrollViewDidScroll(scrollView) +// } +//} -extension HashtagTimelineViewController: LoadMoreConfigurableTableViewContainer { - typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell - typealias LoadingState = HashtagTimelineViewModel.LoadOldestState.Loading - var loadMoreConfigurableTableView: UITableView { return tableView } - var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.loadOldestStateMachine } -} +//extension HashtagTimelineViewController: LoadMoreConfigurableTableViewContainer { +// typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell +// typealias LoadingState = HashtagTimelineViewModel.LoadOldestState.Loading +// var loadMoreConfigurableTableView: UITableView { return tableView } +// var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.loadOldestStateMachine } +//} // MARK: - UITableViewDelegate -extension HashtagTimelineViewController: UITableViewDelegate { - - func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { - return aspectTableView(tableView, estimatedHeightForRowAt: indexPath) - } - - func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { - aspectTableView(tableView, willDisplay: cell, forRowAt: indexPath) - } - - func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { - aspectTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) - } - +extension HashtagTimelineViewController: UITableViewDelegate, AutoGenerateTableViewDelegate { + // sourcery:inline:HashtagTimelineViewController.AutoGenerateTableViewDelegate + + // Generated using Sourcery + // DO NOT EDIT func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { aspectTableView(tableView, didSelectRowAt: 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) } @@ -242,123 +200,88 @@ extension HashtagTimelineViewController: UITableViewDelegate { 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) } + // sourcery:end + +// func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { +// return aspectTableView(tableView, estimatedHeightForRowAt: indexPath) +// } +// +// func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { +// aspectTableView(tableView, willDisplay: cell, forRowAt: indexPath) +// } +// +// func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { +// aspectTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) +// } +// +// func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { +// aspectTableView(tableView, didSelectRowAt: 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) +// } } -// MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate -extension HashtagTimelineViewController: ContentOffsetAdjustableTimelineViewControllerDelegate { - func navigationBar() -> UINavigationBar? { - return navigationController?.navigationBar - } -} - - // MARK: - UITableViewDataSourcePrefetching -extension HashtagTimelineViewController: UITableViewDataSourcePrefetching { - func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { - aspectTableView(tableView, prefetchRowsAt: indexPaths) - } -} - -// MARK: - TimelineMiddleLoaderTableViewCellDelegate -extension HashtagTimelineViewController: TimelineMiddleLoaderTableViewCellDelegate { - func configure(cell: TimelineMiddleLoaderTableViewCell, upperTimelineStatusID: String?, timelineIndexobjectID: NSManagedObjectID?) { - guard let upperTimelineIndexObjectID = timelineIndexobjectID else { - return - } - viewModel.loadMiddleSateMachineList - .receive(on: DispatchQueue.main) - .sink { [weak self] ids in - guard let _ = self else { return } - if let stateMachine = ids[upperTimelineIndexObjectID] { - guard let state = stateMachine.currentState else { - assertionFailure() - return - } - - // make success state same as loading due to snapshot updating delay - let isLoading = state is HashtagTimelineViewModel.LoadMiddleState.Loading || state is HashtagTimelineViewModel.LoadMiddleState.Success - if isLoading { - cell.startAnimating() - } else { - cell.stopAnimating() - } - } else { - cell.stopAnimating() - } - } - .store(in: &cell.disposeBag) - - var dict = viewModel.loadMiddleSateMachineList.value - if let _ = dict[upperTimelineIndexObjectID] { - // do nothing - } else { - let stateMachine = GKStateMachine(states: [ - HashtagTimelineViewModel.LoadMiddleState.Initial(viewModel: viewModel, upperStatusObjectID: upperTimelineIndexObjectID), - HashtagTimelineViewModel.LoadMiddleState.Loading(viewModel: viewModel, upperStatusObjectID: upperTimelineIndexObjectID), - HashtagTimelineViewModel.LoadMiddleState.Fail(viewModel: viewModel, upperStatusObjectID: upperTimelineIndexObjectID), - HashtagTimelineViewModel.LoadMiddleState.Success(viewModel: viewModel, upperStatusObjectID: upperTimelineIndexObjectID), - ]) - stateMachine.enter(HashtagTimelineViewModel.LoadMiddleState.Initial.self) - dict[upperTimelineIndexObjectID] = stateMachine - viewModel.loadMiddleSateMachineList.value = dict - } - } - - func timelineMiddleLoaderTableViewCell(_ cell: TimelineMiddleLoaderTableViewCell, loadMoreButtonDidPressed button: UIButton) { - guard let diffableDataSource = viewModel.diffableDataSource else { return } - guard let indexPath = tableView.indexPath(for: cell) else { return } - guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } - - switch item { - case .homeMiddleLoader(let upper): - guard let stateMachine = viewModel.loadMiddleSateMachineList.value[upper] else { - assertionFailure() - return - } - stateMachine.enter(HashtagTimelineViewModel.LoadMiddleState.Loading.self) - default: - assertionFailure() - } - } -} - -// MARK: - AVPlayerViewControllerDelegate -extension HashtagTimelineViewController: AVPlayerViewControllerDelegate { - - func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { - aspectPlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator) - } - - func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { - aspectPlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator) - } - -} +//extension HashtagTimelineViewController: UITableViewDataSourcePrefetching { +// func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { +// aspectTableView(tableView, prefetchRowsAt: indexPaths) +// } +//} // MARK: - StatusTableViewCellDelegate -extension HashtagTimelineViewController: StatusTableViewCellDelegate { - weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self } - func parent() -> UIViewController { return self } -} +extension HashtagTimelineViewController: StatusTableViewCellDelegate { } -extension HashtagTimelineViewController { - override var keyCommands: [UIKeyCommand]? { - return navigationKeyCommands + statusNavigationKeyCommands - } -} +// MARK: - AVPlayerViewControllerDelegate +//extension HashtagTimelineViewController: AVPlayerViewControllerDelegate { +// +// func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { +// aspectPlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator) +// } +// +// func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { +// aspectPlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator) +// } +// +//} -// MARK: - StatusTableViewControllerNavigateable -extension HashtagTimelineViewController: StatusTableViewControllerNavigateable { - @objc func navigateKeyCommandHandlerRelay(_ sender: UIKeyCommand) { - navigateKeyCommandHandler(sender) - } - - @objc func statusKeyCommandHandlerRelay(_ sender: UIKeyCommand) { - statusKeyCommandHandler(sender) - } -} +// MARK: - StatusTableViewCellDelegate +//extension HashtagTimelineViewController: StatusTableViewCellDelegate { +// weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self } +// func parent() -> UIViewController { return self } +//} + +//extension HashtagTimelineViewController { +// override var keyCommands: [UIKeyCommand]? { +// return navigationKeyCommands + statusNavigationKeyCommands +// } +//} +// +//// MARK: - StatusTableViewControllerNavigateable +//extension HashtagTimelineViewController: StatusTableViewControllerNavigateable { +// @objc func navigateKeyCommandHandlerRelay(_ sender: UIKeyCommand) { +// navigateKeyCommandHandler(sender) +// } +// +// @objc func statusKeyCommandHandlerRelay(_ sender: UIKeyCommand) { +// statusKeyCommandHandler(sender) +// } +//} diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift index a601eb927..c71d195c7 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift @@ -7,122 +7,59 @@ import os.log import UIKit +import Combine import CoreData import CoreDataStack extension HashtagTimelineViewModel { func setupDiffableDataSource( - for tableView: UITableView, - dependency: NeedsDependency, - statusTableViewCellDelegate: StatusTableViewCellDelegate, - timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate + tableView: UITableView, + statusTableViewCellDelegate: StatusTableViewCellDelegate ) { - diffableDataSource = StatusSection.tableViewDiffableDataSource( - for: tableView, - timelineContext: .hashtag, - dependency: dependency, - managedObjectContext: context.managedObjectContext, - statusTableViewCellDelegate: statusTableViewCellDelegate, - timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate, - threadReplyLoaderTableViewCellDelegate: nil + diffableDataSource = StatusSection.diffableDataSource( + tableView: tableView, + context: context, + configuration: StatusSection.Configuration( + statusTableViewCellDelegate: statusTableViewCellDelegate, + timelineMiddleLoaderTableViewCellDelegate: nil, + filterContext: .none, + activeFilters: nil + ) ) - var snapshot = NSDiffableDataSourceSnapshot<StatusSection, Item>() + var snapshot = NSDiffableDataSourceSnapshot<StatusSection, StatusItem>() snapshot.appendSections([.main]) diffableDataSource?.apply(snapshot) - // workaround to append loader wrong animation issue - snapshot.appendItems([.bottomLoader], toSection: .main) - diffableDataSource?.apply(snapshot) - } -} - -// MARK: - Compare old & new snapshots and generate new items -extension HashtagTimelineViewModel { - func generateStatusItems(newObjectIDs: [NSManagedObjectID]) { - os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - - guard let tableView = self.tableView else { return } - guard let navigationBar = self.contentOffsetAdjustableTimelineViewControllerDelegate?.navigationBar() else { return } - - guard let diffableDataSource = self.diffableDataSource else { return } - - let parentManagedObjectContext = fetchedResultsController.fetchedResultsController.managedObjectContext - let managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) - managedObjectContext.parent = parentManagedObjectContext - - let oldSnapshot = diffableDataSource.snapshot() -// let snapshot = snapshot as NSDiffableDataSourceSnapshot<Int, NSManagedObjectID> - - var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:] - for item in oldSnapshot.itemIdentifiers { - guard case let .status(objectID, attribute) = item else { continue } - oldSnapshotAttributeDict[objectID] = attribute - } - - let statusItemList: [Item] = newObjectIDs.map { - let attribute = oldSnapshotAttributeDict[$0] ?? Item.StatusAttribute() - return Item.status(objectID: $0, attribute: attribute) - } - - var newSnapshot = NSDiffableDataSourceSnapshot<StatusSection, Item>() - newSnapshot.appendSections([.main]) - - // Check if there is a `needLoadMiddleIndex` - if let needLoadMiddleIndex = needLoadMiddleIndex, needLoadMiddleIndex < (statusItemList.count - 1) { - // If yes, insert a `middleLoader` at the index - var newItems = statusItemList - newItems.insert(.homeMiddleLoader(upperTimelineIndexAnchorObjectID: newObjectIDs[needLoadMiddleIndex]), at: (needLoadMiddleIndex + 1)) - newSnapshot.appendItems(newItems, toSection: .main) - } else { - newSnapshot.appendItems(statusItemList, toSection: .main) - } - - if !(self.loadOldestStateMachine.currentState is LoadOldestState.NoMore) { - newSnapshot.appendItems([.bottomLoader], toSection: .main) - } - - guard let difference = self.calculateReloadSnapshotDifference(navigationBar: navigationBar, tableView: tableView, oldSnapshot: oldSnapshot, newSnapshot: newSnapshot) else { - diffableDataSource.apply(newSnapshot) - self.isFetchingLatestTimeline.value = false - return - } - - DispatchQueue.main.async { - 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 + fetchedResultsController.$records + .receive(on: DispatchQueue.main) + .sink { [weak self] records in + guard let self = self else { return } + guard let diffableDataSource = self.diffableDataSource else { return } + self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): incoming \(records.count) objects") + + var snapshot = NSDiffableDataSourceSnapshot<StatusSection, StatusItem>() + snapshot.appendSections([.main]) + let items = records.map { StatusItem.status(record: $0) } + snapshot.appendItems(items, toSection: .main) + + if let currentState = self.loadOldestStateMachine.currentState { + switch currentState { + case is LoadOldestState.Initial, + is LoadOldestState.Loading, + is LoadOldestState.Idle, + is LoadOldestState.Fail: + snapshot.appendItems([.bottomLoader], toSection: .main) + case is LoadOldestState.NoMore: + break + default: + assertionFailure() + break + } + } + + diffableDataSource.apply(snapshot) } - } - } - - private struct Difference<T> { - let targetIndexPath: IndexPath - let offset: CGFloat - } - - private func calculateReloadSnapshotDifference<T: Hashable>( - navigationBar: UINavigationBar, - tableView: UITableView, - oldSnapshot: NSDiffableDataSourceSnapshot<StatusSection, T>, - newSnapshot: NSDiffableDataSourceSnapshot<StatusSection, T> - ) -> Difference<T>? { - guard oldSnapshot.numberOfItems != 0 else { return nil } - guard let item = oldSnapshot.itemIdentifiers.first as? Item, case Item.status = item else { return nil } - - let oldItemAtBeginning = oldSnapshot.itemIdentifiers(inSection: .main).first! - - guard let oldItemBeginIndexInNewSnapshot = newSnapshot.itemIdentifiers(inSection: .main).firstIndex(of: oldItemAtBeginning) else { return nil } - - if oldItemBeginIndexInNewSnapshot > 0 { - let targetIndexPath = IndexPath(row: oldItemBeginIndexInNewSnapshot, section: 0) - let offset = UIViewController.tableViewCellOriginOffsetToWindowTop(in: tableView, at: IndexPath(row: 0, section: 0), navigationBar: navigationBar) - return Difference( - targetIndexPath: targetIndexPath, - offset: offset - ) - } - return nil + .store(in: &disposeBag) } } diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadLatestState.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadLatestState.swift deleted file mode 100644 index b2d121d50..000000000 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadLatestState.swift +++ /dev/null @@ -1,104 +0,0 @@ -// -// HashtagTimelineViewModel+LoadLatestState.swift -// Mastodon -// -// Created by BradGao on 2021/3/30. -// - -import os.log -import UIKit -import GameplayKit -import CoreData -import CoreDataStack -import MastodonSDK - -extension HashtagTimelineViewModel { - class LoadLatestState: GKState { - weak var viewModel: HashtagTimelineViewModel? - - init(viewModel: HashtagTimelineViewModel) { - 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) - viewModel?.loadLatestStateMachinePublisher.send(self) - } - } -} - -extension HashtagTimelineViewModel.LoadLatestState { - class Initial: HashtagTimelineViewModel.LoadLatestState { - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - return stateClass == Loading.self - } - } - - class Loading: HashtagTimelineViewModel.LoadLatestState { - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - return stateClass == Fail.self || stateClass == Idle.self - } - - override func didEnter(from previousState: GKState?) { - super.didEnter(from: previousState) - guard let viewModel = viewModel, let stateMachine = stateMachine else { return } - guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { - // sign out when loading will enter here - stateMachine.enter(Fail.self) - return - } - // TODO: only set large count when using Wi-Fi - viewModel.context.apiService.hashtagTimeline( - domain: activeMastodonAuthenticationBox.domain, - hashtag: viewModel.hashtag, - authorizationBox: activeMastodonAuthenticationBox) - .receive(on: DispatchQueue.main) - .sink { completion in - switch completion { - case .failure(let error): - // TODO: handle error - viewModel.isFetchingLatestTimeline.value = false - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch statues failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) - case .finished: - // handle isFetchingLatestTimeline in fetch controller delegate - break - } - - stateMachine.enter(Idle.self) - - } receiveValue: { response in - let newStatusIDList = response.value.map { $0.id } - - // When response data: - // 1. is not empty - // 2. last status are not recorded - // Then we may have middle data to load - var oldStatusIDs = viewModel.fetchedResultsController.statusIDs.value - if !oldStatusIDs.isEmpty, let lastNewStatusID = newStatusIDList.last, - !oldStatusIDs.contains(lastNewStatusID) { - viewModel.needLoadMiddleIndex = (newStatusIDList.count - 1) - } else { - viewModel.needLoadMiddleIndex = nil - } - - oldStatusIDs.insert(contentsOf: newStatusIDList, at: 0) - let newIDs = oldStatusIDs.removingDuplicates() - - viewModel.fetchedResultsController.statusIDs.value = newIDs - } - .store(in: &viewModel.disposeBag) - } - } - - class Fail: HashtagTimelineViewModel.LoadLatestState { - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - return stateClass == Loading.self || stateClass == Idle.self - } - } - - class Idle: HashtagTimelineViewModel.LoadLatestState { - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - return stateClass == Loading.self - } - } -} diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadMiddleState.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadMiddleState.swift deleted file mode 100644 index f458b86a5..000000000 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadMiddleState.swift +++ /dev/null @@ -1,131 +0,0 @@ -// -// HashtagTimelineViewModel+LoadMiddleState.swift -// Mastodon -// -// Created by BradGao on 2021/3/31. -// - -import os.log -import Foundation -import GameplayKit -import CoreData -import CoreDataStack - -extension HashtagTimelineViewModel { - class LoadMiddleState: GKState { - weak var viewModel: HashtagTimelineViewModel? - let upperStatusObjectID: NSManagedObjectID - - init(viewModel: HashtagTimelineViewModel, upperStatusObjectID: NSManagedObjectID) { - self.viewModel = viewModel - self.upperStatusObjectID = upperStatusObjectID - } - - 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) - guard let viewModel = viewModel, let stateMachine = stateMachine else { return } - var dict = viewModel.loadMiddleSateMachineList.value - dict[upperStatusObjectID] = stateMachine - viewModel.loadMiddleSateMachineList.value = dict // trigger value change - } - } -} - -extension HashtagTimelineViewModel.LoadMiddleState { - - class Initial: HashtagTimelineViewModel.LoadMiddleState { - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - return stateClass == Loading.self - } - } - - class Loading: HashtagTimelineViewModel.LoadMiddleState { - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - // guard let viewModel = viewModel else { return false } - return stateClass == Success.self || stateClass == Fail.self - } - - override func didEnter(from previousState: GKState?) { - super.didEnter(from: previousState) - - guard let viewModel = viewModel, let stateMachine = stateMachine else { return } - guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { - stateMachine.enter(Fail.self) - return - } - - guard let upperStatusObject = (viewModel.fetchedResultsController.fetchedResultsController.fetchedObjects ?? []).first(where: { $0.objectID == upperStatusObjectID }) else { - stateMachine.enter(Fail.self) - return - } - _ = (viewModel.fetchedResultsController.fetchedResultsController.fetchedObjects ?? []).compactMap { status in - status.id - } - - // TODO: only set large count when using Wi-Fi - let maxID = upperStatusObject.id - viewModel.context.apiService.hashtagTimeline( - domain: activeMastodonAuthenticationBox.domain, - maxID: maxID, - hashtag: viewModel.hashtag, - authorizationBox: activeMastodonAuthenticationBox) - .delay(for: .seconds(1), scheduler: DispatchQueue.main) - .receive(on: DispatchQueue.main) - .sink { completion in -// viewModel.homeTimelineNavigationBarState.receiveCompletion(completion: completion) - switch completion { - case .failure(let error): - // TODO: handle error - os_log("%{public}s[%{public}ld], %{public}s: fetch statuses failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) - stateMachine.enter(Fail.self) - case .finished: - break - } - } receiveValue: { response in - stateMachine.enter(Success.self) - - let newStatusIDList = response.value.map { $0.id } - - var oldStatusIDs = viewModel.fetchedResultsController.statusIDs.value - if let indexToInsert = oldStatusIDs.firstIndex(of: maxID) { - // When response data: - // 1. is not empty - // 2. last status are not recorded - // Then we may have middle data to load - if let lastNewStatusID = newStatusIDList.last, - !oldStatusIDs.contains(lastNewStatusID) { - viewModel.needLoadMiddleIndex = indexToInsert + newStatusIDList.count - } else { - viewModel.needLoadMiddleIndex = nil - } - oldStatusIDs.insert(contentsOf: newStatusIDList, at: indexToInsert + 1) - oldStatusIDs.removeDuplicates() - } else { - // Only when the hashtagStatusIDList changes, we could not find the `loadMiddleState` index - // Then there is no need to set a `loadMiddleState` cell - viewModel.needLoadMiddleIndex = nil - } - - viewModel.fetchedResultsController.statusIDs.value = oldStatusIDs - - } - .store(in: &viewModel.disposeBag) - } - } - - class Fail: HashtagTimelineViewModel.LoadMiddleState { - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - // guard let viewModel = viewModel else { return false } - return stateClass == Loading.self - } - } - - class Success: HashtagTimelineViewModel.LoadMiddleState { - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - // guard let viewModel = viewModel else { return false } - return false - } - } - -} - diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift index 137373647..eba85657b 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift @@ -11,7 +11,16 @@ import GameplayKit import CoreDataStack extension HashtagTimelineViewModel { - class LoadOldestState: GKState { + class LoadOldestState: GKState, NamingState { + + let logger = Logger(subsystem: "HashtagTimelineViewModel.LoadOldestState", category: "StateMachine") + + let id = UUID() + + var name: String { + String(describing: Self.self) + } + weak var viewModel: HashtagTimelineViewModel? init(viewModel: HashtagTimelineViewModel) { @@ -19,23 +28,32 @@ extension HashtagTimelineViewModel { } 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) + let previousState = previousState as? HashtagTimelineViewModel.LoadOldestState + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [\(self.id.uuidString)] enter \(self.name), previous: \(previousState?.name ?? "<nil>")") + viewModel?.loadOldestStateMachinePublisher.send(self) } + + @MainActor + func enter(state: LoadOldestState.Type) { + stateMachine?.enter(state) + } + + deinit { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [\(self.id.uuidString)] \(self.name)") + } } } extension HashtagTimelineViewModel.LoadOldestState { class Initial: HashtagTimelineViewModel.LoadOldestState { override func isValidNextState(_ stateClass: AnyClass) -> Bool { - guard let viewModel = viewModel else { return false } - guard !(viewModel.fetchedResultsController.fetchedResultsController.fetchedObjects ?? []).isEmpty else { return false } return stateClass == Loading.self } } class Loading: HashtagTimelineViewModel.LoadOldestState { - var maxID: String? + var maxID: Status.ID? override func isValidNextState(_ stateClass: AnyClass) -> Bool { return stateClass == Fail.self || stateClass == Idle.self || stateClass == NoMore.self @@ -43,59 +61,47 @@ extension HashtagTimelineViewModel.LoadOldestState { override func didEnter(from previousState: GKState?) { super.didEnter(from: previousState) + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } - guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { + guard let authenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { assertionFailure() stateMachine.enter(Fail.self) return } - guard let last = viewModel.fetchedResultsController.fetchedResultsController.fetchedObjects?.last else { - stateMachine.enter(Idle.self) - return - } - // TODO: only set large count when using Wi-Fi - let maxID = self.maxID ?? last.id - viewModel.context.apiService.hashtagTimeline( - domain: activeMastodonAuthenticationBox.domain, - maxID: maxID, - hashtag: viewModel.hashtag, - authorizationBox: activeMastodonAuthenticationBox) - .delay(for: .seconds(1), scheduler: DispatchQueue.main) - .receive(on: DispatchQueue.main) - .sink { completion in -// viewModel.homeTimelineNavigationBarState.receiveCompletion(completion: completion) - switch completion { - case .failure(let error): - os_log("%{public}s[%{public}ld], %{public}s: fetch statuses failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) - case .finished: - // handle isFetchingLatestTimeline in fetch controller delegate - break + let maxID = self.maxID + Task { + do { + let response = try await viewModel.context.apiService.hashtagTimeline( + domain: authenticationBox.domain, + maxID: maxID, + hashtag: viewModel.hashtag, + authenticationBox: authenticationBox + ) + + var hasMore = false + + if let _maxID = response.link?.maxID, + _maxID != maxID + { + self.maxID = _maxID + hasMore = true } - } receiveValue: { [weak self] response in - guard let self = self else { return } - - let statuses = response.value - // enter no more state when no new statuses - - let hasNextPage: Bool = { - guard let link = response.link else { return true } // assert has more when link invalid - return link.maxID != nil - }() - self.maxID = response.link?.maxID - - if !hasNextPage || statuses.isEmpty || (statuses.count == 1 && statuses[0].id == maxID) { - stateMachine.enter(NoMore.self) + if hasMore { + await enter(state: Idle.self) } else { - stateMachine.enter(Idle.self) + await enter(state: NoMore.self) } - var newStatusIDs = viewModel.fetchedResultsController.statusIDs.value - let fetchedStatusIDList = statuses.map { $0.id } - newStatusIDs.append(contentsOf: fetchedStatusIDList) - viewModel.fetchedResultsController.statusIDs.value = newStatusIDs + + let statusIDs = response.value.map { $0.id } + viewModel.fetchedResultsController.append(statusIDs: statusIDs) + + } catch { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch statues failed: \(error.localizedDescription)") + await enter(state: Fail.self) } - .store(in: &viewModel.disposeBag) + } // end Task } } @@ -113,8 +119,7 @@ extension HashtagTimelineViewModel.LoadOldestState { class NoMore: HashtagTimelineViewModel.LoadOldestState { override func isValidNextState(_ stateClass: AnyClass) -> Bool { - // reset state if needs - return stateClass == Idle.self + return false } override func didEnter(from previousState: GKState?) { diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift index 1bb76493a..d63fad807 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift @@ -13,7 +13,9 @@ import CoreDataStack import GameplayKit import MastodonSDK -final class HashtagTimelineViewModel: NSObject { +final class HashtagTimelineViewModel { + + let logger = Logger(subsystem: "HashtagTimelineViewModel", category: "ViewModel") let hashtag: String @@ -27,24 +29,12 @@ final class HashtagTimelineViewModel: NSObject { let isFetchingLatestTimeline = CurrentValueSubject<Bool, Never>(false) let timelinePredicate = CurrentValueSubject<NSPredicate?, Never>(nil) let hashtagEntity = CurrentValueSubject<Mastodon.Entity.Tag?, Never>(nil) - - weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate? - weak var tableView: UITableView? + let listBatchFetchViewModel = ListBatchFetchViewModel() // output - // top loader - private(set) lazy var loadLatestStateMachine: GKStateMachine = { - // exclude timeline middle fetcher state - let stateMachine = GKStateMachine(states: [ - LoadLatestState.Initial(viewModel: self), - LoadLatestState.Loading(viewModel: self), - LoadLatestState.Fail(viewModel: self), - LoadLatestState.Idle(viewModel: self), - ]) - stateMachine.enter(LoadLatestState.Initial.self) - return stateMachine - }() - lazy var loadLatestStateMachinePublisher = CurrentValueSubject<LoadLatestState?, Never>(nil) + var diffableDataSource: UITableViewDiffableDataSource<StatusSection, StatusItem>? + let didLoadLatest = PassthroughSubject<Void, Never>() + // bottom loader private(set) lazy var loadOldestStateMachine: GKStateMachine = { // exclude timeline middle fetcher state @@ -59,47 +49,21 @@ final class HashtagTimelineViewModel: NSObject { return stateMachine }() lazy var loadOldestStateMachinePublisher = CurrentValueSubject<LoadOldestState?, Never>(nil) - // middle loader - let loadMiddleSateMachineList = CurrentValueSubject<[NSManagedObjectID: GKStateMachine], Never>([:]) // TimelineIndex.objectID : middle loading state machine - var diffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>? - var cellFrameCache = NSCache<NSNumber, NSValue>() - init(context: AppContext, hashtag: String) { self.context = context self.hashtag = hashtag - let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value - self.fetchedResultsController = StatusFetchedResultsController(managedObjectContext: context.managedObjectContext, domain: activeMastodonAuthenticationBox?.domain, additionalTweetPredicate: nil) - super.init() - - fetchedResultsController.objectIDs - .receive(on: DispatchQueue.main) - .sink { [weak self] objectIds in - self?.generateStatusItems(newObjectIDs: objectIds) - } - .store(in: &disposeBag) - } - - func fetchTag() { - guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { - return - } - let query = Mastodon.API.V2.Search.Query(q: hashtag, type: .hashtags) - context.apiService.search( - domain: activeMastodonAuthenticationBox.domain, - query: query, - mastodonAuthenticationBox: activeMastodonAuthenticationBox + self.fetchedResultsController = StatusFetchedResultsController( + managedObjectContext: context.managedObjectContext, + domain: nil, + additionalTweetPredicate: nil ) - .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) - } - .store(in: &disposeBag) - + // end init + + context.authenticationService.activeMastodonAuthenticationBox + .map { $0?.domain } + .assign(to: \.value, on: fetchedResultsController.domain) + .store(in: &disposeBag) } deinit { @@ -107,3 +71,4 @@ final class HashtagTimelineViewModel: NSObject { } } + diff --git a/Mastodon/Scene/HomeTimeline/AsyncHomeTimeline/AsyncHomeTimelineViewController+DebugAction.swift b/Mastodon/Scene/HomeTimeline/AsyncHomeTimeline/AsyncHomeTimelineViewController+DebugAction.swift deleted file mode 100644 index 19c3244c9..000000000 --- a/Mastodon/Scene/HomeTimeline/AsyncHomeTimeline/AsyncHomeTimelineViewController+DebugAction.swift +++ /dev/null @@ -1,384 +0,0 @@ -// -// AsyncHomeTimelineViewController+DebugAction.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-6-21. -// - -#if ASDK && DEBUG - -import os.log -import UIKit -import CoreData -import CoreDataStack -import FLEX - -extension AsyncHomeTimelineViewController { - var debugMenu: UIMenu { - let menu = UIMenu( - title: "Debug Tools", - image: nil, - 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) - }), - 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 Or Remove 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) - }, - UIAction(title: "Settings", image: UIImage(systemName: "gear"), attributes: []) { [weak self] action in - guard let self = self else { return } - self.showSettings(action) - }, - UIAction(title: "Sign Out", image: UIImage(systemName: "escape"), attributes: .destructive) { [weak self] action in - guard let self = self else { return } - self.signOutAction(action) - } - ] - ) - return menu - } - - var moveMenu: UIMenu { - return UIMenu( - title: "Move to…", - image: UIImage(systemName: "arrow.forward.circle"), - identifier: nil, - options: [], - children: [ - UIAction(title: "First Gap", image: nil, attributes: [], handler: { [weak self] action in - guard let self = self else { return } - self.moveToTopGapAction(action) - }), - UIAction(title: "First Replied Status", image: nil, attributes: [], handler: { [weak self] action in - guard let self = self else { return } - self.moveToFirstRepliedStatus(action) - }), - UIAction(title: "First Reblog Status", image: nil, attributes: [], handler: { [weak self] action in - guard let self = self else { return } - self.moveToFirstReblogStatus(action) - }), - UIAction(title: "First Poll Status", image: nil, attributes: [], handler: { [weak self] action in - guard let self = self else { return } - self.moveToFirstPollStatus(action) - }), - UIAction(title: "First Audio Status", image: nil, attributes: [], handler: { [weak self] action in - guard let self = self else { return } - self.moveToFirstAudioStatus(action) - }), - UIAction(title: "First Video Status", image: nil, attributes: [], handler: { [weak self] action in - guard let self = self else { return } - self.moveToFirstVideoStatus(action) - }), - UIAction(title: "First GIF status", image: nil, attributes: [], handler: { [weak self] action in - guard let self = self else { return } - self.moveToFirstGIFStatus(action) - }), - ] - ) - } - - var dropMenu: UIMenu { - return UIMenu( - title: "Drop…", - image: UIImage(systemName: "minus.circle"), - identifier: nil, - options: [], - children: [10, 50, 100, 150, 200, 250, 300].map { count in - UIAction(title: "Drop Recent \(count) Statuses", image: nil, attributes: [], handler: { [weak self] action in - guard let self = self else { return } - self.dropRecentStatusAction(action, count: count) - }) - } - ) - } -} - -extension AsyncHomeTimelineViewController { - - @objc private func showFLEXAction(_ sender: UIAction) { - FLEXManager.shared.showExplorer() - } - - @objc private func moveToTopGapAction(_ sender: UIAction) { - guard let diffableDataSource = viewModel.diffableDataSource else { return } - let snapshotTransitioning = diffableDataSource.snapshot() - let item = snapshotTransitioning.itemIdentifiers.first(where: { item in - switch item { - case .homeMiddleLoader: return true - default: return false - } - }) - if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) { - tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) - } - } - - @objc private func moveToFirstReblogStatus(_ sender: UIAction) { - guard let diffableDataSource = viewModel.diffableDataSource else { return } - let snapshotTransitioning = diffableDataSource.snapshot() - let item = snapshotTransitioning.itemIdentifiers.first(where: { item in - switch item { - case .homeTimelineIndex(let objectID, _): - let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex - return homeTimelineIndex.status.reblog != nil - default: - return false - } - }) - if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) { - tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) - tableView.blinkRow(at: IndexPath(row: index, section: 0)) - } else { - print("Not found reblog status") - } - } - - @objc private func moveToFirstPollStatus(_ sender: UIAction) { - guard let diffableDataSource = viewModel.diffableDataSource else { return } - let snapshotTransitioning = diffableDataSource.snapshot() - let item = snapshotTransitioning.itemIdentifiers.first(where: { item in - switch item { - case .homeTimelineIndex(let objectID, _): - let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex - let post = homeTimelineIndex.status.reblog ?? homeTimelineIndex.status - return post.poll != nil - default: - return false - } - }) - if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) { - tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) - tableView.blinkRow(at: IndexPath(row: index, section: 0)) - } else { - print("Not found poll status") - } - } - - @objc private func moveToFirstRepliedStatus(_ sender: UIAction) { - guard let diffableDataSource = viewModel.diffableDataSource else { return } - let snapshotTransitioning = diffableDataSource.snapshot() - let item = snapshotTransitioning.itemIdentifiers.first(where: { item in - switch item { - case .homeTimelineIndex(let objectID, _): - let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex - guard homeTimelineIndex.status.inReplyToID != nil else { - return false - } - return true - default: - return false - } - }) - if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) { - tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) - tableView.blinkRow(at: IndexPath(row: index, section: 0)) - } else { - print("Not found replied status") - } - } - - @objc private func moveToFirstAudioStatus(_ sender: UIAction) { - guard let diffableDataSource = viewModel.diffableDataSource else { return } - let snapshotTransitioning = diffableDataSource.snapshot() - let item = snapshotTransitioning.itemIdentifiers.first(where: { item in - switch item { - case .homeTimelineIndex(let objectID, _): - let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex - let status = homeTimelineIndex.status.reblog ?? homeTimelineIndex.status - return status.mediaAttachments?.contains(where: { $0.type == .audio }) ?? false - default: - return false - } - }) - if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) { - tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) - tableView.blinkRow(at: IndexPath(row: index, section: 0)) - } else { - print("Not found audio status") - } - } - - @objc private func moveToFirstVideoStatus(_ sender: UIAction) { - guard let diffableDataSource = viewModel.diffableDataSource else { return } - let snapshotTransitioning = diffableDataSource.snapshot() - let item = snapshotTransitioning.itemIdentifiers.first(where: { item in - switch item { - case .homeTimelineIndex(let objectID, _): - let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex - let status = homeTimelineIndex.status.reblog ?? homeTimelineIndex.status - return status.mediaAttachments?.contains(where: { $0.type == .video }) ?? false - default: - return false - } - }) - if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) { - tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) - tableView.blinkRow(at: IndexPath(row: index, section: 0)) - } else { - print("Not found video status") - } - } - - @objc private func moveToFirstGIFStatus(_ sender: UIAction) { - guard let diffableDataSource = viewModel.diffableDataSource else { return } - let snapshotTransitioning = diffableDataSource.snapshot() - let item = snapshotTransitioning.itemIdentifiers.first(where: { item in - switch item { - case .homeTimelineIndex(let objectID, _): - let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex - let status = homeTimelineIndex.status.reblog ?? homeTimelineIndex.status - return status.mediaAttachments?.contains(where: { $0.type == .gifv }) ?? false - default: - return false - } - }) - if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) { - tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) - tableView.blinkRow(at: IndexPath(row: index, section: 0)) - } else { - print("Not found GIF status") - } - } - - @objc private func dropRecentStatusAction(_ sender: UIAction, count: Int) { - guard let diffableDataSource = viewModel.diffableDataSource else { return } - let snapshotTransitioning = diffableDataSource.snapshot() - - let droppingObjectIDs = snapshotTransitioning.itemIdentifiers.prefix(count).compactMap { item -> NSManagedObjectID? in - switch item { - case .homeTimelineIndex(let objectID, _): return objectID - default: return nil - } - } - var droppingStatusObjectIDs: [NSManagedObjectID] = [] - context.apiService.backgroundManagedObjectContext.performChanges { [weak self] in - guard let self = self else { return } - for objectID in droppingObjectIDs { - guard let homeTimelineIndex = try? self.context.apiService.backgroundManagedObjectContext.existingObject(with: objectID) as? HomeTimelineIndex else { continue } - droppingStatusObjectIDs.append(homeTimelineIndex.status.objectID) - self.context.apiService.backgroundManagedObjectContext.delete(homeTimelineIndex) - } - } - .sink { [weak self] result in - guard let self = self else { return } - switch result { - case .success: - self.context.apiService.backgroundManagedObjectContext.performChanges { [weak self] in - guard let self = self else { return } - for objectID in droppingStatusObjectIDs { - guard let post = try? self.context.apiService.backgroundManagedObjectContext.existingObject(with: objectID) as? Status else { continue } - self.context.apiService.backgroundManagedObjectContext.delete(post) - } - } - .sink { _ in - // do nothing - } - .store(in: &self.disposeBag) - case .failure(let error): - assertionFailure(error.localizedDescription) - } - } - .store(in: &disposeBag) - } - - @objc private func showWelcomeAction(_ sender: UIAction) { - coordinator.present(scene: .welcome, from: self, transition: .modal(animated: true, completion: nil)) - } - - @objc private func showPublicTimelineAction(_ sender: UIAction) { - coordinator.present(scene: .publicTimeline, from: self, transition: .show) - } - - @objc private func showProfileAction(_ sender: UIAction) { - let alertController = UIAlertController(title: "Enter User 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 else { return } - let profileViewModel = RemoteProfileViewModel(context: self.context, userID: textField.text ?? "") - self.coordinator.present(scene: .profile(viewModel: profileViewModel), from: self, transition: .show) - } - alertController.addAction(showAction) - let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil) - alertController.addAction(cancelAction) - coordinator.present(scene: .alertController(alertController: alertController), from: self, transition: .alertController(animated: true, completion: nil)) - } - - @objc private func showThreadAction(_ sender: UIAction) { - let alertController = UIAlertController(title: "Enter Status 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 else { return } - let threadViewModel = RemoteThreadViewModel(context: self.context, statusID: textField.text ?? "") - self.coordinator.present(scene: .thread(viewModel: threadViewModel), from: self, transition: .show) - } - alertController.addAction(showAction) - let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil) - alertController.addAction(cancelAction) - 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) - coordinator.present( - scene: .settings(viewModel: settingsViewModel), - from: self, - transition: .modal(animated: true, completion: nil) - ) - } - - @objc func signOutAction(_ sender: UIAction) { - guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { - return - } - - context.authenticationService.signOutMastodonUser( - domain: activeMastodonAuthenticationBox.domain, - userID: activeMastodonAuthenticationBox.userID - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] result in - guard let self = self else { return } - switch result { - case .failure(let error): - assertionFailure(error.localizedDescription) - case .success(let isSignOut): - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: sign out %s", ((#file as NSString).lastPathComponent), #line, #function, isSignOut ? "success" : "fail") - guard isSignOut else { return } - self.coordinator.setup() - self.coordinator.setupOnboardingIfNeeds(animated: true) - } - } - .store(in: &disposeBag) - } -} - -#endif diff --git a/Mastodon/Scene/HomeTimeline/AsyncHomeTimeline/AsyncHomeTimelineViewController+Provider.swift b/Mastodon/Scene/HomeTimeline/AsyncHomeTimeline/AsyncHomeTimelineViewController+Provider.swift deleted file mode 100644 index 5f97ebead..000000000 --- a/Mastodon/Scene/HomeTimeline/AsyncHomeTimeline/AsyncHomeTimelineViewController+Provider.swift +++ /dev/null @@ -1,123 +0,0 @@ -// -// AsyncHomeTimelineViewController+Provider.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-6-21. -// - -#if ASDK - -import os.log -import UIKit -import Combine -import CoreData -import CoreDataStack -import AsyncDisplayKit - -// MARK: - StatusProvider -extension AsyncHomeTimelineViewController: StatusProvider { - - func status() -> Future<Status?, Never> { - return Future { promise in promise(.success(nil)) } - } - - func status(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future<Status?, Never> { - return Future { promise in - 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 .homeTimelineIndex(let objectID, _): - let managedObjectContext = self.viewModel.fetchedResultsController.managedObjectContext - managedObjectContext.perform { - let timelineIndex = managedObjectContext.object(with: objectID) as? HomeTimelineIndex - promise(.success(timelineIndex?.status)) - } - default: - promise(.success(nil)) - } - } - } - - func status(for cell: UICollectionViewCell) -> Future<Status?, Never> { - return Future { promise in promise(.success(nil)) } - } - - var managedObjectContext: NSManagedObjectContext { - return viewModel.fetchedResultsController.managedObjectContext - } - - var tableViewDiffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>? { - return nil - } - - func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item? { - guard let diffableDataSource = self.viewModel.diffableDataSource else { - assertionFailure() - return nil - } - - guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }), - let item = diffableDataSource.itemIdentifier(for: indexPath) else { - return nil - } - - return item - } - - func items(indexPaths: [IndexPath]) -> [Item] { - guard let diffableDataSource = self.viewModel.diffableDataSource else { - assertionFailure() - return [] - } - - var items: [Item] = [] - for indexPath in indexPaths { - guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { continue } - items.append(item) - } - return items - } - - func status(node: ASCellNode?, indexPath: IndexPath?) -> Status? { - guard let diffableDataSource = self.viewModel.diffableDataSource else { - assertionFailure() - return nil - } - - guard let indexPath = indexPath ?? node.flatMap({ self.node.indexPath(for: $0) }), - let item = diffableDataSource.itemIdentifier(for: indexPath) else { - return nil - } - - switch item { - case .homeTimelineIndex(let objectID, _): - guard let homeTimelineIndex = try? viewModel.fetchedResultsController.managedObjectContext.existingObject(with: objectID) as? HomeTimelineIndex else { - assertionFailure() - return nil - } - return homeTimelineIndex.status - default: - return nil - } - } - - func statusObjectItems(indexPaths: [IndexPath]) -> [StatusObjectItem] { - guard let diffableDataSource = self.viewModel.diffableDataSource else { return [] } - let items = indexPaths.compactMap { diffableDataSource.itemIdentifier(for: $0)?.statusObjectItem } - return items - } - -} - -extension AsyncHomeTimelineViewController: UserProvider {} - -#endif diff --git a/Mastodon/Scene/HomeTimeline/AsyncHomeTimeline/AsyncHomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/AsyncHomeTimeline/AsyncHomeTimelineViewController.swift deleted file mode 100644 index c90b703e5..000000000 --- a/Mastodon/Scene/HomeTimeline/AsyncHomeTimeline/AsyncHomeTimelineViewController.swift +++ /dev/null @@ -1,573 +0,0 @@ -// -// AsyncHomeTimelineViewController.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-6-21. -// - -#if ASDK - -import os.log -import UIKit -import AVKit -import Combine -import CoreData -import CoreDataStack -import GameplayKit -import MastodonSDK -import AlamofireImage -import AsyncDisplayKit - -final class AsyncHomeTimelineViewController: ASDKViewController<ASTableNode>, NeedsDependency, MediaPreviewableViewController { - - weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } - weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } - - var disposeBag = Set<AnyCancellable>() - private(set) lazy var viewModel = AsyncHomeTimelineViewModel(context: context) - - let mediaPreviewTransitionController = MediaPreviewTransitionController() - - lazy var emptyView: UIStackView = { - let emptyView = UIStackView() - emptyView.axis = .vertical - emptyView.distribution = .fill - emptyView.layoutMargins = UIEdgeInsets(top: 0, left: 20, bottom: 54, right: 20) - emptyView.isLayoutMarginsRelativeArrangement = true - return emptyView - }() - - let titleView = HomeTimelineNavigationBarTitleView() - - let settingBarButtonItem: UIBarButtonItem = { - let barButtonItem = UIBarButtonItem() - barButtonItem.tintColor = Asset.Colors.brandBlue.color - barButtonItem.image = UIImage(systemName: "gear")?.withRenderingMode(.alwaysTemplate) - return barButtonItem - }() - - let composeBarButtonItem: UIBarButtonItem = { - let barButtonItem = UIBarButtonItem() - barButtonItem.tintColor = Asset.Colors.brandBlue.color - barButtonItem.image = UIImage(systemName: "square.and.pencil")?.withRenderingMode(.alwaysTemplate) - return barButtonItem - }() - - var tableView: UITableView { node.view } - - let publishProgressView: UIProgressView = { - let progressView = UIProgressView(progressViewStyle: .bar) - progressView.alpha = 0 - return progressView - }() - - let refreshControl = UIRefreshControl() - - - override init() { - super.init(node: ASTableNode()) - } - - 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 AsyncHomeTimelineViewController { - - override func viewDidLoad() { - super.viewDidLoad() - - node.allowsSelection = true - - title = L10n.Scene.HomeTimeline.title - view.backgroundColor = ThemeService.shared.currentTheme.value.secondarySystemBackgroundColor - navigationItem.leftBarButtonItem = settingBarButtonItem - navigationItem.titleView = titleView - titleView.delegate = self - - viewModel.homeTimelineNavigationBarTitleViewModel.state - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink { [weak self] state in - guard let self = self else { return } - self.titleView.configure(state: state) - } - .store(in: &disposeBag) - - #if DEBUG - // long press to trigger debug menu - settingBarButtonItem.menu = debugMenu - #else - settingBarButtonItem.target = self - settingBarButtonItem.action = #selector(AsyncHomeTimelineViewController.settingBarButtonItemPressed(_:)) - #endif - - navigationItem.rightBarButtonItem = composeBarButtonItem - composeBarButtonItem.target = self - composeBarButtonItem.action = #selector(AsyncHomeTimelineViewController.composeBarButtonItemPressed(_:)) - - node.view.refreshControl = refreshControl - refreshControl.addTarget(self, action: #selector(AsyncHomeTimelineViewController.refreshControlValueChanged(_:)), for: .valueChanged) -// -// 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), -// ]) -// -// publishProgressView.translatesAutoresizingMaskIntoConstraints = false -// view.addSubview(publishProgressView) -// NSLayoutConstraint.activate([ -// publishProgressView.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor), -// publishProgressView.leadingAnchor.constraint(equalTo: view.leadingAnchor), -// publishProgressView.trailingAnchor.constraint(equalTo: view.trailingAnchor), -// ]) -// -// viewModel.tableView = tableView - viewModel.tableNode = node - viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self - node.delegate = self - viewModel.setupDiffableDataSource( - tableNode: node, - dependency: self, - statusTableViewCellDelegate: self, - timelineMiddleLoaderTableViewCellDelegate: self - ) - - -// tableView.delegate = self -// tableView.prefetchDataSource = self - - // bind refresh control - viewModel.isFetchingLatestTimeline - .receive(on: DispatchQueue.main) - .sink { [weak self] isFetching in - guard let self = self else { return } - if !isFetching { - UIView.animate(withDuration: 0.5) { [weak self] in - guard let self = self else { return } - self.refreshControl.endRefreshing() - } completion: { _ in } - } - } - .store(in: &disposeBag) - -// viewModel.homeTimelineNavigationBarTitleViewModel.publishingProgress -// .receive(on: DispatchQueue.main) -// .sink { [weak self] progress in -// guard let self = self else { return } -// guard progress > 0 else { -// let dismissAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .easeInOut) -// dismissAnimator.addAnimations { -// self.publishProgressView.alpha = 0 -// } -// dismissAnimator.addCompletion { _ in -// self.publishProgressView.setProgress(0, animated: false) -// } -// dismissAnimator.startAnimation() -// return -// } -// if self.publishProgressView.alpha == 0 { -// let progressAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .easeOut) -// progressAnimator.addAnimations { -// self.publishProgressView.alpha = 1 -// } -// progressAnimator.startAnimation() -// } -// -// self.publishProgressView.setProgress(progress, animated: true) -// } -// .store(in: &disposeBag) -// -// viewModel.timelineIsEmpty -// .receive(on: DispatchQueue.main) -// .sink { [weak self] isEmpty in -// if isEmpty { -// self?.showEmptyView() -// } else { -// self?.emptyView.removeFromSuperview() -// } -// } -// .store(in: &disposeBag) - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - -// aspectViewWillAppear(animated) -// -// // needs trigger manually after onboarding dismiss -// setNeedsStatusBarAppearanceUpdate() -// -// if (viewModel.fetchedResultsController.fetchedObjects ?? []).isEmpty { -// viewModel.loadLatestStateMachine.enter(HomeTimelineViewModel.LoadLatestState.Loading.self) -// } - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - -// viewModel.viewDidAppear.send() -// -// DispatchQueue.main.async { [weak self] in -// guard let self = self else { return } -// if (self.viewModel.fetchedResultsController.fetchedObjects ?? []).count == 0 { -// self.viewModel.loadLatestStateMachine.enter(HomeTimelineViewModel.LoadLatestState.Loading.self) -// } -// } - } - - override func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - -// aspectViewDidDisappear(animated) - } - - override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { - super.viewWillTransition(to: size, with: coordinator) - -// coordinator.animate { _ in -// // do nothing -// } completion: { _ in -// // fix AutoLayout cell height not update after rotate issue -// self.viewModel.cellFrameCache.removeAllObjects() -// self.tableView.reloadData() -// } - } -} - -extension AsyncHomeTimelineViewController { - func showEmptyView() { - if emptyView.superview != nil { - return - } - view.addSubview(emptyView) - emptyView.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - emptyView.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor), - emptyView.trailingAnchor.constraint(equalTo: view.readableContentGuide.trailingAnchor), - emptyView.bottomAnchor.constraint(equalTo: view.readableContentGuide.bottomAnchor) - ]) - - if emptyView.arrangedSubviews.count > 0 { - return - } - let findPeopleButton: PrimaryActionButton = { - let button = PrimaryActionButton() - button.setTitle(L10n.Common.Controls.Actions.findPeople, for: .normal) - button.addTarget(self, action: #selector(AsyncHomeTimelineViewController.findPeopleButtonPressed(_:)), for: .touchUpInside) - return button - }() - NSLayoutConstraint.activate([ - findPeopleButton.heightAnchor.constraint(equalToConstant: 46) - ]) - - let manuallySearchButton: HighlightDimmableButton = { - let button = HighlightDimmableButton() - button.titleLabel?.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .semibold)) - button.setTitle(L10n.Common.Controls.Actions.manuallySearch, for: .normal) - button.setTitleColor(Asset.Colors.brandBlue.color, for: .normal) - button.addTarget(self, action: #selector(AsyncHomeTimelineViewController.manuallySearchButtonPressed(_:)), for: .touchUpInside) - return button - }() - - emptyView.addArrangedSubview(findPeopleButton) - emptyView.setCustomSpacing(17, after: findPeopleButton) - emptyView.addArrangedSubview(manuallySearchButton) - - } -} - -extension AsyncHomeTimelineViewController { - - @objc private func findPeopleButtonPressed(_ sender: PrimaryActionButton) { - let viewModel = SuggestionAccountViewModel(context: context) - viewModel.delegate = self.viewModel - coordinator.present(scene: .suggestionAccount(viewModel: viewModel), from: self, transition: .modal(animated: true, completion: nil)) - } - - @objc private func manuallySearchButtonPressed(_ sender: UIButton) { - coordinator.switchToTabBar(tab: .search) - } - - @objc private func settingBarButtonItemPressed(_ sender: UIBarButtonItem) { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - 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)) - } - - @objc private func composeBarButtonItemPressed(_ sender: UIBarButtonItem) { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - let composeViewModel = ComposeViewModel(context: context, composeKind: .post) - coordinator.present(scene: .compose(viewModel: composeViewModel), from: self, transition: .modal(animated: true, completion: nil)) - } - - @objc private func refreshControlValueChanged(_ sender: UIRefreshControl) { - guard viewModel.loadLatestStateMachine.enter(HomeTimelineViewModel.LoadLatestState.Loading.self) else { - sender.endRefreshing() - return - } - } - -} - -// MARK: - StatusTableViewControllerAspect -//extension AsyncHomeTimelineViewController: StatusTableViewControllerAspect { } - -//extension AsyncHomeTimelineViewController: TableViewCellHeightCacheableContainer { -// var cellFrameCache: NSCache<NSNumber, NSValue> { return viewModel.cellFrameCache } -//} - -// MARK: - UIScrollViewDelegate -extension AsyncHomeTimelineViewController { - func scrollViewDidScroll(_ scrollView: UIScrollView) { - - //aspectScrollViewDidScroll(scrollView) - viewModel.homeTimelineNavigationBarTitleViewModel.handleScrollViewDidScroll(scrollView) - } -} - -//extension AsyncHomeTimelineViewController: LoadMoreConfigurableTableViewContainer { -// typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell -// typealias LoadingState = HomeTimelineViewModel.LoadOldestState.Loading -// var loadMoreConfigurableTableView: UITableView { return tableView } -// var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.loadOldestStateMachine } -//} - -// MARK: - UITableViewDelegate -//extension AsyncHomeTimelineViewController: UITableViewDelegate { -// -// func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { -// aspectTableView(tableView, estimatedHeightForRowAt: indexPath) -// } -// -// func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { -// aspectTableView(tableView, willDisplay: cell, forRowAt: indexPath) -// } -// -// func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { -// aspectTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) -// } -// -// func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { -// aspectTableView(tableView, didSelectRowAt: 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) -// } -// -//} - -// MARK: - UITableViewDataSourcePrefetching -//extension AsyncHomeTimelineViewController: UITableViewDataSourcePrefetching { -// func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { -// aspectTableView(tableView, prefetchRowsAt: indexPaths) -// } -//} - -// MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate -extension AsyncHomeTimelineViewController: ContentOffsetAdjustableTimelineViewControllerDelegate { - func navigationBar() -> UINavigationBar? { - return navigationController?.navigationBar - } -} - -// MARK: - TimelineMiddleLoaderTableViewCellDelegate -extension AsyncHomeTimelineViewController: TimelineMiddleLoaderTableViewCellDelegate { - func configure(cell: TimelineMiddleLoaderTableViewCell, upperTimelineStatusID: String?, timelineIndexobjectID: NSManagedObjectID?) { - guard let upperTimelineIndexObjectID = timelineIndexobjectID else { - return - } - viewModel.loadMiddleSateMachineList - .receive(on: DispatchQueue.main) - .sink { [weak self] ids in - guard let _ = self else { return } - if let stateMachine = ids[upperTimelineIndexObjectID] { - guard let state = stateMachine.currentState else { - assertionFailure() - return - } - - // make success state same as loading due to snapshot updating delay - let isLoading = state is HomeTimelineViewModel.LoadMiddleState.Loading || state is HomeTimelineViewModel.LoadMiddleState.Success - if isLoading { - cell.startAnimating() - } else { - cell.stopAnimating() - } - } else { - cell.stopAnimating() - } - } - .store(in: &cell.disposeBag) - - var dict = viewModel.loadMiddleSateMachineList.value - if let _ = dict[upperTimelineIndexObjectID] { - // do nothing - } else { - let stateMachine = GKStateMachine(states: [ - AsyncHomeTimelineViewModel.LoadMiddleState.Initial(viewModel: viewModel, upperTimelineIndexObjectID: upperTimelineIndexObjectID), - AsyncHomeTimelineViewModel.LoadMiddleState.Loading(viewModel: viewModel, upperTimelineIndexObjectID: upperTimelineIndexObjectID), - AsyncHomeTimelineViewModel.LoadMiddleState.Fail(viewModel: viewModel, upperTimelineIndexObjectID: upperTimelineIndexObjectID), - AsyncHomeTimelineViewModel.LoadMiddleState.Success(viewModel: viewModel, upperTimelineIndexObjectID: upperTimelineIndexObjectID), - ]) - stateMachine.enter(HomeTimelineViewModel.LoadMiddleState.Initial.self) - dict[upperTimelineIndexObjectID] = stateMachine - viewModel.loadMiddleSateMachineList.value = dict - } - } - - func timelineMiddleLoaderTableViewCell(_ cell: TimelineMiddleLoaderTableViewCell, loadMoreButtonDidPressed button: UIButton) { - guard let diffableDataSource = viewModel.diffableDataSource else { return } - guard let indexPath = tableView.indexPath(for: cell) else { return } - guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } - - switch item { - case .homeMiddleLoader(let upper): - guard let stateMachine = viewModel.loadMiddleSateMachineList.value[upper] else { - assertionFailure() - return - } - stateMachine.enter(HomeTimelineViewModel.LoadMiddleState.Loading.self) - default: - assertionFailure() - } - } -} - -// MARK: - ScrollViewContainer -extension AsyncHomeTimelineViewController: ScrollViewContainer { - - var scrollView: UIScrollView { return tableView } - - func scrollToTop(animated: Bool) { - if scrollView.contentOffset.y < scrollView.frame.height, - viewModel.loadLatestStateMachine.canEnterState(HomeTimelineViewModel.LoadLatestState.Loading.self), - (scrollView.contentOffset.y + scrollView.adjustedContentInset.top) == 0.0, - !refreshControl.isRefreshing { - scrollView.scrollRectToVisible(CGRect(origin: CGPoint(x: 0, y: -refreshControl.frame.height), size: CGSize(width: 1, height: 1)), animated: animated) - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - self.refreshControl.beginRefreshing() - self.refreshControl.sendActions(for: .valueChanged) - } - } else { - let indexPath = IndexPath(row: 0, section: 0) - guard viewModel.diffableDataSource?.itemIdentifier(for: indexPath) != nil else { return } - node.scrollToRow(at: indexPath, at: .top, animated: true) - } - } - -} - -// MARK: - AVPlayerViewControllerDelegate -extension AsyncHomeTimelineViewController: 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 AsyncHomeTimelineViewController: StatusTableViewCellDelegate { - weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self } - func parent() -> UIViewController { return self } -} - -// MARK: - HomeTimelineNavigationBarTitleViewDelegate -extension AsyncHomeTimelineViewController: HomeTimelineNavigationBarTitleViewDelegate { - func homeTimelineNavigationBarTitleView(_ titleView: HomeTimelineNavigationBarTitleView, logoButtonDidPressed sender: UIButton) { - scrollToTop(animated: true) - } - - func homeTimelineNavigationBarTitleView(_ titleView: HomeTimelineNavigationBarTitleView, buttonDidPressed sender: UIButton) { - switch titleView.state { - case .newPostButton: - guard let diffableDataSource = viewModel.diffableDataSource else { return } - let indexPath = IndexPath(row: 0, section: 0) - guard diffableDataSource.itemIdentifier(for: indexPath) != nil else { return } - node.scrollToRow(at: indexPath, at: .top, animated: true) - case .offlineButton: - // TODO: retry - break - case .publishedButton: - break - default: - break - } - } -} - -extension AsyncHomeTimelineViewController { - override var keyCommands: [UIKeyCommand]? { - return navigationKeyCommands + statusNavigationKeyCommands - } -} - -// MARK: - StatusTableViewControllerNavigateable -extension AsyncHomeTimelineViewController: StatusTableViewControllerNavigateable { - @objc func navigateKeyCommandHandlerRelay(_ sender: UIKeyCommand) { - navigateKeyCommandHandler(sender) - } - - @objc func statusKeyCommandHandlerRelay(_ sender: UIKeyCommand) { - statusKeyCommandHandler(sender) - } -} - - -// MARK: - ASTableDelegate -extension AsyncHomeTimelineViewController: ASTableDelegate { - func shouldBatchFetch(for tableNode: ASTableNode) -> Bool { - switch viewModel.loadLatestStateMachine.currentState { - case is HomeTimelineViewModel.LoadOldestState.NoMore: - return false - default: - return true - } - } - - func tableNode(_ tableNode: ASTableNode, willBeginBatchFetchWith context: ASBatchContext) { - viewModel.loadLatestStateMachine.enter(HomeTimelineViewModel.LoadOldestState.Loading.self) - context.completeBatchFetching(true) - } - - func tableNode(_ tableNode: ASTableNode, willDisplayRowWith node: ASCellNode) { - if let statusNode = node as? StatusNode { - statusNode.delegate = self - } - } -} - -// MARK: - StatusNodeDelegate -extension AsyncHomeTimelineViewController: StatusNodeDelegate { } - -#endif diff --git a/Mastodon/Scene/HomeTimeline/AsyncHomeTimeline/AsyncHomeTimelineViewModel+Diffable.swift b/Mastodon/Scene/HomeTimeline/AsyncHomeTimeline/AsyncHomeTimelineViewModel+Diffable.swift deleted file mode 100644 index 7799c2163..000000000 --- a/Mastodon/Scene/HomeTimeline/AsyncHomeTimeline/AsyncHomeTimelineViewModel+Diffable.swift +++ /dev/null @@ -1,159 +0,0 @@ -// -// AsyncHomeTimelineViewModel+Diffable.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-6-21. -// - -#if ASDK - -import os.log -import UIKit -import CoreData -import CoreDataStack -import AsyncDisplayKit -import DifferenceKit -import DiffableDataSources - -extension AsyncHomeTimelineViewModel { - - func setupDiffableDataSource( - tableNode: ASTableNode, - dependency: NeedsDependency, - statusTableViewCellDelegate: StatusTableViewCellDelegate, - timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate - ) { - tableNode.automaticallyAdjustsContentOffset = true - - diffableDataSource = StatusSection.tableNodeDiffableDataSource( - tableNode: tableNode, - managedObjectContext: fetchedResultsController.managedObjectContext - ) - - var snapshot = DiffableDataSourceSnapshot<StatusSection, Item>() - snapshot.appendSections([.main]) - diffableDataSource?.apply(snapshot) - } - -} - -// MARK: - NSFetchedResultsControllerDelegate -extension AsyncHomeTimelineViewModel: NSFetchedResultsControllerDelegate { - - func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) { - os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - } - - func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { - os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - - guard let diffableDataSource = self.diffableDataSource else { return } - let oldSnapshot = diffableDataSource.snapshot() - - let predicate = fetchedResultsController.fetchRequest.predicate - let parentManagedObjectContext = fetchedResultsController.managedObjectContext - let managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) - managedObjectContext.parent = parentManagedObjectContext - - managedObjectContext.perform { - var shouldAddBottomLoader = false - - let timelineIndexes: [HomeTimelineIndex] = { - let request = HomeTimelineIndex.sortedFetchRequest - request.returnsObjectsAsFaults = false - request.predicate = predicate - do { - return try managedObjectContext.fetch(request) - } catch { - assertionFailure(error.localizedDescription) - return [] - } - }() - - // that's will be the most fastest fetch because of upstream just update and no modify needs consider - - var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:] - - for item in oldSnapshot.itemIdentifiers { - guard case let .homeTimelineIndex(objectID, attribute) = item else { continue } - oldSnapshotAttributeDict[objectID] = attribute - } - - var newTimelineItems: [Item] = [] - - for (i, timelineIndex) in timelineIndexes.enumerated() { - let attribute = oldSnapshotAttributeDict[timelineIndex.objectID] ?? Item.StatusAttribute() - attribute.isSeparatorLineHidden = false - - // append new item into snapshot - newTimelineItems.append(.homeTimelineIndex(objectID: timelineIndex.objectID, attribute: attribute)) - - let isLast = i == timelineIndexes.count - 1 - switch (isLast, timelineIndex.hasMore) { - case (false, true): - newTimelineItems.append(.homeMiddleLoader(upperTimelineIndexAnchorObjectID: timelineIndex.objectID)) - attribute.isSeparatorLineHidden = true - case (true, true): - shouldAddBottomLoader = true - default: - break - } - } // end for - - var newSnapshot = DiffableDataSourceSnapshot<StatusSection, Item>() - newSnapshot.appendSections([.main]) - newSnapshot.appendItems(newTimelineItems, toSection: .main) - - let endSnapshot = CACurrentMediaTime() - - if shouldAddBottomLoader, !(self.loadLatestStateMachine.currentState is LoadOldestState.NoMore) { - newSnapshot.appendItems([.bottomLoader], toSection: .main) - } - - diffableDataSource.apply(newSnapshot, animatingDifferences: false) { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - self.isFetchingLatestTimeline.value = false - } - - let end = CACurrentMediaTime() - os_log("%{public}s[%{public}ld], %{public}s: calculate home timeline layout cost %.2fs", ((#file as NSString).lastPathComponent), #line, #function, end - endSnapshot) - } - } // end perform - } - - private struct Difference<T> { - let item: T - let sourceIndexPath: IndexPath - let targetIndexPath: IndexPath - let offset: CGFloat - } - - private func calculateReloadSnapshotDifference<T: Hashable>( - navigationBar: UINavigationBar, - tableView: UITableView, - oldSnapshot: DiffableDataSourceSnapshot<StatusSection, T>, - newSnapshot: DiffableDataSourceSnapshot<StatusSection, T> - ) -> Difference<T>? { - guard oldSnapshot.numberOfItems != 0 else { return nil } - - // old snapshot not empty. set source index path to first item if not match - let sourceIndexPath = UIViewController.topVisibleTableViewCellIndexPath(in: tableView, navigationBar: navigationBar) ?? IndexPath(row: 0, section: 0) - - guard sourceIndexPath.row < oldSnapshot.itemIdentifiers(inSection: .main).count else { return nil } - - let timelineItem = oldSnapshot.itemIdentifiers(inSection: .main)[sourceIndexPath.row] - guard let itemIndex = newSnapshot.itemIdentifiers(inSection: .main).firstIndex(of: timelineItem) else { return nil } - let targetIndexPath = IndexPath(row: itemIndex, section: 0) - - let offset = UIViewController.tableViewCellOriginOffsetToWindowTop(in: tableView, at: sourceIndexPath, navigationBar: navigationBar) - return Difference( - item: timelineItem, - sourceIndexPath: sourceIndexPath, - targetIndexPath: targetIndexPath, - offset: offset - ) - } - -} - -#endif diff --git a/Mastodon/Scene/HomeTimeline/AsyncHomeTimeline/AsyncHomeTimelineViewModel+LoadLatestState.swift b/Mastodon/Scene/HomeTimeline/AsyncHomeTimeline/AsyncHomeTimelineViewModel+LoadLatestState.swift deleted file mode 100644 index 4d73eae5a..000000000 --- a/Mastodon/Scene/HomeTimeline/AsyncHomeTimeline/AsyncHomeTimelineViewModel+LoadLatestState.swift +++ /dev/null @@ -1,134 +0,0 @@ -// -// AsyncHomeTimelineViewModel+LoadLatestState.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-6-21. -// -// - -#if ASDK - -import os.log -import func QuartzCore.CACurrentMediaTime -import Foundation -import CoreData -import CoreDataStack -import GameplayKit - -extension AsyncHomeTimelineViewModel { - class LoadLatestState: GKState { - weak var viewModel: AsyncHomeTimelineViewModel? - - init(viewModel: AsyncHomeTimelineViewModel) { - 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) - viewModel?.loadLatestStateMachinePublisher.send(self) - } - } -} - -extension AsyncHomeTimelineViewModel.LoadLatestState { - class Initial: AsyncHomeTimelineViewModel.LoadLatestState { - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - return stateClass == Loading.self - } - } - - class Loading: AsyncHomeTimelineViewModel.LoadLatestState { - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - return stateClass == Fail.self || stateClass == Idle.self - } - - override func didEnter(from previousState: GKState?) { - super.didEnter(from: previousState) - guard let viewModel = viewModel, let stateMachine = stateMachine else { return } - guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { - // sign out when loading will enter here - stateMachine.enter(Fail.self) - return - } - - let predicate = viewModel.fetchedResultsController.fetchRequest.predicate - let parentManagedObjectContext = viewModel.fetchedResultsController.managedObjectContext - let managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) - managedObjectContext.parent = parentManagedObjectContext - - managedObjectContext.perform { - let start = CACurrentMediaTime() - let latestStatusIDs: [Status.ID] - let request = HomeTimelineIndex.sortedFetchRequest - request.returnsObjectsAsFaults = false - request.predicate = predicate - - do { - let timelineIndexes = try managedObjectContext.fetch(request) - let endFetch = CACurrentMediaTime() - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: collect timelineIndexes cost: %.2fs", ((#file as NSString).lastPathComponent), #line, #function, endFetch - start) - latestStatusIDs = timelineIndexes - .prefix(APIService.onceRequestStatusMaxCount) // avoid performance issue - .compactMap { timelineIndex in - timelineIndex.value(forKeyPath: #keyPath(HomeTimelineIndex.status.id)) as? Status.ID - } - } catch { - stateMachine.enter(Fail.self) - return - } - - let end = CACurrentMediaTime() - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: collect statuses id cost: %.2fs", ((#file as NSString).lastPathComponent), #line, #function, end - start) - - // TODO: only set large count when using Wi-Fi - viewModel.context.apiService.homeTimeline(domain: activeMastodonAuthenticationBox.domain, authorizationBox: activeMastodonAuthenticationBox) - .receive(on: DispatchQueue.main) - .sink { completion in - viewModel.homeTimelineNavigationBarTitleViewModel.receiveLoadingStateCompletion(completion) - switch completion { - case .failure(let error): - // TODO: handle error - viewModel.isFetchingLatestTimeline.value = false - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch statuses failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) - case .finished: - // handle isFetchingLatestTimeline in fetch controller delegate - break - } - - stateMachine.enter(Idle.self) - - } receiveValue: { response in - // stop refresher if no new statuses - let statuses = response.value - let newStatuses = statuses.filter { !latestStatusIDs.contains($0.id) } - os_log("%{public}s[%{public}ld], %{public}s: load %{public}ld new statuses", ((#file as NSString).lastPathComponent), #line, #function, newStatuses.count) - - if newStatuses.isEmpty { - viewModel.isFetchingLatestTimeline.value = false - } else { - if !latestStatusIDs.isEmpty { - viewModel.homeTimelineNavigationBarTitleViewModel.newPostsIncoming() - } - } - viewModel.timelineIsEmpty.value = latestStatusIDs.isEmpty && statuses.isEmpty - } - .store(in: &viewModel.disposeBag) - } - } - } - - class Fail: AsyncHomeTimelineViewModel.LoadLatestState { - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - return stateClass == Loading.self || stateClass == Idle.self - } - } - - class Idle: AsyncHomeTimelineViewModel.LoadLatestState { - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - return stateClass == Loading.self - } - } - -} - -#endif diff --git a/Mastodon/Scene/HomeTimeline/AsyncHomeTimeline/AsyncHomeTimelineViewModel+LoadMiddleState.swift b/Mastodon/Scene/HomeTimeline/AsyncHomeTimeline/AsyncHomeTimelineViewModel+LoadMiddleState.swift deleted file mode 100644 index f568a6aaa..000000000 --- a/Mastodon/Scene/HomeTimeline/AsyncHomeTimeline/AsyncHomeTimelineViewModel+LoadMiddleState.swift +++ /dev/null @@ -1,112 +0,0 @@ -// -// AsyncHomeTimelineViewModel+LoadMiddleState.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-6-21. -// - -#if ASDK - -import os.log -import Foundation -import GameplayKit -import CoreData -import CoreDataStack - -extension AsyncHomeTimelineViewModel { - class LoadMiddleState: GKState { - weak var viewModel: AsyncHomeTimelineViewModel? - let upperTimelineIndexObjectID: NSManagedObjectID - - init(viewModel: AsyncHomeTimelineViewModel, upperTimelineIndexObjectID: NSManagedObjectID) { - self.viewModel = viewModel - self.upperTimelineIndexObjectID = upperTimelineIndexObjectID - } - - 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) - guard let viewModel = viewModel, let stateMachine = stateMachine else { return } - var dict = viewModel.loadMiddleSateMachineList.value - dict[upperTimelineIndexObjectID] = stateMachine - viewModel.loadMiddleSateMachineList.value = dict // trigger value change - } - } -} - -extension AsyncHomeTimelineViewModel.LoadMiddleState { - - class Initial: AsyncHomeTimelineViewModel.LoadMiddleState { - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - return stateClass == Loading.self - } - } - - class Loading: AsyncHomeTimelineViewModel.LoadMiddleState { - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - // guard let viewModel = viewModel else { return false } - return stateClass == Success.self || stateClass == Fail.self - } - - override func didEnter(from previousState: GKState?) { - super.didEnter(from: previousState) - - guard let viewModel = viewModel, let stateMachine = stateMachine else { return } - guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { - stateMachine.enter(Fail.self) - return - } - - guard let timelineIndex = (viewModel.fetchedResultsController.fetchedObjects ?? []).first(where: { $0.objectID == upperTimelineIndexObjectID }) else { - stateMachine.enter(Fail.self) - return - } - let statusIDs = (viewModel.fetchedResultsController.fetchedObjects ?? []).compactMap { timelineIndex in - timelineIndex.status.id - } - - // TODO: only set large count when using Wi-Fi - let maxID = timelineIndex.status.id - viewModel.context.apiService.homeTimeline(domain: activeMastodonAuthenticationBox.domain,maxID: maxID, authorizationBox: activeMastodonAuthenticationBox) - .delay(for: .seconds(1), scheduler: DispatchQueue.main) - .receive(on: DispatchQueue.main) - .sink { completion in - viewModel.homeTimelineNavigationBarTitleViewModel.receiveLoadingStateCompletion(completion) - switch completion { - case .failure(let error): - // TODO: handle error - os_log("%{public}s[%{public}ld], %{public}s: fetch statuses failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) - stateMachine.enter(Fail.self) - case .finished: - break - } - } receiveValue: { response in - let statuses = response.value - let newStatuses = statuses.filter { !statusIDs.contains($0.id) } - os_log("%{public}s[%{public}ld], %{public}s: load %{public}ld statuses, %{public}%ld new statuses", ((#file as NSString).lastPathComponent), #line, #function, statuses.count, newStatuses.count) - if newStatuses.isEmpty { - stateMachine.enter(Fail.self) - } else { - stateMachine.enter(Success.self) - } - } - .store(in: &viewModel.disposeBag) - } - } - - class Fail: AsyncHomeTimelineViewModel.LoadMiddleState { - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - // guard let viewModel = viewModel else { return false } - return stateClass == Loading.self - } - } - - class Success: AsyncHomeTimelineViewModel.LoadMiddleState { - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - // guard let viewModel = viewModel else { return false } - return false - } - } - -} - -#endif diff --git a/Mastodon/Scene/HomeTimeline/AsyncHomeTimeline/AsyncHomeTimelineViewModel+LoadOldestState.swift b/Mastodon/Scene/HomeTimeline/AsyncHomeTimeline/AsyncHomeTimelineViewModel+LoadOldestState.swift deleted file mode 100644 index 5743ab292..000000000 --- a/Mastodon/Scene/HomeTimeline/AsyncHomeTimeline/AsyncHomeTimelineViewModel+LoadOldestState.swift +++ /dev/null @@ -1,117 +0,0 @@ -// -// AsyncHomeTimelineViewModel+LoadOldestState.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-6-21. -// - -#if ASDK - -import os.log -import Foundation -import GameplayKit - -extension AsyncHomeTimelineViewModel { - class LoadOldestState: GKState { - weak var viewModel: AsyncHomeTimelineViewModel? - - init(viewModel: AsyncHomeTimelineViewModel) { - 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) - viewModel?.loadOldestStateMachinePublisher.send(self) - } - } -} - -extension AsyncHomeTimelineViewModel.LoadOldestState { - class Initial: AsyncHomeTimelineViewModel.LoadOldestState { - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - guard let viewModel = viewModel else { return false } - guard !(viewModel.fetchedResultsController.fetchedObjects ?? []).isEmpty else { return false } - return stateClass == Loading.self - } - } - - class Loading: AsyncHomeTimelineViewModel.LoadOldestState { - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - return stateClass == Fail.self || stateClass == Idle.self || stateClass == NoMore.self - } - - override func didEnter(from previousState: GKState?) { - super.didEnter(from: previousState) - guard let viewModel = viewModel, let stateMachine = stateMachine else { return } - guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { - assertionFailure() - stateMachine.enter(Fail.self) - return - } - - guard let last = viewModel.fetchedResultsController.fetchedObjects?.last else { - stateMachine.enter(Idle.self) - return - } - - // TODO: only set large count when using Wi-Fi - let maxID = last.status.id - viewModel.context.apiService.homeTimeline(domain: activeMastodonAuthenticationBox.domain, maxID: maxID, authorizationBox: activeMastodonAuthenticationBox) - .delay(for: .seconds(1), scheduler: DispatchQueue.main) - .receive(on: DispatchQueue.main) - .sink { completion in - viewModel.homeTimelineNavigationBarTitleViewModel.receiveLoadingStateCompletion(completion) - switch completion { - case .failure(let error): - os_log("%{public}s[%{public}ld], %{public}s: fetch statuses failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) - case .finished: - // handle isFetchingLatestTimeline in fetch controller delegate - break - } - } receiveValue: { response in - let statuses = response.value - // enter no more state when no new statuses - if statuses.isEmpty || (statuses.count == 1 && statuses[0].id == maxID) { - stateMachine.enter(NoMore.self) - } else { - stateMachine.enter(Idle.self) - } - } - .store(in: &viewModel.disposeBag) - } - } - - class Fail: AsyncHomeTimelineViewModel.LoadOldestState { - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - return stateClass == Loading.self || stateClass == Idle.self - } - } - - class Idle: AsyncHomeTimelineViewModel.LoadOldestState { - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - return stateClass == Loading.self - } - } - - class NoMore: AsyncHomeTimelineViewModel.LoadOldestState { - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - // reset state if needs - return stateClass == Idle.self - } - - override func didEnter(from previousState: GKState?) { - guard let viewModel = viewModel else { return } - guard let diffableDataSource = viewModel.diffableDataSource else { - assertionFailure() - return - } - DispatchQueue.main.async { - var snapshot = diffableDataSource.snapshot() - snapshot.deleteItems([.bottomLoader]) - diffableDataSource.apply(snapshot) - } - } - } -} - -#endif diff --git a/Mastodon/Scene/HomeTimeline/AsyncHomeTimeline/AsyncHomeTimelineViewModel.swift b/Mastodon/Scene/HomeTimeline/AsyncHomeTimeline/AsyncHomeTimelineViewModel.swift deleted file mode 100644 index d7ed0b10d..000000000 --- a/Mastodon/Scene/HomeTimeline/AsyncHomeTimeline/AsyncHomeTimelineViewModel.swift +++ /dev/null @@ -1,151 +0,0 @@ -// -// AsyncHomeTimelineViewModel.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-6-21. -// -// - -#if ASDK - -import os.log -import func AVFoundation.AVMakeRect -import UIKit -import AVKit -import Combine -import CoreData -import CoreDataStack -import GameplayKit -import AlamofireImage -import DateToolsSwift -import AsyncDisplayKit - -final class AsyncHomeTimelineViewModel: NSObject { - - var disposeBag = Set<AnyCancellable>() - var observations = Set<NSKeyValueObservation>() - - // input - let context: AppContext - let timelinePredicate = CurrentValueSubject<NSPredicate?, Never>(nil) - let fetchedResultsController: NSFetchedResultsController<HomeTimelineIndex> - let isFetchingLatestTimeline = CurrentValueSubject<Bool, Never>(false) - let viewDidAppear = PassthroughSubject<Void, Never>() - let homeTimelineNavigationBarTitleViewModel: HomeTimelineNavigationBarTitleViewModel - - weak var tableNode: ASTableNode? - weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate? - //weak var tableView: UITableView? - weak var timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate? - - let timelineIsEmpty = CurrentValueSubject<Bool, Never>(false) - let homeTimelineNeedRefresh = PassthroughSubject<Void, Never>() - - // output - var diffableDataSource: TableNodeDiffableDataSource<StatusSection, Item>? - - // top loader - private(set) lazy var loadLatestStateMachine: GKStateMachine = { - // exclude timeline middle fetcher state - let stateMachine = GKStateMachine(states: [ - LoadLatestState.Initial(viewModel: self), - LoadLatestState.Loading(viewModel: self), - LoadLatestState.Fail(viewModel: self), - LoadLatestState.Idle(viewModel: self), - ]) - stateMachine.enter(LoadLatestState.Initial.self) - return stateMachine - }() - lazy var loadLatestStateMachinePublisher = CurrentValueSubject<LoadLatestState?, Never>(nil) - // bottom loader - private(set) lazy var loadOldestStateMachine: GKStateMachine = { - // exclude timeline middle fetcher state - let stateMachine = GKStateMachine(states: [ - LoadOldestState.Initial(viewModel: self), - LoadOldestState.Loading(viewModel: self), - LoadOldestState.Fail(viewModel: self), - LoadOldestState.Idle(viewModel: self), - LoadOldestState.NoMore(viewModel: self), - ]) - stateMachine.enter(LoadOldestState.Initial.self) - return stateMachine - }() - lazy var loadOldestStateMachinePublisher = CurrentValueSubject<LoadOldestState?, Never>(nil) - // middle loader - let loadMiddleSateMachineList = CurrentValueSubject<[NSManagedObjectID: GKStateMachine], Never>([:]) // TimelineIndex.objectID : middle loading state machine - // var diffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>? - var cellFrameCache = NSCache<NSNumber, NSValue>() - - - init(context: AppContext) { - self.context = context - self.fetchedResultsController = { - let fetchRequest = HomeTimelineIndex.sortedFetchRequest - fetchRequest.fetchBatchSize = 20 - fetchRequest.returnsObjectsAsFaults = false - fetchRequest.relationshipKeyPathsForPrefetching = [#keyPath(HomeTimelineIndex.status)] - let controller = NSFetchedResultsController( - fetchRequest: fetchRequest, - managedObjectContext: context.managedObjectContext, - sectionNameKeyPath: nil, - cacheName: nil - ) - - return controller - }() - self.homeTimelineNavigationBarTitleViewModel = HomeTimelineNavigationBarTitleViewModel(context: context) - super.init() - - fetchedResultsController.delegate = self - - timelinePredicate - .receive(on: DispatchQueue.main) - .compactMap { $0 } - .first() // set once - .sink { [weak self] predicate in - guard let self = self else { return } - self.fetchedResultsController.fetchRequest.predicate = predicate - do { - try self.fetchedResultsController.performFetch() - } catch { - assertionFailure(error.localizedDescription) - } - } - .store(in: &disposeBag) - - context.authenticationService.activeMastodonAuthentication - .sink { [weak self] activeMastodonAuthentication in - guard let self = self else { return } - guard let mastodonAuthentication = activeMastodonAuthentication else { return } - let activeMastodonUserID = mastodonAuthentication.userID - let predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ - HomeTimelineIndex.predicate(userID: activeMastodonUserID), - HomeTimelineIndex.notDeleted() - ]) - self.timelinePredicate.value = predicate - } - .store(in: &disposeBag) - - homeTimelineNeedRefresh - .sink { [weak self] _ in - self?.loadLatestStateMachine.enter(LoadLatestState.Loading.self) - } - .store(in: &disposeBag) - - homeTimelineNavigationBarTitleViewModel.isPublished - .sink { [weak self] isPublished in - guard let self = self else { return } - self.homeTimelineNeedRefresh.send() - } - .store(in: &disposeBag) - } - - deinit { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function) - } - -} - -extension AsyncHomeTimelineViewModel: SuggestionAccountViewModelDelegate { } - -#endif diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DataSourceProvider.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DataSourceProvider.swift new file mode 100644 index 000000000..b141d386a --- /dev/null +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DataSourceProvider.swift @@ -0,0 +1,44 @@ +// +// HomeTimelineViewController+DataSourceProvider.swift +// Mastodon +// +// Created by MainasuK on 2022-1-13. +// + +import UIKit + +extension HomeTimelineViewController: DataSourceProvider { + func item(from source: DataSourceItem.Source) async -> DataSourceItem? { + var _indexPath = source.indexPath + if _indexPath == nil, let cell = source.tableViewCell { + _indexPath = await self.indexPath(for: cell) + } + guard let indexPath = _indexPath else { return nil } + + guard let item = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else { + return nil + } + + switch item { + case .feed(let record): + let managedObjectContext = context.managedObjectContext + let item: DataSourceItem? = try? await managedObjectContext.perform { + guard let feed = record.object(in: managedObjectContext) else { return nil } + guard feed.kind == .home else { return nil } + if let status = feed.status { + return .status(record: .init(objectID: status.objectID)) + } else { + return nil + } + } + return item + default: + return nil + } + } + + @MainActor + private func indexPath(for cell: UITableViewCell) async -> IndexPath? { + return tableView.indexPath(for: cell) + } +} diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift index 6e75a17e7..8b1d390f5 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift @@ -6,7 +6,7 @@ // -#if DEBUG +#if DEBUG || SNAPSHOT import os.log import UIKit import CoreData @@ -66,10 +66,6 @@ extension HomeTimelineViewController { 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) @@ -78,6 +74,15 @@ extension HomeTimelineViewController { guard let self = self else { return } self.showThreadAction(action) }, + UIAction(title: "Account Recommend", image: UIImage(systemName: "human"), attributes: []) { [weak self] action in + guard let self = self else { return } + let suggestionAccountViewModel = SuggestionAccountViewModel(context: self.context) + self.coordinator.present( + scene: .suggestionAccount(viewModel: suggestionAccountViewModel), + from: self, + transition: .modal(animated: true, completion: nil) + ) + }, 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 } @@ -87,45 +92,6 @@ extension HomeTimelineViewController { ) } - var moveMenu: UIMenu { - return UIMenu( - title: "Move to…", - image: UIImage(systemName: "arrow.forward.circle"), - identifier: nil, - options: [], - children: [ - UIAction(title: "First Gap", image: nil, attributes: [], handler: { [weak self] action in - guard let self = self else { return } - self.moveToTopGapAction(action) - }), - UIAction(title: "First Replied Status", image: nil, attributes: [], handler: { [weak self] action in - guard let self = self else { return } - self.moveToFirstRepliedStatus(action) - }), - UIAction(title: "First Reblog Status", image: nil, attributes: [], handler: { [weak self] action in - guard let self = self else { return } - self.moveToFirstReblogStatus(action) - }), - UIAction(title: "First Poll Status", image: nil, attributes: [], handler: { [weak self] action in - guard let self = self else { return } - self.moveToFirstPollStatus(action) - }), - UIAction(title: "First Audio Status", image: nil, attributes: [], handler: { [weak self] action in - guard let self = self else { return } - self.moveToFirstAudioStatus(action) - }), - UIAction(title: "First Video Status", image: nil, attributes: [], handler: { [weak self] action in - guard let self = self else { return } - self.moveToFirstVideoStatus(action) - }), - UIAction(title: "First GIF status", image: nil, attributes: [], handler: { [weak self] action in - guard let self = self else { return } - self.moveToFirstGIFStatus(action) - }), - ] - ) - } - var dropMenu: UIMenu { return UIMenu( title: "Drop…", @@ -156,19 +122,6 @@ extension HomeTimelineViewController { 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"), @@ -190,6 +143,12 @@ extension HomeTimelineViewController { identifier: nil, options: [], children: [ + UIAction(title: "Badge +1", image: UIImage(systemName: "app.badge.fill"), attributes: []) { [weak self] action 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: "Profile", image: UIImage(systemName: "person.badge.plus"), attributes: []) { [weak self] action in guard let self = self else { return } self.showNotification(action, notificationType: .follow) @@ -206,190 +165,130 @@ extension HomeTimelineViewController { extension HomeTimelineViewController { - @objc private func showFLEXAction(_ sender: UIAction) { - FLEXManager.shared.showExplorer() - } - - @objc private func moveToTopGapAction(_ sender: UIAction) { - guard let diffableDataSource = viewModel.diffableDataSource else { return } - let snapshotTransitioning = diffableDataSource.snapshot() - let item = snapshotTransitioning.itemIdentifiers.first(where: { item in - switch item { - case .homeMiddleLoader: return true - default: return false - } - }) - if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) { - tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) + enum MoveAction: String, CaseIterable { + case gap + case reply + case mention + case poll +// case quote +// case gif +// case video +// case location +// case followsYouAuthor +// case blockingAuthor + + var title: String { + return rawValue.capitalized } - } - - @objc private func moveToFirstReblogStatus(_ sender: UIAction) { - guard let diffableDataSource = viewModel.diffableDataSource else { return } - let snapshotTransitioning = diffableDataSource.snapshot() - let item = snapshotTransitioning.itemIdentifiers.first(where: { item in + + func match(item: StatusItem) -> Bool { + let authenticationBox = AppContext.shared.authenticationService.activeMastodonAuthenticationBox.value switch item { - case .homeTimelineIndex(let objectID, _): - let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex - return homeTimelineIndex.status.reblog != nil - default: - return false - } - }) - if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) { - tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) - tableView.blinkRow(at: IndexPath(row: index, section: 0)) - } else { - print("Not found reblog status") - } - } - - @objc private func moveToFirstPollStatus(_ sender: UIAction) { - guard let diffableDataSource = viewModel.diffableDataSource else { return } - let snapshotTransitioning = diffableDataSource.snapshot() - let item = snapshotTransitioning.itemIdentifiers.first(where: { item in - switch item { - case .homeTimelineIndex(let objectID, _): - let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex - let post = homeTimelineIndex.status.reblog ?? homeTimelineIndex.status - return post.poll != nil - default: - return false - } - }) - if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) { - tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) - tableView.blinkRow(at: IndexPath(row: index, section: 0)) - } else { - print("Not found poll status") - } - } - - @objc private func moveToFirstRepliedStatus(_ sender: UIAction) { - guard let diffableDataSource = viewModel.diffableDataSource else { return } - let snapshotTransitioning = diffableDataSource.snapshot() - let item = snapshotTransitioning.itemIdentifiers.first(where: { item in - switch item { - case .homeTimelineIndex(let objectID, _): - let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex - guard homeTimelineIndex.status.inReplyToID != nil else { + case .feed(let record): + guard let feed = record.object(in: AppContext.shared.managedObjectContext) else { return false } + if let status = feed.status { + switch self { + case .gap: + return false + case .reply: + return status.inReplyToID != nil + case .mention: + return !(status.reblog ?? status).mentions.isEmpty + case .poll: + return (status.reblog ?? status).poll != nil +// case .quote: +// return status.quote != nil +// case .gif: +// return status.attachments.contains(where: { attachment in attachment.kind == .animatedGIF }) +// case .video: +// return status.attachments.contains(where: { attachment in attachment.kind == .video }) +// case .location: +// return status.location != nil +// case .followsYouAuthor: +// guard case let .twitter(authenticationContext) = authenticationContext else { return false } +// guard let me = authenticationContext.authenticationRecord.object(in: AppContext.shared.managedObjectContext)?.user else { return false } +// return (status.repost ?? status).author.following.contains(me) +// case .blockingAuthor: +// guard case let .twitter(authenticationContext) = authenticationContext else { return false } +// guard let me = authenticationContext.authenticationRecord.object(in: AppContext.shared.managedObjectContext)?.user else { return false } +// return (status.repost ?? status).author.blockingBy.contains(me) +// default: +// return false + } // end switch + } else { return false } + case .feedLoader where self == .gap: return true default: return false } - }) - if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) { - tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) - tableView.blinkRow(at: IndexPath(row: index, section: 0)) - } else { - print("Not found replied status") + } + + func firstMatch(in items: [StatusItem]) -> StatusItem? { + return items.first { item in self.match(item: item) } } } - @objc private func moveToFirstAudioStatus(_ sender: UIAction) { - guard let diffableDataSource = viewModel.diffableDataSource else { return } - let snapshotTransitioning = diffableDataSource.snapshot() - let item = snapshotTransitioning.itemIdentifiers.first(where: { item in - switch item { - case .homeTimelineIndex(let objectID, _): - let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex - let status = homeTimelineIndex.status.reblog ?? homeTimelineIndex.status - return status.mediaAttachments?.contains(where: { $0.type == .audio }) ?? false - default: - return false - } - }) - if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) { - tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) - tableView.blinkRow(at: IndexPath(row: index, section: 0)) - } else { - print("Not found audio status") - } + var moveMenu: UIMenu { + return UIMenu( + title: "Move to…", + image: UIImage(systemName: "arrow.forward.circle"), + identifier: nil, + options: [], + children: + MoveAction.allCases.map { moveAction in + UIAction(title: "First \(moveAction.title)", image: nil, attributes: []) { [weak self] action in + guard let self = self else { return } + self.moveToFirst(action, moveAction: moveAction) + } + } + ) } - @objc private func moveToFirstVideoStatus(_ sender: UIAction) { + private func moveToFirst(_ sender: UIAction, moveAction: MoveAction) { guard let diffableDataSource = viewModel.diffableDataSource else { return } - let snapshotTransitioning = diffableDataSource.snapshot() - let item = snapshotTransitioning.itemIdentifiers.first(where: { item in - switch item { - case .homeTimelineIndex(let objectID, _): - let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex - let status = homeTimelineIndex.status.reblog ?? homeTimelineIndex.status - return status.mediaAttachments?.contains(where: { $0.type == .video }) ?? false - default: - return false - } - }) - if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) { - tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) - tableView.blinkRow(at: IndexPath(row: index, section: 0)) - } else { - print("Not found video status") - } + let snapshot = diffableDataSource.snapshot() + let items = snapshot.itemIdentifiers + guard let targetItem = moveAction.firstMatch(in: items), + let index = snapshot.indexOfItem(targetItem) + else { return } + let indexPath = IndexPath(row: index, section: 0) + tableView.scrollToRow(at: indexPath, at: .middle, animated: true) + tableView.blinkRow(at: indexPath) } - @objc private func moveToFirstGIFStatus(_ sender: UIAction) { - guard let diffableDataSource = viewModel.diffableDataSource else { return } - let snapshotTransitioning = diffableDataSource.snapshot() - let item = snapshotTransitioning.itemIdentifiers.first(where: { item in - switch item { - case .homeTimelineIndex(let objectID, _): - let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex - let status = homeTimelineIndex.status.reblog ?? homeTimelineIndex.status - return status.mediaAttachments?.contains(where: { $0.type == .gifv }) ?? false - default: - return false - } - }) - if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) { - tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) - tableView.blinkRow(at: IndexPath(row: index, section: 0)) - } else { - print("Not found GIF status") - } +} + +extension HomeTimelineViewController { + + @objc private func showFLEXAction(_ sender: UIAction) { + FLEXManager.shared.showExplorer() } @objc private func dropRecentStatusAction(_ sender: UIAction, count: Int) { guard let diffableDataSource = viewModel.diffableDataSource else { return } - let snapshotTransitioning = diffableDataSource.snapshot() + let snapshot = diffableDataSource.snapshot() - let droppingObjectIDs = snapshotTransitioning.itemIdentifiers.prefix(count).compactMap { item -> NSManagedObjectID? in + let feedRecords = snapshot.itemIdentifiers.prefix(count).compactMap { item -> ManagedObjectRecord<Feed>? in switch item { - case .homeTimelineIndex(let objectID, _): return objectID + case .feed(let record): return record default: return nil } } - var droppingStatusObjectIDs: [NSManagedObjectID] = [] - context.apiService.backgroundManagedObjectContext.performChanges { [weak self] in - guard let self = self else { return } - for objectID in droppingObjectIDs { - guard let homeTimelineIndex = try? self.context.apiService.backgroundManagedObjectContext.existingObject(with: objectID) as? HomeTimelineIndex else { continue } - droppingStatusObjectIDs.append(homeTimelineIndex.status.objectID) - self.context.apiService.backgroundManagedObjectContext.delete(homeTimelineIndex) - } - } - .sink { [weak self] result in - guard let self = self else { return } - switch result { - case .success: - self.context.apiService.backgroundManagedObjectContext.performChanges { [weak self] in - guard let self = self else { return } - for objectID in droppingStatusObjectIDs { - guard let post = try? self.context.apiService.backgroundManagedObjectContext.existingObject(with: objectID) as? Status else { continue } - self.context.apiService.backgroundManagedObjectContext.delete(post) + let managedObjectContext = viewModel.context.backgroundManagedObjectContext + Task { + try await managedObjectContext.performChanges { + for record in feedRecords { + guard let feed = record.object(in: managedObjectContext) else { continue } + let status = feed.status + managedObjectContext.delete(feed) + if let status = status { + managedObjectContext.delete(status) } - } - .sink { _ in - // do nothing - } - .store(in: &self.disposeBag) - case .failure(let error): - assertionFailure(error.localizedDescription) - } - } - .store(in: &disposeBag) + } // end for in + } // end managedObjectContext.performChanges + } // end Task } @objc private func showWelcomeAction(_ sender: UIAction) { @@ -405,10 +304,6 @@ extension HomeTimelineViewController { 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) - } - @objc private func showProfileAction(_ sender: UIAction) { let alertController = UIAlertController(title: "Enter User ID", message: nil, preferredStyle: .alert) alertController.addTextField() @@ -453,7 +348,7 @@ extension HomeTimelineViewController { else { return } let pushNotification = MastodonPushNotification( - _accessToken: authenticationBox.userAuthorization.accessToken, + accessToken: authenticationBox.userAuthorization.accessToken, notificationID: notificationID, notificationType: notificationType.rawValue, preferredLocale: nil, @@ -477,7 +372,7 @@ extension HomeTimelineViewController { else { return } let pushNotification = MastodonPushNotification( - _accessToken: accessToken, + accessToken: accessToken, notificationID: notificationID, notificationType: notificationType.rawValue, preferredLocale: nil, diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+Provider.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+Provider.swift deleted file mode 100644 index 83022f5d7..000000000 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+Provider.swift +++ /dev/null @@ -1,95 +0,0 @@ -// -// HomeTimelineViewController+Provider.swift -// Mastodon -// -// Created by sxiaojian on 2021/2/5. -// - -import os.log -import UIKit -import Combine -import CoreData -import CoreDataStack - -// MARK: - StatusProvider -extension HomeTimelineViewController: StatusProvider { - - func status() -> Future<Status?, Never> { - return Future { promise in promise(.success(nil)) } - } - - func status(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future<Status?, Never> { - return Future { promise in - 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 .homeTimelineIndex(let objectID, _): - let managedObjectContext = self.viewModel.fetchedResultsController.managedObjectContext - managedObjectContext.perform { - let timelineIndex = managedObjectContext.object(with: objectID) as? HomeTimelineIndex - promise(.success(timelineIndex?.status)) - } - default: - promise(.success(nil)) - } - } - } - - func status(for cell: UICollectionViewCell) -> Future<Status?, Never> { - return Future { promise in promise(.success(nil)) } - } - - var managedObjectContext: NSManagedObjectContext { - return viewModel.fetchedResultsController.managedObjectContext - } - - var tableViewDiffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>? { - return viewModel.diffableDataSource - } - - func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item? { - guard let diffableDataSource = self.viewModel.diffableDataSource else { - assertionFailure() - return nil - } - - guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }), - let item = diffableDataSource.itemIdentifier(for: indexPath) else { - return nil - } - - return item - } - - func items(indexPaths: [IndexPath]) -> [Item] { - guard let diffableDataSource = self.viewModel.diffableDataSource else { - assertionFailure() - return [] - } - - var items: [Item] = [] - for indexPath in indexPaths { - guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { continue } - items.append(item) - } - return items - } - - func statusObjectItems(indexPaths: [IndexPath]) -> [StatusObjectItem] { - guard let diffableDataSource = self.viewModel.diffableDataSource else { return [] } - let items = indexPaths.compactMap { diffableDataSource.itemIdentifier(for: $0)?.statusObjectItem } - return items - } - -} - -extension HomeTimelineViewController: UserProvider {} diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index 62695f211..7b7f35e5d 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -15,6 +15,8 @@ import GameplayKit import MastodonSDK import AlamofireImage import StoreKit +import MastodonAsset +import MastodonLocalization final class HomeTimelineViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { @@ -49,6 +51,7 @@ final class HomeTimelineViewController: UIViewController, NeedsDependency, Media let barButtonItem = UIBarButtonItem() barButtonItem.tintColor = ThemeService.tintColor barButtonItem.image = UIImage(systemName: "gear")?.withRenderingMode(.alwaysTemplate) + barButtonItem.accessibilityLabel = L10n.Common.Controls.Actions.settings return barButtonItem }() @@ -56,6 +59,7 @@ final class HomeTimelineViewController: UIViewController, NeedsDependency, Media let barButtonItem = UIBarButtonItem() barButtonItem.tintColor = ThemeService.tintColor barButtonItem.image = UIImage(systemName: "square.and.pencil")?.withRenderingMode(.alwaysTemplate) + barButtonItem.accessibilityLabel = L10n.Common.Controls.Actions.compose return barButtonItem }() @@ -98,7 +102,7 @@ extension HomeTimelineViewController { self.view.backgroundColor = theme.secondarySystemBackgroundColor } .store(in: &disposeBag) - viewModel.displaySettingBarButtonItem + viewModel.$displaySettingBarButtonItem .receive(on: DispatchQueue.main) .sink { [weak self] displaySettingBarButtonItem in guard let self = self else { return } @@ -123,7 +127,12 @@ extension HomeTimelineViewController { settingBarButtonItem.action = #selector(HomeTimelineViewController.settingBarButtonItemPressed(_:)) #endif - viewModel.displayComposeBarButtonItem + #if SNAPSHOT + titleView.logoButton.menu = self.debugMenu + titleView.button.menu = self.debugMenu + #endif + + viewModel.$displayComposeBarButtonItem .receive(on: DispatchQueue.main) .sink { [weak self] displayComposeBarButtonItem in guard let self = self else { return } @@ -181,27 +190,33 @@ extension HomeTimelineViewController { ]) viewModel.tableView = tableView - viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self tableView.delegate = self - tableView.prefetchDataSource = self viewModel.setupDiffableDataSource( - for: tableView, - dependency: self, + tableView: tableView, statusTableViewCellDelegate: self, timelineMiddleLoaderTableViewCellDelegate: self ) - - // bind refresh control - viewModel.isFetchingLatestTimeline + + // setup batch fetch + viewModel.listBatchFetchViewModel.setup(scrollView: tableView) + viewModel.listBatchFetchViewModel.shouldFetch .receive(on: DispatchQueue.main) - .sink { [weak self] isFetching in + .sink { [weak self] _ in guard let self = self else { return } - if !isFetching { - UIView.animate(withDuration: 0.5) { [weak self] in - guard let self = self else { return } - self.refreshControl.endRefreshing() - } completion: { _ in } - } + guard self.view.window != nil else { return } + self.viewModel.loadOldestStateMachine.enter(HomeTimelineViewModel.LoadOldestState.Loading.self) + } + .store(in: &disposeBag) + + // bind refresh control + viewModel.didLoadLatest + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + UIView.animate(withDuration: 0.5) { [weak self] in + guard let self = self else { return } + self.refreshControl.endRefreshing() + } completion: { _ in } } .store(in: &disposeBag) @@ -272,10 +287,11 @@ extension HomeTimelineViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - aspectViewWillAppear(animated) + refreshControl.endRefreshing() + tableView.deselectRow(with: transitionCoordinator, animated: animated) // needs trigger manually after onboarding dismiss - setNeedsStatusBarAppearanceUpdate() + setNeedsStatusBarAppearanceUpdate() } override func viewDidAppear(_ animated: Bool) { @@ -283,10 +299,10 @@ extension HomeTimelineViewController { viewModel.viewDidAppear.send() - if let timestamp = viewModel.lastAutomaticFetchTimestamp.value { + if let timestamp = viewModel.lastAutomaticFetchTimestamp { let now = Date() if now.timeIntervalSince(timestamp) > 60 { - self.viewModel.lastAutomaticFetchTimestamp.value = now + self.viewModel.lastAutomaticFetchTimestamp = now self.viewModel.homeTimelineNeedRefresh.send() } else { // do nothing @@ -295,12 +311,6 @@ extension HomeTimelineViewController { self.viewModel.homeTimelineNeedRefresh.send() } } - - override func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - - aspectViewDidDisappear(animated) - } override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { super.viewWillTransition(to: size, with: coordinator) @@ -379,9 +389,13 @@ extension HomeTimelineViewController { extension HomeTimelineViewController { @objc private func findPeopleButtonPressed(_ sender: PrimaryActionButton) { - let viewModel = SuggestionAccountViewModel(context: context) - viewModel.delegate = self.viewModel - coordinator.present(scene: .suggestionAccount(viewModel: viewModel), from: self, transition: .modal(animated: true, completion: nil)) + let suggestionAccountViewModel = SuggestionAccountViewModel(context: context) + suggestionAccountViewModel.delegate = viewModel + coordinator.present( + scene: .suggestionAccount(viewModel: suggestionAccountViewModel), + from: self, + transition: .modal(animated: true, completion: nil) + ) } @objc private func manuallySearchButtonPressed(_ sender: UIButton) { @@ -399,7 +413,12 @@ extension HomeTimelineViewController { @objc private func composeBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - let composeViewModel = ComposeViewModel(context: context, composeKind: .post) + guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } + let composeViewModel = ComposeViewModel( + context: context, + composeKind: .post, + authenticationBox: authenticationBox + ) coordinator.present(scene: .compose(viewModel: composeViewModel), from: self, transition: .modal(animated: true, completion: nil)) } @@ -411,45 +430,23 @@ extension HomeTimelineViewController { } @objc func signOutAction(_ sender: UIAction) { - guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { + guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } - context.authenticationService.signOutMastodonUser( - domain: activeMastodonAuthenticationBox.domain, - userID: activeMastodonAuthenticationBox.userID - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] result in - guard let self = self else { return } - switch result { - case .failure(let error): - assertionFailure(error.localizedDescription) - case .success(let isSignOut): - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: sign out %s", ((#file as NSString).lastPathComponent), #line, #function, isSignOut ? "success" : "fail") - guard isSignOut else { return } - self.coordinator.setup() - self.coordinator.setupOnboardingIfNeeds(animated: true) - } + Task { @MainActor in + try await context.authenticationService.signOutMastodonUser(authenticationBox: authenticationBox) + self.coordinator.setup() + self.coordinator.setupOnboardingIfNeeds(animated: true) } - .store(in: &disposeBag) } } - -// MARK: - StatusTableViewControllerAspect -extension HomeTimelineViewController: StatusTableViewControllerAspect { } - -extension HomeTimelineViewController: TableViewCellHeightCacheableContainer { - var cellFrameCache: NSCache<NSNumber, NSValue> { return viewModel.cellFrameCache } -} - // MARK: - UIScrollViewDelegate extension HomeTimelineViewController { func scrollViewDidScroll(_ scrollView: UIScrollView) { switch scrollView { case tableView: - aspectScrollViewDidScroll(scrollView) viewModel.homeTimelineNavigationBarTitleViewModel.handleScrollViewDidScroll(scrollView) default: break @@ -478,7 +475,7 @@ extension HomeTimelineViewController { 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 { + if let record = viewModel.scrollPositionRecord { let now = Date() guard now.timeIntervalSince(record.timestamp) > 0.5 else { // skip this save action @@ -498,7 +495,7 @@ extension HomeTimelineViewController { 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( + viewModel.scrollPositionRecord = HomeTimelineViewModel.ScrollPositionRecord( item: anchorItem, offset: offset, timestamp: Date() @@ -514,45 +511,29 @@ extension HomeTimelineViewController { private func restorePositionWhenScrollToTop() { guard let diffableDataSource = self.viewModel.diffableDataSource else { return } - guard let record = self.viewModel.scrollPositionRecord.value, + guard let record = self.viewModel.scrollPositionRecord, let indexPath = diffableDataSource.indexPath(for: record.item) else { return } - self.tableView.scrollToRow(at: indexPath, at: .middle, animated: true) - self.viewModel.scrollPositionRecord.value = nil + tableView.scrollToRow(at: indexPath, at: .middle, animated: true) + viewModel.scrollPositionRecord = nil } } -extension HomeTimelineViewController: LoadMoreConfigurableTableViewContainer { - typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell - typealias LoadingState = HomeTimelineViewModel.LoadOldestState.Loading - var loadMoreConfigurableTableView: UITableView { return tableView } - var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.loadOldestStateMachine } -} - // MARK: - UITableViewDelegate -extension HomeTimelineViewController: UITableViewDelegate { - - func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { - aspectTableView(tableView, estimatedHeightForRowAt: indexPath) - } - - func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { - aspectTableView(tableView, willDisplay: cell, forRowAt: indexPath) - } - - func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { - aspectTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) - } - +extension HomeTimelineViewController: UITableViewDelegate, AutoGenerateTableViewDelegate { + // sourcery:inline:HomeTimelineViewController.AutoGenerateTableViewDelegate + + // Generated using Sourcery + // DO NOT EDIT func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { aspectTableView(tableView, didSelectRowAt: 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) } @@ -560,90 +541,23 @@ extension HomeTimelineViewController: UITableViewDelegate { 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) } - -} -// MARK: - UITableViewDataSourcePrefetching -extension HomeTimelineViewController: UITableViewDataSourcePrefetching { - func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { - aspectTableView(tableView, prefetchRowsAt: indexPaths) - } - - func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) { - aspectTableView(tableView, cancelPrefetchingForRowsAt: indexPaths) - } -} - -// MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate -extension HomeTimelineViewController: ContentOffsetAdjustableTimelineViewControllerDelegate { - func navigationBar() -> UINavigationBar? { - return navigationController?.navigationBar - } + // sourcery:end } // MARK: - TimelineMiddleLoaderTableViewCellDelegate extension HomeTimelineViewController: TimelineMiddleLoaderTableViewCellDelegate { - func configure(cell: TimelineMiddleLoaderTableViewCell, upperTimelineStatusID: String?, timelineIndexobjectID: NSManagedObjectID?) { - guard let upperTimelineIndexObjectID = timelineIndexobjectID else { - return - } - viewModel.loadMiddleSateMachineList - .receive(on: DispatchQueue.main) - .sink { [weak self] ids in - guard let _ = self else { return } - if let stateMachine = ids[upperTimelineIndexObjectID] { - guard let state = stateMachine.currentState else { - assertionFailure() - return - } - - // make success state same as loading due to snapshot updating delay - let isLoading = state is HomeTimelineViewModel.LoadMiddleState.Loading || state is HomeTimelineViewModel.LoadMiddleState.Success - if isLoading { - cell.startAnimating() - } else { - cell.stopAnimating() - } - } else { - cell.stopAnimating() - } - } - .store(in: &cell.disposeBag) - - var dict = viewModel.loadMiddleSateMachineList.value - if let _ = dict[upperTimelineIndexObjectID] { - // do nothing - } else { - let stateMachine = GKStateMachine(states: [ - HomeTimelineViewModel.LoadMiddleState.Initial(viewModel: viewModel, upperTimelineIndexObjectID: upperTimelineIndexObjectID), - HomeTimelineViewModel.LoadMiddleState.Loading(viewModel: viewModel, upperTimelineIndexObjectID: upperTimelineIndexObjectID), - HomeTimelineViewModel.LoadMiddleState.Fail(viewModel: viewModel, upperTimelineIndexObjectID: upperTimelineIndexObjectID), - HomeTimelineViewModel.LoadMiddleState.Success(viewModel: viewModel, upperTimelineIndexObjectID: upperTimelineIndexObjectID), - ]) - stateMachine.enter(HomeTimelineViewModel.LoadMiddleState.Initial.self) - dict[upperTimelineIndexObjectID] = stateMachine - viewModel.loadMiddleSateMachineList.value = dict - } - } - func timelineMiddleLoaderTableViewCell(_ cell: TimelineMiddleLoaderTableViewCell, loadMoreButtonDidPressed button: UIButton) { guard let diffableDataSource = viewModel.diffableDataSource else { return } guard let indexPath = tableView.indexPath(for: cell) else { return } guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } - - switch item { - case .homeMiddleLoader(let upper): - guard let stateMachine = viewModel.loadMiddleSateMachineList.value[upper] else { - assertionFailure() - return - } - stateMachine.enter(HomeTimelineViewModel.LoadMiddleState.Loading.self) - default: - assertionFailure() + + Task { + await viewModel.loadMore(item: item) } } } @@ -651,9 +565,13 @@ extension HomeTimelineViewController: TimelineMiddleLoaderTableViewCellDelegate // MARK: - ScrollViewContainer extension HomeTimelineViewController: ScrollViewContainer { - var scrollView: UIScrollView { return tableView } + var scrollView: UIScrollView? { return tableView } func scrollToTop(animated: Bool) { + guard let scrollView = scrollView else { + return + } + if scrollView.contentOffset.y < scrollView.frame.height, viewModel.loadLatestStateMachine.canEnterState(HomeTimelineViewModel.LoadLatestState.Loading.self), (scrollView.contentOffset.y + scrollView.adjustedContentInset.top) == 0.0, @@ -675,24 +593,8 @@ extension HomeTimelineViewController: ScrollViewContainer { } -// MARK: - AVPlayerViewControllerDelegate -extension HomeTimelineViewController: 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 HomeTimelineViewController: StatusTableViewCellDelegate { - weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self } - func parent() -> UIViewController { return self } -} +extension HomeTimelineViewController: StatusTableViewCellDelegate { } // MARK: - HomeTimelineNavigationBarTitleViewDelegate extension HomeTimelineViewController: HomeTimelineNavigationBarTitleViewDelegate { @@ -736,7 +638,7 @@ extension HomeTimelineViewController: StatusTableViewControllerNavigateable { @objc func navigateKeyCommandHandlerRelay(_ sender: UIKeyCommand) { navigateKeyCommandHandler(sender) } - + @objc func statusKeyCommandHandlerRelay(_ sender: UIKeyCommand) { statusKeyCommandHandler(sender) } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift index e87cab1c1..756a4b608 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift @@ -13,155 +13,303 @@ import CoreDataStack extension HomeTimelineViewModel { func setupDiffableDataSource( - for tableView: UITableView, - dependency: NeedsDependency, + tableView: UITableView, statusTableViewCellDelegate: StatusTableViewCellDelegate, timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate ) { - diffableDataSource = StatusSection.tableViewDiffableDataSource( - for: tableView, - timelineContext: .home, - dependency: dependency, - managedObjectContext: fetchedResultsController.managedObjectContext, - statusTableViewCellDelegate: statusTableViewCellDelegate, - timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate, - threadReplyLoaderTableViewCellDelegate: nil + diffableDataSource = StatusSection.diffableDataSource( + tableView: tableView, + context: context, + configuration: StatusSection.Configuration( + statusTableViewCellDelegate: statusTableViewCellDelegate, + timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate, + filterContext: .home, + activeFilters: context.statusFilterService.$activeFilters + ) ) // make initial snapshot animation smooth - var snapshot = NSDiffableDataSourceSnapshot<StatusSection, Item>() + var snapshot = NSDiffableDataSourceSnapshot<StatusSection, StatusItem>() snapshot.appendSections([.main]) diffableDataSource?.apply(snapshot) + + fetchedResultsController.$records + .receive(on: DispatchQueue.main) + .sink { [weak self] records in + guard let self = self else { return } + guard let diffableDataSource = self.diffableDataSource else { return } + self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): incoming \(records.count) objects") + Task { + let start = CACurrentMediaTime() + defer { + let end = CACurrentMediaTime() + self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): cost \(end - start, format: .fixed(precision: 4))s to process \(records.count) feeds") + } + let oldSnapshot = diffableDataSource.snapshot() + var newSnapshot: NSDiffableDataSourceSnapshot<StatusSection, StatusItem> = { + let newItems = records.map { record in + StatusItem.feed(record: record) + } + var snapshot = NSDiffableDataSourceSnapshot<StatusSection, StatusItem>() + snapshot.appendSections([.main]) + snapshot.appendItems(newItems, toSection: .main) + return snapshot + }() + + let parentManagedObjectContext = self.context.managedObjectContext + let managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) + managedObjectContext.parent = parentManagedObjectContext + try? await managedObjectContext.perform { + let anchors: [Feed] = { + let request = Feed.sortedFetchRequest + request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ + Feed.hasMorePredicate(), + self.fetchedResultsController.predicate, + ]) + do { + return try managedObjectContext.fetch(request) + } catch { + assertionFailure(error.localizedDescription) + return [] + } + }() + + let itemIdentifiers = newSnapshot.itemIdentifiers + for (index, item) in itemIdentifiers.enumerated() { + guard case let .feed(record) = item else { continue } + guard anchors.contains(where: { feed in feed.objectID == record.objectID }) else { continue } + let isLast = index + 1 == itemIdentifiers.count + if isLast { + newSnapshot.insertItems([.bottomLoader], afterItem: item) + } else { + newSnapshot.insertItems([.feedLoader(record: record)], afterItem: item) + } + } + } + + let hasChanges = newSnapshot.itemIdentifiers != oldSnapshot.itemIdentifiers + if !hasChanges { + self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): snapshot not changes") + self.didLoadLatest.send() + return + } else { + self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): snapshot has changes") + } + + guard let difference = await self.calculateReloadSnapshotDifference( + tableView: tableView, + oldSnapshot: oldSnapshot, + newSnapshot: newSnapshot + ) else { + await self.updateSnapshotUsingReloadData(snapshot: newSnapshot) + self.didLoadLatest.send() + self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): applied new snapshot") + return + } + + await self.updateSnapshotUsingReloadData(snapshot: newSnapshot) + await tableView.scrollToRow(at: difference.targetIndexPath, at: .top, animated: false) + var contentOffset = await tableView.contentOffset + contentOffset.y = await tableView.contentOffset.y - difference.sourceDistanceToTableViewTopEdge + await tableView.setContentOffset(contentOffset, animated: false) + self.didLoadLatest.send() + self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): applied new snapshot") + } // end Task + } + .store(in: &disposeBag) } } -// MARK: - NSFetchedResultsControllerDelegate -extension HomeTimelineViewModel: NSFetchedResultsControllerDelegate { - - func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) { - os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - } - - func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { - os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - - guard let tableView = self.tableView else { return } - guard let navigationBar = self.contentOffsetAdjustableTimelineViewControllerDelegate?.navigationBar() else { return } - - guard let diffableDataSource = self.diffableDataSource else { return } - let oldSnapshot = diffableDataSource.snapshot() - - let predicate = fetchedResultsController.fetchRequest.predicate - let parentManagedObjectContext = fetchedResultsController.managedObjectContext - let managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) - managedObjectContext.parent = parentManagedObjectContext - - managedObjectContext.perform { - var shouldAddBottomLoader = false - - let timelineIndexes: [HomeTimelineIndex] = { - let request = HomeTimelineIndex.sortedFetchRequest - request.returnsObjectsAsFaults = false - request.predicate = predicate - do { - return try managedObjectContext.fetch(request) - } catch { - assertionFailure(error.localizedDescription) - return [] - } - }() - - // that's will be the most fastest fetch because of upstream just update and no modify needs consider - - var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:] - - for item in oldSnapshot.itemIdentifiers { - guard case let .homeTimelineIndex(objectID, attribute) = item else { continue } - oldSnapshotAttributeDict[objectID] = attribute - } - - var newTimelineItems: [Item] = [] - for (i, timelineIndex) in timelineIndexes.enumerated() { - let attribute = oldSnapshotAttributeDict[timelineIndex.objectID] ?? Item.StatusAttribute() - attribute.isSeparatorLineHidden = false - - // append new item into snapshot - newTimelineItems.append(.homeTimelineIndex(objectID: timelineIndex.objectID, attribute: attribute)) - - let isLast = i == timelineIndexes.count - 1 - switch (isLast, timelineIndex.hasMore) { - case (false, true): - newTimelineItems.append(.homeMiddleLoader(upperTimelineIndexAnchorObjectID: timelineIndex.objectID)) - attribute.isSeparatorLineHidden = true - case (true, true): - shouldAddBottomLoader = true - default: - break - } - } // end for - - var newSnapshot = NSDiffableDataSourceSnapshot<StatusSection, Item>() - newSnapshot.appendSections([.main]) - newSnapshot.appendItems(newTimelineItems, toSection: .main) - - let endSnapshot = CACurrentMediaTime() - - DispatchQueue.main.async { - if shouldAddBottomLoader, !(self.loadLatestStateMachine.currentState is LoadOldestState.NoMore) { - newSnapshot.appendItems([.bottomLoader], toSection: .main) - } - - guard let difference = self.calculateReloadSnapshotDifference(navigationBar: navigationBar, tableView: tableView, oldSnapshot: oldSnapshot, newSnapshot: newSnapshot) else { - diffableDataSource.apply(newSnapshot) - self.isFetchingLatestTimeline.value = false - return - } - - 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 - } - - let end = CACurrentMediaTime() - os_log("%{public}s[%{public}ld], %{public}s: calculate home timeline layout cost %.2fs", ((#file as NSString).lastPathComponent), #line, #function, end - endSnapshot) - } - } // end perform +extension HomeTimelineViewModel { + + @MainActor func updateDataSource( + snapshot: NSDiffableDataSourceSnapshot<StatusSection, StatusItem>, + animatingDifferences: Bool + ) async { + diffableDataSource?.apply(snapshot, animatingDifferences: animatingDifferences) } - private struct Difference<T> { + @MainActor func updateSnapshotUsingReloadData( + snapshot: NSDiffableDataSourceSnapshot<StatusSection, StatusItem> + ) async { + if #available(iOS 15.0, *) { + await self.diffableDataSource?.applySnapshotUsingReloadData(snapshot) + } else { + diffableDataSource?.applySnapshot(snapshot, animated: false, completion: nil) + } + } + + struct Difference<T> { let item: T let sourceIndexPath: IndexPath + let sourceDistanceToTableViewTopEdge: CGFloat let targetIndexPath: IndexPath - let offset: CGFloat } - - private func calculateReloadSnapshotDifference<T: Hashable>( - navigationBar: UINavigationBar, + + @MainActor private func calculateReloadSnapshotDifference<S: Hashable, T: Hashable>( tableView: UITableView, - oldSnapshot: NSDiffableDataSourceSnapshot<StatusSection, T>, - newSnapshot: NSDiffableDataSourceSnapshot<StatusSection, T> + oldSnapshot: NSDiffableDataSourceSnapshot<S, T>, + newSnapshot: NSDiffableDataSourceSnapshot<S, T> ) -> Difference<T>? { - guard oldSnapshot.numberOfItems != 0 else { return nil } + guard let sourceIndexPath = (tableView.indexPathsForVisibleRows ?? []).sorted().first else { return nil } + let rectForSourceItemCell = tableView.rectForRow(at: sourceIndexPath) + let sourceDistanceToTableViewTopEdge = tableView.convert(rectForSourceItemCell, to: nil).origin.y - tableView.safeAreaInsets.top - // old snapshot not empty. set source index path to first item if not match - let sourceIndexPath = UIViewController.topVisibleTableViewCellIndexPath(in: tableView, navigationBar: navigationBar) ?? IndexPath(row: 0, section: 0) + guard sourceIndexPath.section < oldSnapshot.numberOfSections, + sourceIndexPath.row < oldSnapshot.numberOfItems(inSection: oldSnapshot.sectionIdentifiers[sourceIndexPath.section]) + else { return nil } - guard sourceIndexPath.row < oldSnapshot.itemIdentifiers(inSection: .main).count else { return nil } + let sectionIdentifier = oldSnapshot.sectionIdentifiers[sourceIndexPath.section] + let item = oldSnapshot.itemIdentifiers(inSection: sectionIdentifier)[sourceIndexPath.row] - let timelineItem = oldSnapshot.itemIdentifiers(inSection: .main)[sourceIndexPath.row] - guard let itemIndex = newSnapshot.itemIdentifiers(inSection: .main).firstIndex(of: timelineItem) else { return nil } - let targetIndexPath = IndexPath(row: itemIndex, section: 0) + guard let targetIndexPathRow = newSnapshot.indexOfItem(item), + let newSectionIdentifier = newSnapshot.sectionIdentifier(containingItem: item), + let targetIndexPathSection = newSnapshot.indexOfSection(newSectionIdentifier) + else { return nil } + + let targetIndexPath = IndexPath(row: targetIndexPathRow, section: targetIndexPathSection) - let offset = UIViewController.tableViewCellOriginOffsetToWindowTop(in: tableView, at: sourceIndexPath, navigationBar: navigationBar) return Difference( - item: timelineItem, + item: item, sourceIndexPath: sourceIndexPath, - targetIndexPath: targetIndexPath, - offset: offset + sourceDistanceToTableViewTopEdge: sourceDistanceToTableViewTopEdge, + targetIndexPath: targetIndexPath ) } } + + + + +//// MARK: - NSFetchedResultsControllerDelegate +//extension HomeTimelineViewModel: NSFetchedResultsControllerDelegate { +// +// func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) { +// os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) +// } +// +// func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { +// os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) +// +// guard let tableView = self.tableView else { return } +// guard let navigationBar = self.contentOffsetAdjustableTimelineViewControllerDelegate?.navigationBar() else { return } +// +// guard let diffableDataSource = self.diffableDataSource else { return } +// let oldSnapshot = diffableDataSource.snapshot() +// +// let predicate = fetchedResultsController.fetchRequest.predicate +// let parentManagedObjectContext = fetchedResultsController.managedObjectContext +// let managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) +// managedObjectContext.parent = parentManagedObjectContext +// +// managedObjectContext.perform { +// var shouldAddBottomLoader = false +// +// let timelineIndexes: [HomeTimelineIndex] = { +// let request = HomeTimelineIndex.sortedFetchRequest +// request.returnsObjectsAsFaults = false +// request.predicate = predicate +// do { +// return try managedObjectContext.fetch(request) +// } catch { +// assertionFailure(error.localizedDescription) +// return [] +// } +// }() +// +// // that's will be the most fastest fetch because of upstream just update and no modify needs consider +// +// var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:] +// +// for item in oldSnapshot.itemIdentifiers { +// guard case let .homeTimelineIndex(objectID, attribute) = item else { continue } +// oldSnapshotAttributeDict[objectID] = attribute +// } +// +// var newTimelineItems: [Item] = [] +// +// for (i, timelineIndex) in timelineIndexes.enumerated() { +// let attribute = oldSnapshotAttributeDict[timelineIndex.objectID] ?? Item.StatusAttribute() +// attribute.isSeparatorLineHidden = false +// +// // append new item into snapshot +// newTimelineItems.append(.homeTimelineIndex(objectID: timelineIndex.objectID, attribute: attribute)) +// +// let isLast = i == timelineIndexes.count - 1 +// switch (isLast, timelineIndex.hasMore) { +// case (false, true): +// newTimelineItems.append(.homeMiddleLoader(upperTimelineIndexAnchorObjectID: timelineIndex.objectID)) +// attribute.isSeparatorLineHidden = true +// case (true, true): +// shouldAddBottomLoader = true +// default: +// break +// } +// } // end for +// +// var newSnapshot = NSDiffableDataSourceSnapshot<StatusSection, Item>() +// newSnapshot.appendSections([.main]) +// newSnapshot.appendItems(newTimelineItems, toSection: .main) +// +// let endSnapshot = CACurrentMediaTime() +// +// DispatchQueue.main.async { +// if shouldAddBottomLoader, !(self.loadLatestStateMachine.currentState is LoadOldestState.NoMore) { +// newSnapshot.appendItems([.bottomLoader], toSection: .main) +// } +// +// guard let difference = self.calculateReloadSnapshotDifference(navigationBar: navigationBar, tableView: tableView, oldSnapshot: oldSnapshot, newSnapshot: newSnapshot) else { +// diffableDataSource.apply(newSnapshot) +// self.isFetchingLatestTimeline.value = false +// return +// } +// +// 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 +// } +// +// let end = CACurrentMediaTime() +// os_log("%{public}s[%{public}ld], %{public}s: calculate home timeline layout cost %.2fs", ((#file as NSString).lastPathComponent), #line, #function, end - endSnapshot) +// } +// } // end perform +// } +// +// private struct Difference<T> { +// let item: T +// let sourceIndexPath: IndexPath +// let targetIndexPath: IndexPath +// let offset: CGFloat +// } +// +// private func calculateReloadSnapshotDifference<T: Hashable>( +// navigationBar: UINavigationBar, +// tableView: UITableView, +// oldSnapshot: NSDiffableDataSourceSnapshot<StatusSection, T>, +// newSnapshot: NSDiffableDataSourceSnapshot<StatusSection, T> +// ) -> Difference<T>? { +// guard oldSnapshot.numberOfItems != 0 else { return nil } +// +// // old snapshot not empty. set source index path to first item if not match +// let sourceIndexPath = UIViewController.topVisibleTableViewCellIndexPath(in: tableView, navigationBar: navigationBar) ?? IndexPath(row: 0, section: 0) +// +// guard sourceIndexPath.row < oldSnapshot.itemIdentifiers(inSection: .main).count else { return nil } +// +// let timelineItem = oldSnapshot.itemIdentifiers(inSection: .main)[sourceIndexPath.row] +// guard let itemIndex = newSnapshot.itemIdentifiers(inSection: .main).firstIndex(of: timelineItem) else { return nil } +// let targetIndexPath = IndexPath(row: itemIndex, section: 0) +// +// let offset = UIViewController.tableViewCellOriginOffsetToWindowTop(in: tableView, at: sourceIndexPath, navigationBar: navigationBar) +// return Difference( +// item: timelineItem, +// sourceIndexPath: sourceIndexPath, +// targetIndexPath: targetIndexPath, +// offset: offset +// ) +// } +// +//} diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift index 425eb9aa0..3e46c2af4 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift @@ -14,6 +14,15 @@ import GameplayKit extension HomeTimelineViewModel { class LoadLatestState: GKState { + + let logger = Logger(subsystem: "HomeTimelineViewModel.LoadLatestState", category: "StateMachine") + + let id = UUID() + + var name: String { + String(describing: Self.self) + } + weak var viewModel: HomeTimelineViewModel? init(viewModel: HomeTimelineViewModel) { @@ -21,9 +30,20 @@ extension HomeTimelineViewModel { } 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) + super.didEnter(from: previousState) + let previousState = previousState as? HomeTimelineViewModel.LoadLatestState + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [\(self.id.uuidString)] enter \(self.name), previous: \(previousState?.name ?? "<nil>")") viewModel?.loadLatestStateMachinePublisher.send(self) } + + @MainActor + func enter(state: LoadLatestState.Type) { + stateMachine?.enter(state) + } + + deinit { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [\(self.id.uuidString)] \(self.name)") + } } } @@ -48,69 +68,49 @@ extension HomeTimelineViewModel.LoadLatestState { return } - let predicate = viewModel.fetchedResultsController.fetchRequest.predicate - let parentManagedObjectContext = viewModel.fetchedResultsController.managedObjectContext + let latestFeedRecords = viewModel.fetchedResultsController.records.prefix(APIService.onceRequestStatusMaxCount) + let parentManagedObjectContext = viewModel.fetchedResultsController.fetchedResultsController.managedObjectContext let managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) managedObjectContext.parent = parentManagedObjectContext - managedObjectContext.perform { + Task { let start = CACurrentMediaTime() - let latestStatusIDs: [Status.ID] - let request = HomeTimelineIndex.sortedFetchRequest - request.returnsObjectsAsFaults = false - request.predicate = predicate - - do { - let timelineIndexes = try managedObjectContext.fetch(request) - let endFetch = CACurrentMediaTime() - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: collect timelineIndexes cost: %.2fs", ((#file as NSString).lastPathComponent), #line, #function, endFetch - start) - latestStatusIDs = timelineIndexes - .prefix(APIService.onceRequestStatusMaxCount) // avoid performance issue - .compactMap { timelineIndex in - timelineIndex.value(forKeyPath: #keyPath(HomeTimelineIndex.status.id)) as? Status.ID - } - } catch { - stateMachine.enter(Fail.self) - return + let latestStatusIDs: [Status.ID] = latestFeedRecords.compactMap { record in + guard let feed = record.object(in: managedObjectContext) else { return nil } + return feed.status?.id } - let end = CACurrentMediaTime() os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: collect statuses id cost: %.2fs", ((#file as NSString).lastPathComponent), #line, #function, end - start) - - // TODO: only set large count when using Wi-Fi - viewModel.context.apiService.homeTimeline(domain: activeMastodonAuthenticationBox.domain, authorizationBox: activeMastodonAuthenticationBox) - .receive(on: DispatchQueue.main) - .sink { completion in - viewModel.homeTimelineNavigationBarTitleViewModel.receiveLoadingStateCompletion(completion) - switch completion { - case .failure(let error): - // TODO: handle error - viewModel.isFetchingLatestTimeline.value = false - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch statuses failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) - case .finished: - // handle isFetchingLatestTimeline in fetch controller delegate - break + + do { + let response = try await viewModel.context.apiService.homeTimeline( + authenticationBox: activeMastodonAuthenticationBox + ) + + await enter(state: Idle.self) + viewModel.homeTimelineNavigationBarTitleViewModel.receiveLoadingStateCompletion(.finished) + + // stop refresher if no new statuses + let statuses = response.value + let newStatuses = statuses.filter { !latestStatusIDs.contains($0.id) } + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): load \(newStatuses.count) new statuses") + + if newStatuses.isEmpty { + viewModel.didLoadLatest.send() + } else { + if !latestStatusIDs.isEmpty { + viewModel.homeTimelineNavigationBarTitleViewModel.newPostsIncoming() } - - stateMachine.enter(Idle.self) - - } receiveValue: { response in - // stop refresher if no new statuses - let statuses = response.value - let newStatuses = statuses.filter { !latestStatusIDs.contains($0.id) } - os_log("%{public}s[%{public}ld], %{public}s: load %{public}ld new statuses", ((#file as NSString).lastPathComponent), #line, #function, newStatuses.count) - - if newStatuses.isEmpty { - viewModel.isFetchingLatestTimeline.value = false - } else { - if !latestStatusIDs.isEmpty { - viewModel.homeTimelineNavigationBarTitleViewModel.newPostsIncoming() - } - } - viewModel.timelineIsEmpty.value = latestStatusIDs.isEmpty && statuses.isEmpty } - .store(in: &viewModel.disposeBag) - } + viewModel.timelineIsEmpty.value = latestStatusIDs.isEmpty && statuses.isEmpty + + } catch { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch statuses failed: \(error.localizedDescription)") + await enter(state: Idle.self) + viewModel.didLoadLatest.send() + viewModel.homeTimelineNavigationBarTitleViewModel.receiveLoadingStateCompletion(.failure(error)) + } + } // end Task } } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadMiddleState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadMiddleState.swift deleted file mode 100644 index b5b9e4ceb..000000000 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadMiddleState.swift +++ /dev/null @@ -1,108 +0,0 @@ -// -// HomeTimelineViewModel+LoadMiddleState.swift -// Mastodon -// -// Created by sxiaojian on 2021/2/5. -// - -import os.log -import Foundation -import GameplayKit -import CoreData -import CoreDataStack - -extension HomeTimelineViewModel { - class LoadMiddleState: GKState { - weak var viewModel: HomeTimelineViewModel? - let upperTimelineIndexObjectID: NSManagedObjectID - - init(viewModel: HomeTimelineViewModel, upperTimelineIndexObjectID: NSManagedObjectID) { - self.viewModel = viewModel - self.upperTimelineIndexObjectID = upperTimelineIndexObjectID - } - - 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) - guard let viewModel = viewModel, let stateMachine = stateMachine else { return } - var dict = viewModel.loadMiddleSateMachineList.value - dict[upperTimelineIndexObjectID] = stateMachine - viewModel.loadMiddleSateMachineList.value = dict // trigger value change - } - } -} - -extension HomeTimelineViewModel.LoadMiddleState { - - class Initial: HomeTimelineViewModel.LoadMiddleState { - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - return stateClass == Loading.self - } - } - - class Loading: HomeTimelineViewModel.LoadMiddleState { - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - // guard let viewModel = viewModel else { return false } - return stateClass == Success.self || stateClass == Fail.self - } - - override func didEnter(from previousState: GKState?) { - super.didEnter(from: previousState) - - guard let viewModel = viewModel, let stateMachine = stateMachine else { return } - guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { - stateMachine.enter(Fail.self) - return - } - - guard let timelineIndex = (viewModel.fetchedResultsController.fetchedObjects ?? []).first(where: { $0.objectID == upperTimelineIndexObjectID }) else { - stateMachine.enter(Fail.self) - return - } - let statusIDs = (viewModel.fetchedResultsController.fetchedObjects ?? []).compactMap { timelineIndex in - timelineIndex.status.id - } - - // TODO: only set large count when using Wi-Fi - let maxID = timelineIndex.status.id - viewModel.context.apiService.homeTimeline(domain: activeMastodonAuthenticationBox.domain,maxID: maxID, authorizationBox: activeMastodonAuthenticationBox) - .delay(for: .seconds(1), scheduler: DispatchQueue.main) - .receive(on: DispatchQueue.main) - .sink { completion in - viewModel.homeTimelineNavigationBarTitleViewModel.receiveLoadingStateCompletion(completion) - switch completion { - case .failure(let error): - // TODO: handle error - os_log("%{public}s[%{public}ld], %{public}s: fetch statuses failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) - stateMachine.enter(Fail.self) - case .finished: - break - } - } receiveValue: { response in - let statuses = response.value - let newStatuses = statuses.filter { !statusIDs.contains($0.id) } - os_log("%{public}s[%{public}ld], %{public}s: load %{public}ld statuses, %{public}%ld new statuses", ((#file as NSString).lastPathComponent), #line, #function, statuses.count, newStatuses.count) - if newStatuses.isEmpty { - stateMachine.enter(Fail.self) - } else { - stateMachine.enter(Success.self) - } - } - .store(in: &viewModel.disposeBag) - } - } - - class Fail: HomeTimelineViewModel.LoadMiddleState { - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - // guard let viewModel = viewModel else { return false } - return stateClass == Loading.self - } - } - - class Success: HomeTimelineViewModel.LoadMiddleState { - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - // guard let viewModel = viewModel else { return false } - return false - } - } - -} diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift index a74d03a52..1986ac36a 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift @@ -8,9 +8,19 @@ import os.log import Foundation import GameplayKit +import MastodonSDK extension HomeTimelineViewModel { - class LoadOldestState: GKState { + class LoadOldestState: GKState, NamingState { + + let logger = Logger(subsystem: "HomeTimelineViewModel.LoadOldestState", category: "StateMachine") + + let id = UUID() + + var name: String { + String(describing: Self.self) + } + weak var viewModel: HomeTimelineViewModel? init(viewModel: HomeTimelineViewModel) { @@ -18,9 +28,21 @@ extension HomeTimelineViewModel { } 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) + super.didEnter(from: previousState) + let previousState = previousState as? HomeTimelineViewModel.LoadOldestState + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [\(self.id.uuidString)] enter \(self.name), previous: \(previousState?.name ?? "<nil>")") + viewModel?.loadOldestStateMachinePublisher.send(self) } + + @MainActor + func enter(state: LoadOldestState.Type) { + stateMachine?.enter(state) + } + + deinit { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [\(self.id.uuidString)] \(self.name)") + } } } @@ -28,7 +50,7 @@ extension HomeTimelineViewModel.LoadOldestState { class Initial: HomeTimelineViewModel.LoadOldestState { override func isValidNextState(_ stateClass: AnyClass) -> Bool { guard let viewModel = viewModel else { return false } - guard !(viewModel.fetchedResultsController.fetchedObjects ?? []).isEmpty else { return false } + guard !viewModel.fetchedResultsController.records.isEmpty else { return false } return stateClass == Loading.self } } @@ -40,6 +62,7 @@ extension HomeTimelineViewModel.LoadOldestState { override func didEnter(from previousState: GKState?) { super.didEnter(from: previousState) + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { assertionFailure() @@ -47,35 +70,47 @@ extension HomeTimelineViewModel.LoadOldestState { return } - guard let last = viewModel.fetchedResultsController.fetchedObjects?.last else { + guard let lastFeedRecord = viewModel.fetchedResultsController.records.last else { stateMachine.enter(Idle.self) return } - // TODO: only set large count when using Wi-Fi - let maxID = last.status.id - viewModel.context.apiService.homeTimeline(domain: activeMastodonAuthenticationBox.domain, maxID: maxID, authorizationBox: activeMastodonAuthenticationBox) - .delay(for: .seconds(1), scheduler: DispatchQueue.main) - .receive(on: DispatchQueue.main) - .sink { completion in - viewModel.homeTimelineNavigationBarTitleViewModel.receiveLoadingStateCompletion(completion) - switch completion { - case .failure(let error): - os_log("%{public}s[%{public}ld], %{public}s: fetch statuses failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) - case .finished: - // handle isFetchingLatestTimeline in fetch controller delegate - break - } - } receiveValue: { response in + Task { + let managedObjectContext = viewModel.fetchedResultsController.fetchedResultsController.managedObjectContext + let _maxID: Mastodon.Entity.Status.ID? = try await managedObjectContext.perform { + guard let feed = lastFeedRecord.object(in: managedObjectContext), + let status = feed.status + else { return nil } + return status.id + } + + guard let maxID = _maxID else { + await self.enter(state: Fail.self) + return + } + + do { + let response = try await viewModel.context.apiService.homeTimeline( + maxID: maxID, + authenticationBox: activeMastodonAuthenticationBox + ) + let statuses = response.value // enter no more state when no new statuses if statuses.isEmpty || (statuses.count == 1 && statuses[0].id == maxID) { - stateMachine.enter(NoMore.self) + await self.enter(state: NoMore.self) } else { - stateMachine.enter(Idle.self) + await self.enter(state: Idle.self) } + + viewModel.homeTimelineNavigationBarTitleViewModel.receiveLoadingStateCompletion(.finished) + + } catch { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch statues failed: \(error.localizedDescription)") + await self.enter(state: Fail.self) + viewModel.homeTimelineNavigationBarTitleViewModel.receiveLoadingStateCompletion(.failure(error)) } - .store(in: &viewModel.disposeBag) + } // end Task } } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift index c4681b40b..b2c280fb5 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift @@ -18,22 +18,23 @@ import DateToolsSwift final class HomeTimelineViewModel: NSObject { + let logger = Logger(subsystem: "HomeTimelineViewModel", category: "ViewModel") + var disposeBag = Set<AnyCancellable>() var observations = Set<NSKeyValueObservation>() // input let context: AppContext - let timelinePredicate = CurrentValueSubject<NSPredicate?, Never>(nil) - let fetchedResultsController: NSFetchedResultsController<HomeTimelineIndex> - let isFetchingLatestTimeline = CurrentValueSubject<Bool, Never>(false) - let viewDidAppear = PassthroughSubject<Void, Never>() + let fetchedResultsController: FeedFetchedResultsController let homeTimelineNavigationBarTitleViewModel: HomeTimelineNavigationBarTitleViewModel - let lastAutomaticFetchTimestamp = CurrentValueSubject<Date?, Never>(nil) - let scrollPositionRecord = CurrentValueSubject<ScrollPositionRecord?, Never>(nil) - let displaySettingBarButtonItem = CurrentValueSubject<Bool, Never>(true) - let displayComposeBarButtonItem = CurrentValueSubject<Bool, Never>(true) + let listBatchFetchViewModel = ListBatchFetchViewModel() + let viewDidAppear = PassthroughSubject<Void, Never>() + + @Published var lastAutomaticFetchTimestamp: Date? = nil + @Published var scrollPositionRecord: ScrollPositionRecord? = nil + @Published var displaySettingBarButtonItem = true + @Published var displayComposeBarButtonItem = true - weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate? weak var tableView: UITableView? weak var timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate? @@ -41,6 +42,9 @@ final class HomeTimelineViewModel: NSObject { let homeTimelineNeedRefresh = PassthroughSubject<Void, Never>() // output + var diffableDataSource: UITableViewDiffableDataSource<StatusSection, StatusItem>? + let didLoadLatest = PassthroughSubject<Void, Never>() + // top loader private(set) lazy var loadLatestStateMachine: GKStateMachine = { // exclude timeline middle fetcher state @@ -54,6 +58,7 @@ final class HomeTimelineViewModel: NSObject { return stateMachine }() lazy var loadLatestStateMachinePublisher = CurrentValueSubject<LoadLatestState?, Never>(nil) + // bottom loader private(set) lazy var loadOldestStateMachine: GKStateMachine = { // exclude timeline middle fetcher state @@ -68,68 +73,26 @@ final class HomeTimelineViewModel: NSObject { return stateMachine }() lazy var loadOldestStateMachinePublisher = CurrentValueSubject<LoadOldestState?, Never>(nil) - // middle loader - let loadMiddleSateMachineList = CurrentValueSubject<[NSManagedObjectID: GKStateMachine], Never>([:]) // TimelineIndex.objectID : middle loading state machine - var diffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>? + var cellFrameCache = NSCache<NSNumber, NSValue>() init(context: AppContext) { self.context = context - self.fetchedResultsController = { - let fetchRequest = HomeTimelineIndex.sortedFetchRequest - fetchRequest.fetchBatchSize = 20 - fetchRequest.returnsObjectsAsFaults = false - fetchRequest.relationshipKeyPathsForPrefetching = [ - #keyPath(HomeTimelineIndex.status), - #keyPath(HomeTimelineIndex.status.author), - #keyPath(HomeTimelineIndex.status.reblog), - #keyPath(HomeTimelineIndex.status.reblog.author), - ] - let controller = NSFetchedResultsController( - fetchRequest: fetchRequest, - managedObjectContext: context.managedObjectContext, - sectionNameKeyPath: nil, - cacheName: nil - ) - - return controller - }() + self.fetchedResultsController = FeedFetchedResultsController(managedObjectContext: context.managedObjectContext) self.homeTimelineNavigationBarTitleViewModel = HomeTimelineNavigationBarTitleViewModel(context: context) super.init() - fetchedResultsController.delegate = self - - timelinePredicate - .receive(on: DispatchQueue.main) - .compactMap { $0 } - .first() // set once - .sink { [weak self] predicate in + context.authenticationService.activeMastodonAuthenticationBox + .sink { [weak self] authenticationBox in guard let self = self else { return } - self.fetchedResultsController.fetchRequest.predicate = predicate - do { - self.diffableDataSource?.defaultRowAnimation = .fade - try self.fetchedResultsController.performFetch() - DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in - guard let self = self else { return } - self.diffableDataSource?.defaultRowAnimation = .automatic - } - } catch { - assertionFailure(error.localizedDescription) + guard let authenticationBox = authenticationBox else { + self.fetchedResultsController.predicate = Feed.predicate(kind: .none, acct: .none) + return } - } - .store(in: &disposeBag) - - context.authenticationService.activeMastodonAuthentication - .sink { [weak self] activeMastodonAuthentication in - guard let self = self else { return } - guard let mastodonAuthentication = activeMastodonAuthentication else { return } - let domain = mastodonAuthentication.domain - let userID = mastodonAuthentication.userID - let predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ - HomeTimelineIndex.predicate(domain: domain, userID: userID), - HomeTimelineIndex.notDeleted() - ]) - self.timelinePredicate.value = predicate + self.fetchedResultsController.predicate = Feed.predicate( + kind: .home, + acct: .mastodon(domain: authenticationBox.domain, userID: authenticationBox.userID) + ) } .store(in: &disposeBag) @@ -155,13 +118,85 @@ final class HomeTimelineViewModel: NSObject { } -extension HomeTimelineViewModel: SuggestionAccountViewModelDelegate { } - - extension HomeTimelineViewModel { struct ScrollPositionRecord { - let item: Item + let item: StatusItem let offset: CGFloat let timestamp: Date } } + +extension HomeTimelineViewModel { + + // load timeline gap + func loadMore(item: StatusItem) async { + guard case let .feedLoader(record) = item else { return } + guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } + guard let diffableDataSource = diffableDataSource else { return } + var snapshot = diffableDataSource.snapshot() + + let managedObjectContext = context.managedObjectContext + let key = "LoadMore@\(record.objectID)" + + guard let feed = record.object(in: managedObjectContext) else { return } + guard let status = feed.status else { return } + + // keep transient property live + managedObjectContext.cache(feed, key: key) + defer { + managedObjectContext.cache(nil, key: key) + } + do { + // update state + try await managedObjectContext.performChanges { + feed.update(isLoadingMore: true) + } + } catch { + assertionFailure(error.localizedDescription) + } + + // reconfigure item + if #available(iOS 15.0, *) { + snapshot.reconfigureItems([item]) + } else { + // Fallback on earlier versions + snapshot.reloadItems([item]) + } + await updateSnapshotUsingReloadData(snapshot: snapshot) + + // fetch data + do { + let maxID = status.id + _ = try await context.apiService.homeTimeline( + maxID: maxID, + authenticationBox: authenticationBox + ) + } catch { + do { + // restore state + try await managedObjectContext.performChanges { + feed.update(isLoadingMore: false) + } + } catch { + assertionFailure(error.localizedDescription) + } + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch more failure: \(error.localizedDescription)") + } + + // reconfigure item again + if #available(iOS 15.0, *) { + snapshot.reconfigureItems([item]) + } else { + // Fallback on earlier versions + snapshot.reloadItems([item]) + } + await updateSnapshotUsingReloadData(snapshot: snapshot) + } + +} + +// MARK: - SuggestionAccountViewModelDelegate +extension HomeTimelineViewModel: SuggestionAccountViewModelDelegate { + +} + diff --git a/Mastodon/Scene/HomeTimeline/View/HomeTimelineNavigationBarTitleView.swift b/Mastodon/Scene/HomeTimeline/View/HomeTimelineNavigationBarTitleView.swift index 1e9c020c5..e67ee0106 100644 --- a/Mastodon/Scene/HomeTimeline/View/HomeTimelineNavigationBarTitleView.swift +++ b/Mastodon/Scene/HomeTimeline/View/HomeTimelineNavigationBarTitleView.swift @@ -8,6 +8,8 @@ import os.log import UIKit import MastodonUI +import MastodonAsset +import MastodonLocalization protocol HomeTimelineNavigationBarTitleViewDelegate: AnyObject { func homeTimelineNavigationBarTitleView(_ titleView: HomeTimelineNavigationBarTitleView, logoButtonDidPressed sender: UIButton) @@ -63,6 +65,9 @@ extension HomeTimelineNavigationBarTitleView { configure(state: .logo) logoButton.addTarget(self, action: #selector(HomeTimelineNavigationBarTitleView.logoButtonDidPressed(_:)), for: .touchUpInside) button.addTarget(self, action: #selector(HomeTimelineNavigationBarTitleView.buttonDidPressed(_:)), for: .touchUpInside) + + logoButton.accessibilityIdentifier = "TitleButton" + button.accessibilityIdentifier = "TitleButton" } } diff --git a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageView.swift b/Mastodon/Scene/MediaPreview/Image/MediaPreviewImageView.swift similarity index 100% rename from Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageView.swift rename to Mastodon/Scene/MediaPreview/Image/MediaPreviewImageView.swift diff --git a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewController.swift b/Mastodon/Scene/MediaPreview/Image/MediaPreviewImageViewController.swift similarity index 77% rename from Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewController.swift rename to Mastodon/Scene/MediaPreview/Image/MediaPreviewImageViewController.swift index 03004028e..127c4c0c0 100644 --- a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewController.swift +++ b/Mastodon/Scene/MediaPreview/Image/MediaPreviewImageViewController.swift @@ -8,6 +8,9 @@ import os.log import UIKit import Combine +import MastodonAsset +import MastodonLocalization +import FLAnimatedImage protocol MediaPreviewImageViewControllerDelegate: AnyObject { func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, tapGestureRecognizerDidTrigger tapGestureRecognizer: UITapGestureRecognizer) @@ -39,17 +42,7 @@ extension MediaPreviewImageViewController { override func viewDidLoad() { super.viewDidLoad() - -// progressBarView.tintColor = .white -// progressBarView.translatesAutoresizingMaskIntoConstraints = false -// view.addSubview(progressBarView) -// NSLayoutConstraint.activate([ -// progressBarView.centerXAnchor.constraint(equalTo: view.centerXAnchor), -// progressBarView.centerYAnchor.constraint(equalTo: view.centerYAnchor), -// progressBarView.widthAnchor.constraint(equalToConstant: 120), -// progressBarView.heightAnchor.constraint(equalToConstant: 44), -// ]) - + previewImageView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(previewImageView) NSLayoutConstraint.activate([ @@ -69,38 +62,31 @@ extension MediaPreviewImageViewController { let previewImageViewContextMenuInteraction = UIContextMenuInteraction(delegate: self) previewImageView.addInteraction(previewImageViewContextMenuInteraction) -// switch viewModel.item { -// case .local(let meta): -// self.previewImageView.imageView.image = meta.image -// self.previewImageView.setup(image: meta.image, container: self.previewImageView, forceUpdate: true) -// self.previewImageView.imageView.accessibilityLabel = self.viewModel.altText -// case .status(let meta): -// Nuke.loadImage( -// with: meta.url, -// into: self.previewImageView.imageView -// ) { result in -// switch result { -// case .failure(let error): -// break -// case .success(let response): -// self.previewImageView.setup(image: response.image, container: self.previewImageView, forceUpdate: true) -// self.previewImageView.imageView.accessibilityLabel = self.viewModel.altText -// } -// } -// } - viewModel.image - .receive(on: RunLoop.main) // use RunLoop prevent set image during zooming (TODO: handle transitioning state) - .sink { [weak self] image, animatedImage in + switch viewModel.item { + case .remote(let imageContext): + previewImageView.imageView.accessibilityLabel = imageContext.altText + + if let thumbnail = imageContext.thumbnail { + previewImageView.imageView.image = thumbnail + previewImageView.setup(image: thumbnail, container: self.previewImageView, forceUpdate: true) + } + + previewImageView.imageView.setImage( + url: imageContext.assetURL, + placeholder: imageContext.thumbnail, + scaleToSize: nil + ) { [weak self] image in guard let self = self else { return } guard let image = image else { return } - self.previewImageView.imageView.image = image self.previewImageView.setup(image: image, container: self.previewImageView, forceUpdate: true) - if let animatedImage = animatedImage { - self.previewImageView.imageView.animatedImage = animatedImage - } - self.previewImageView.imageView.accessibilityLabel = self.viewModel.altText } - .store(in: &disposeBag) + + case .local(let imageContext): + let image = imageContext.image + previewImageView.imageView.image = image + previewImageView.setup(image: image, container: previewImageView, forceUpdate: true) + + } } } @@ -177,3 +163,21 @@ extension MediaPreviewImageViewController { case share } } + +// MARK: - MediaPreviewTransitionViewController +extension MediaPreviewImageViewController: MediaPreviewTransitionViewController { + var mediaPreviewTransitionContext: MediaPreviewTransitionContext? { + let imageView = previewImageView.imageView + let _snapshot: UIView? = imageView.snapshotView(afterScreenUpdates: false) + + guard let snapshot = _snapshot else { + return nil + } + + return MediaPreviewTransitionContext( + transitionView: imageView, + snapshot: snapshot, + snapshotTransitioning: snapshot + ) + } +} diff --git a/Mastodon/Scene/MediaPreview/Image/MediaPreviewImageViewModel.swift b/Mastodon/Scene/MediaPreview/Image/MediaPreviewImageViewModel.swift new file mode 100644 index 000000000..1a141c723 --- /dev/null +++ b/Mastodon/Scene/MediaPreview/Image/MediaPreviewImageViewModel.swift @@ -0,0 +1,47 @@ +// +// MediaPreviewImageViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-28. +// + +import os.log +import UIKit +import Combine +import Alamofire +import AlamofireImage +import FLAnimatedImage + +class MediaPreviewImageViewModel { + + var disposeBag = Set<AnyCancellable>() + + // input + let context: AppContext + let item: ImagePreviewItem + + init(context: AppContext, item: ImagePreviewItem) { + self.context = context + self.item = item + } + +} + +extension MediaPreviewImageViewModel { + + enum ImagePreviewItem { + case remote(RemoteImageContext) + case local(LocalImageContext) + } + + struct RemoteImageContext { + let assetURL: URL? + let thumbnail: UIImage? + let altText: String? + } + + struct LocalImageContext { + let image: UIImage + } + +} diff --git a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift index 88beda0f5..ae55134c4 100644 --- a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift +++ b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift @@ -9,6 +9,8 @@ import os.log import UIKit import Combine import Pageboy +import MastodonAsset +import MastodonLocalization final class MediaPreviewViewController: UIViewController, NeedsDependency { @@ -98,21 +100,41 @@ extension MediaPreviewViewController { closeButton.addTarget(self, action: #selector(MediaPreviewViewController.closeButtonPressed(_:)), for: .touchUpInside) // bind view model - viewModel.currentPage + viewModel.$currentPage .receive(on: DispatchQueue.main) .sink { [weak self] index in guard let self = self else { return } - switch self.viewModel.pushTransitionItem.source { - case .mosaic(let mosaicImageViewContainer): + switch self.viewModel.transitionItem.source { + case .attachment: + break + case .attachments(let mediaGridContainerView): UIView.animate(withDuration: 0.3) { - mosaicImageViewContainer.setImageViews(alpha: 1) - mosaicImageViewContainer.setImageView(alpha: 0, index: index) + mediaGridContainerView.setAlpha(1) + mediaGridContainerView.setAlpha(0, index: index) } case .profileAvatar, .profileBanner: break } } .store(in: &disposeBag) + + viewModel.$currentPage + .receive(on: DispatchQueue.main) + .sink { [weak self] index in + guard let self = self else { return } + switch self.viewModel.item { + case .attachment(let previewContext): + let needsHideCloseButton: Bool = { + guard index < previewContext.attachments.count else { return false } + let attachment = previewContext.attachments[index] + return attachment.kind == .video // not hide buttno for audio + }() + self.closeButtonBackground.isHidden = needsHideCloseButton + default: + break + } + } + .store(in: &disposeBag) } } @@ -143,6 +165,10 @@ extension MediaPreviewViewController: MediaPreviewingViewController { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: dismissible %s", ((#file as NSString).lastPathComponent), #line, #function, dismissible ? "true" : "false") return dismissible } + + if let _ = pagingViewController.currentViewController as? MediaPreviewVideoViewController { + return true + } os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: dismissible false", ((#file as NSString).lastPathComponent), #line, #function) return false @@ -178,7 +204,7 @@ extension MediaPreviewViewController: PageboyViewControllerDelegate { ) { // update page control // pageControl.currentPage = index - viewModel.currentPage.value = index + viewModel.currentPage = index } func pageboyViewController( @@ -196,24 +222,35 @@ extension MediaPreviewViewController: PageboyViewControllerDelegate { extension MediaPreviewViewController: MediaPreviewImageViewControllerDelegate { func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, tapGestureRecognizerDidTrigger tapGestureRecognizer: UITapGestureRecognizer) { - // do nothing + let location = tapGestureRecognizer.location(in: viewController.previewImageView.imageView) + let isContainsTap = viewController.previewImageView.imageView.frame.contains(location) + + guard !isContainsTap else { return } + dismiss(animated: true, completion: nil) } func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, longPressGestureRecognizerDidTrigger longPressGestureRecognizer: UILongPressGestureRecognizer) { // do nothing } - func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, contextMenuActionPerform action: MediaPreviewImageViewController.ContextMenuAction) { + func mediaPreviewImageViewController( + _ viewController: MediaPreviewImageViewController, + contextMenuActionPerform action: MediaPreviewImageViewController.ContextMenuAction + ) { switch action { case .savePhoto: - let savePublisher: AnyPublisher<Void, Error> = { + let _savePublisher: AnyPublisher<Void, Error>? = { switch viewController.viewModel.item { - case .status(let meta): - return context.photoLibraryService.save(imageSource: .url(meta.url)) - case .local(let meta): - return context.photoLibraryService.save(imageSource: .image(meta.image)) + case .remote(let previewContext): + guard let assetURL = previewContext.assetURL else { return nil } + return context.photoLibraryService.save(imageSource: .url(assetURL)) + case .local(let previewContext): + return context.photoLibraryService.save(imageSource: .image(previewContext.image)) } }() + guard let savePublisher = _savePublisher else { + return + } savePublisher .sink { [weak self] completion in guard let self = self else { return } @@ -221,8 +258,15 @@ extension MediaPreviewViewController: MediaPreviewImageViewControllerDelegate { case .failure(let error): guard let error = error as? PhotoLibraryService.PhotoLibraryError, case .noPermission = error else { return } - let alertController = SettingService.openSettingsAlertController(title: L10n.Common.Alerts.SavePhotoFailure.title, message: L10n.Common.Alerts.SavePhotoFailure.message) - self.coordinator.present(scene: .alertController(alertController: alertController), from: self, transition: .alertController(animated: true, completion: nil)) + let alertController = SettingService.openSettingsAlertController( + title: L10n.Common.Alerts.SavePhotoFailure.title, + message: L10n.Common.Alerts.SavePhotoFailure.message + ) + self.coordinator.present( + scene: .alertController(alertController: alertController), + from: self, + transition: .alertController(animated: true, completion: nil) + ) case .finished: break } @@ -231,14 +275,19 @@ extension MediaPreviewViewController: MediaPreviewImageViewControllerDelegate { } .store(in: &context.disposeBag) case .copyPhoto: - let copyPublisher: AnyPublisher<Void, Error> = { + let _copyPublisher: AnyPublisher<Void, Error>? = { switch viewController.viewModel.item { - case .status(let meta): - return context.photoLibraryService.copy(imageSource: .url(meta.url)) - case .local(let meta): - return context.photoLibraryService.copy(imageSource: .image(meta.image)) + case .remote(let previewContext): + guard let assetURL = previewContext.assetURL else { return nil } + return context.photoLibraryService.copy(imageSource: .url(assetURL)) + case .local(let previewContext): + return context.photoLibraryService.copy(imageSource: .image(previewContext.image)) } }() + guard let copyPublisher = _copyPublisher else { + return + } + copyPublisher .sink { completion in switch completion { @@ -256,12 +305,22 @@ extension MediaPreviewViewController: MediaPreviewImageViewControllerDelegate { SafariActivity(sceneCoordinator: self.coordinator) ] let activityViewController = UIActivityViewController( - activityItems: viewController.viewModel.item.activityItems, + activityItems: { + var activityItems: [Any] = [] + switch viewController.viewModel.item { + case .remote(let previewContext): + if let assetURL = previewContext.assetURL { + activityItems.append(assetURL) + } + case .local(let previewContext): + activityItems.append(previewContext.image) + } + return activityItems + }(), applicationActivities: applicationActivities ) activityViewController.popoverPresentationController?.sourceView = viewController.previewImageView.imageView self.present(activityViewController, animated: true, completion: nil) - } } diff --git a/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift b/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift index cd019fc9b..5912e559a 100644 --- a/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift +++ b/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift @@ -13,115 +13,135 @@ import Pageboy final class MediaPreviewViewModel: NSObject { + weak var mediaPreviewImageViewControllerDelegate: MediaPreviewImageViewControllerDelegate? + // input let context: AppContext - let initialItem: PreviewItem - weak var mediaPreviewImageViewControllerDelegate: MediaPreviewImageViewControllerDelegate? - let currentPage: CurrentValueSubject<Int, Never> + let item: PreviewItem + let transitionItem: MediaPreviewTransitionItem + + @Published var currentPage: Int // output - let pushTransitionItem: MediaPreviewTransitionItem let viewControllers: [UIViewController] - init(context: AppContext, meta: StatusImagePreviewMeta, pushTransitionItem: MediaPreviewTransitionItem) { + init( + context: AppContext, + item: PreviewItem, + transitionItem: MediaPreviewTransitionItem + ) { self.context = context - self.initialItem = .status(meta) + self.item = item + var currentPage = 0 var viewControllers: [UIViewController] = [] - let managedObjectContext = self.context.managedObjectContext - managedObjectContext.performAndWait { - let status = managedObjectContext.object(with: meta.statusObjectID) as! Status - guard let media = status.mediaAttachments?.sorted(by: { $0.index.compare($1.index) == .orderedAscending }) else { return } - for (entity, image) in zip(media, meta.preloadThumbnailImages) { - let thumbnail: UIImage? = image.flatMap { $0.size != CGSize(width: 1, height: 1) ? $0 : nil } - switch entity.type { + switch item { + case .attachment(let previewContext): + currentPage = previewContext.initialIndex + for (i, attachment) in previewContext.attachments.enumerated() { + switch attachment.kind { case .image: - guard let url = URL(string: entity.url) else { continue } - let meta = MediaPreviewImageViewModel.RemoteImagePreviewMeta(url: url, thumbnail: thumbnail, altText: entity.descriptionString) - let mediaPreviewImageModel = MediaPreviewImageViewModel(meta: meta) - let mediaPreviewImageViewController = MediaPreviewImageViewController() - mediaPreviewImageViewController.viewModel = mediaPreviewImageModel - viewControllers.append(mediaPreviewImageViewController) - default: - continue - } - } - } + let viewController = MediaPreviewImageViewController() + let viewModel = MediaPreviewImageViewModel( + context: context, + item: .remote(.init( + assetURL: attachment.assetURL.flatMap { URL(string: $0) }, + thumbnail: previewContext.thumbnail(at: i), + altText: attachment.altDescription + )) + ) + viewController.viewModel = viewModel + viewControllers.append(viewController) + case .gifv: + let viewController = MediaPreviewVideoViewController() + let viewModel = MediaPreviewVideoViewModel( + context: context, + item: .gif(.init( + assetURL: attachment.assetURL.flatMap { URL(string: $0) }, + previewURL: attachment.previewURL.flatMap { URL(string: $0) } + )) + ) + viewController.viewModel = viewModel + viewControllers.append(viewController) + case .video, .audio: + let viewController = MediaPreviewVideoViewController() + let viewModel = MediaPreviewVideoViewModel( + context: context, + item: .video(.init( + assetURL: attachment.assetURL.flatMap { URL(string: $0) }, + previewURL: attachment.previewURL.flatMap { URL(string: $0) } + )) + ) + viewController.viewModel = viewModel + viewControllers.append(viewController) + } // end switch attachment.kind { … } + } // end for … in … + case .profileAvatar(let previewContext): + let viewController = MediaPreviewImageViewController() + let viewModel = MediaPreviewImageViewModel( + context: context, + item: .remote(.init( + assetURL: previewContext.assetURL.flatMap { URL(string: $0) }, + thumbnail: previewContext.thumbnail, + altText: nil + )) + ) + viewController.viewModel = viewModel + viewControllers.append(viewController) + case .profileBanner(let previewContext): + let viewController = MediaPreviewImageViewController() + let viewModel = MediaPreviewImageViewModel( + context: context, + item: .remote(.init( + assetURL: previewContext.assetURL.flatMap { URL(string: $0) }, + thumbnail: previewContext.thumbnail, + altText: nil + )) + ) + viewController.viewModel = viewModel + viewControllers.append(viewController) + } // end switch + self.viewControllers = viewControllers - self.currentPage = CurrentValueSubject(meta.initialIndex) - self.pushTransitionItem = pushTransitionItem + self.currentPage = currentPage + self.transitionItem = transitionItem super.init() } - - init(context: AppContext, meta: ProfileBannerImagePreviewMeta, pushTransitionItem: MediaPreviewTransitionItem) { - self.context = context - self.initialItem = .profileBanner(meta) - var viewControllers: [UIViewController] = [] - let managedObjectContext = self.context.managedObjectContext - managedObjectContext.performAndWait { - let account = managedObjectContext.object(with: meta.accountObjectID) as! MastodonUser - let avatarURL = account.headerImageURLWithFallback(domain: account.domain) - let meta = MediaPreviewImageViewModel.RemoteImagePreviewMeta(url: avatarURL, thumbnail: meta.preloadThumbnailImage, altText: nil) - let mediaPreviewImageModel = MediaPreviewImageViewModel(meta: meta) - let mediaPreviewImageViewController = MediaPreviewImageViewController() - mediaPreviewImageViewController.viewModel = mediaPreviewImageModel - viewControllers.append(mediaPreviewImageViewController) - } - self.viewControllers = viewControllers - self.currentPage = CurrentValueSubject(0) - self.pushTransitionItem = pushTransitionItem - super.init() - } - - init(context: AppContext, meta: ProfileAvatarImagePreviewMeta, pushTransitionItem: MediaPreviewTransitionItem) { - self.context = context - self.initialItem = .profileAvatar(meta) - var viewControllers: [UIViewController] = [] - let managedObjectContext = self.context.managedObjectContext - managedObjectContext.performAndWait { - let account = managedObjectContext.object(with: meta.accountObjectID) as! MastodonUser - let avatarURL = account.avatarImageURLWithFallback(domain: account.domain) - let meta = MediaPreviewImageViewModel.RemoteImagePreviewMeta(url: avatarURL, thumbnail: meta.preloadThumbnailImage, altText: nil) - let mediaPreviewImageModel = MediaPreviewImageViewModel(meta: meta) - let mediaPreviewImageViewController = MediaPreviewImageViewController() - mediaPreviewImageViewController.viewModel = mediaPreviewImageModel - viewControllers.append(mediaPreviewImageViewController) - } - self.viewControllers = viewControllers - self.currentPage = CurrentValueSubject(0) - self.pushTransitionItem = pushTransitionItem - super.init() - } - + } extension MediaPreviewViewModel { enum PreviewItem { - case status(StatusImagePreviewMeta) - case profileAvatar(ProfileAvatarImagePreviewMeta) - case profileBanner(ProfileBannerImagePreviewMeta) - case local(LocalImagePreviewMeta) + case attachment(AttachmentPreviewContext) + case profileAvatar(ProfileAvatarPreviewContext) + case profileBanner(ProfileBannerPreviewContext) +// case local(LocalImagePreviewMeta) } - struct StatusImagePreviewMeta { - let statusObjectID: NSManagedObjectID + struct AttachmentPreviewContext { + let attachments: [MastodonAttachment] let initialIndex: Int - let preloadThumbnailImages: [UIImage?] + let thumbnails: [UIImage?] + + func thumbnail(at index: Int) -> UIImage? { + guard index < thumbnails.count else { return nil } + return thumbnails[index] + } } - struct ProfileAvatarImagePreviewMeta { - let accountObjectID: NSManagedObjectID - let preloadThumbnailImage: UIImage? + struct ProfileAvatarPreviewContext { + let assetURL: String? + let thumbnail: UIImage? } - - struct ProfileBannerImagePreviewMeta { - let accountObjectID: NSManagedObjectID - let preloadThumbnailImage: UIImage? - } - - struct LocalImagePreviewMeta { - let image: UIImage + + struct ProfileBannerPreviewContext { + let assetURL: String? + let thumbnail: UIImage? } + +// struct LocalImagePreviewMeta { +// let image: UIImage +// } } @@ -141,8 +161,8 @@ extension MediaPreviewViewModel: PageboyViewControllerDataSource { } func defaultPage(for pageboyViewController: PageboyViewController) -> PageboyViewController.Page? { - guard case let .status(meta) = initialItem else { return nil } - return .at(index: meta.initialIndex) + guard case let .attachment(previewContext) = item else { return nil } + return .at(index: previewContext.initialIndex) } } diff --git a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift deleted file mode 100644 index f44a6a189..000000000 --- a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift +++ /dev/null @@ -1,86 +0,0 @@ -// -// MediaPreviewImageViewModel.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-4-28. -// - -import os.log -import UIKit -import Combine -import Alamofire -import AlamofireImage -import FLAnimatedImage - -class MediaPreviewImageViewModel { - - var disposeBag = Set<AnyCancellable>() - - // input - let item: ImagePreviewItem - - // output - let image: CurrentValueSubject<(UIImage?, FLAnimatedImage?), Never> - let altText: String? - - init(meta: RemoteImagePreviewMeta) { - self.item = .status(meta) - self.image = CurrentValueSubject((meta.thumbnail, nil)) - self.altText = meta.altText - - let url = meta.url - AF.request(url).publishData() - .map { response in - switch response.result { - case .success(let data): - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: download image %s success", ((#file as NSString).lastPathComponent), #line, #function, url.debugDescription) - let image = UIImage(data: data, scale: UIScreen.main.scale) - let animatedImage = FLAnimatedImage(animatedGIFData: data) - return (image, animatedImage) - case .failure(let error): - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: download image %s fail: %s", ((#file as NSString).lastPathComponent), #line, #function, url.debugDescription, error.localizedDescription) - return (nil, nil) - } - } - .assign(to: \.value, on: image) - .store(in: &disposeBag) - } - - init(meta: LocalImagePreviewMeta) { - self.item = .local(meta) - self.image = CurrentValueSubject((meta.image, nil)) - self.altText = nil - } - -} - -extension MediaPreviewImageViewModel { - enum ImagePreviewItem { - case status(RemoteImagePreviewMeta) - case local(LocalImagePreviewMeta) - - var activityItems: [Any] { - var items: [Any] = [] - - switch self { - case .status(let meta): - items.append(meta.url) - case .local(let meta): - items.append(meta.image) - } - - return items - } - } - - struct RemoteImagePreviewMeta { - let url: URL - let thumbnail: UIImage? - let altText: String? - } - - struct LocalImagePreviewMeta { - let image: UIImage - } - -} diff --git a/Mastodon/Scene/MediaPreview/Video/MediaPreviewVideoViewController.swift b/Mastodon/Scene/MediaPreview/Video/MediaPreviewVideoViewController.swift new file mode 100644 index 000000000..7bdbbfed2 --- /dev/null +++ b/Mastodon/Scene/MediaPreview/Video/MediaPreviewVideoViewController.swift @@ -0,0 +1,155 @@ +// +// MediaPreviewVideoViewController.swift +// Mastodon +// +// Created by MainasuK on 2022-2-9. +// + +import os.log +import UIKit +import AVKit +import Combine +import func AVFoundation.AVMakeRect + +final class MediaPreviewVideoViewController: UIViewController { + + let logger = Logger(subsystem: "MediaPreviewVideoViewController", category: "ViewController") + + var disposeBag = Set<AnyCancellable>() + var viewModel: MediaPreviewVideoViewModel! + + let playerViewController = AVPlayerViewController() + + let previewImageView = UIImageView() + + deinit { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + playerViewController.player?.pause() + try? AVAudioSession.sharedInstance().setCategory(.ambient) + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + } + +} + +extension MediaPreviewVideoViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + addChild(playerViewController) + playerViewController.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(playerViewController.view) + NSLayoutConstraint.activate([ + playerViewController.view.centerXAnchor.constraint(equalTo: view.centerXAnchor), + playerViewController.view.centerYAnchor.constraint(equalTo: view.centerYAnchor), + playerViewController.view.widthAnchor.constraint(equalTo: view.widthAnchor), + playerViewController.view.heightAnchor.constraint(equalTo: view.heightAnchor), + ]) + playerViewController.didMove(toParent: self) + + if let contentOverlayView = playerViewController.contentOverlayView { + previewImageView.translatesAutoresizingMaskIntoConstraints = false + contentOverlayView.addSubview(previewImageView) + NSLayoutConstraint.activate([ + previewImageView.topAnchor.constraint(equalTo: contentOverlayView.topAnchor), + previewImageView.leadingAnchor.constraint(equalTo: contentOverlayView.leadingAnchor), + previewImageView.trailingAnchor.constraint(equalTo: contentOverlayView.trailingAnchor), + previewImageView.bottomAnchor.constraint(equalTo: contentOverlayView.bottomAnchor), + ]) + } + + playerViewController.delegate = self + playerViewController.view.backgroundColor = .clear + playerViewController.player = viewModel.player + playerViewController.allowsPictureInPicturePlayback = true + + switch viewModel.item { + case .video: + break + case .gif: + playerViewController.showsPlaybackControls = false + } + + viewModel.player?.play() + viewModel.playbackState = .playing + + if let previewURL = viewModel.item.previewURL { + previewImageView.contentMode = .scaleAspectFit + previewImageView.af.setImage( + withURL: previewURL, + placeholderImage: .placeholder(color: .systemFill) + ) + + playerViewController.publisher(for: \.isReadyForDisplay) + .receive(on: DispatchQueue.main) + .sink { [weak self] isReadyForDisplay in + guard let self = self else { return } + self.previewImageView.isHidden = isReadyForDisplay + } + .store(in: &disposeBag) + } + } + +} + +// MARK: - ShareActivityProvider +//extension MediaPreviewVideoViewController: ShareActivityProvider { +// var activities: [Any] { +// return [] +// } +// +// var applicationActivities: [UIActivity] { +// switch viewModel.item { +// case .gif(let mediaContext): +// guard let url = mediaContext.assetURL else { return [] } +// return [ +// SavePhotoActivity(context: viewModel.context, url: url, resourceType: .video) +// ] +// default: +// return [] +// } +// } +//} + +// MARK: - AVPlayerViewControllerDelegate +extension MediaPreviewVideoViewController: AVPlayerViewControllerDelegate { + +} + + +// MARK: - MediaPreviewTransitionViewController +extension MediaPreviewVideoViewController: MediaPreviewTransitionViewController { + var mediaPreviewTransitionContext: MediaPreviewTransitionContext? { + guard let playerView = playerViewController.view else { return nil } + let _currentFrame: UIImage? = { + guard let player = playerViewController.player else { return nil } + guard let asset = player.currentItem?.asset else { return nil } + let assetImageGenerator = AVAssetImageGenerator(asset: asset) + assetImageGenerator.appliesPreferredTrackTransform = true // fix orientation + do { + let cgImage = try assetImageGenerator.copyCGImage(at: player.currentTime(), actualTime: nil) + let image = UIImage(cgImage: cgImage) + return image + } catch { + return previewImageView.image + } + }() + let _snapshot: UIView? = { + guard let currentFrame = _currentFrame else { return nil } + let size = AVMakeRect(aspectRatio: currentFrame.size, insideRect: view.frame).size + let imageView = UIImageView(frame: CGRect(origin: .zero, size: size)) + imageView.image = currentFrame + return imageView + }() + guard let snapshot = _snapshot else { + return nil + } + + return MediaPreviewTransitionContext( + transitionView: playerView, + snapshot: snapshot, + snapshotTransitioning: snapshot + ) + } +} + diff --git a/Mastodon/Scene/MediaPreview/Video/MediaPreviewVideoViewModel.swift b/Mastodon/Scene/MediaPreview/Video/MediaPreviewVideoViewModel.swift new file mode 100644 index 000000000..7485bdb44 --- /dev/null +++ b/Mastodon/Scene/MediaPreview/Video/MediaPreviewVideoViewModel.swift @@ -0,0 +1,140 @@ +// +// MediaPreviewVideoViewModel.swift +// Mastodon +// +// Created by MainasuK on 2022-2-9. +// + +import os.log +import UIKit +import AVKit +import Combine +import AlamofireImage + +final class MediaPreviewVideoViewModel { + + let logger = Logger(subsystem: "MediaPreviewVideoViewModel", category: "ViewModel") + + var disposeBag = Set<AnyCancellable>() + + // input + let context: AppContext + let item: Item + + // output + public private(set) var player: AVPlayer? + private var playerLooper: AVPlayerLooper? + @Published var playbackState = PlaybackState.unknown + + init(context: AppContext, item: Item) { + self.context = context + self.item = item + // end init + + switch item { + case .video(let mediaContext): + guard let assertURL = mediaContext.assetURL else { return } + let playerItem = AVPlayerItem(url: assertURL) + let _player = AVPlayer(playerItem: playerItem) + self.player = _player + + case .gif(let mediaContext): + guard let assertURL = mediaContext.assetURL else { return } + let playerItem = AVPlayerItem(url: assertURL) + let _player = AVQueuePlayer(playerItem: playerItem) + _player.isMuted = true + self.player = _player + if let templateItem = _player.items().first { + let _playerLooper = AVPlayerLooper(player: _player, templateItem: templateItem) + self.playerLooper = _playerLooper + } + } + + guard let player = player else { + assertionFailure() + return + } + + // setup player state observer + $playbackState + .receive(on: DispatchQueue.main) + .sink { [weak self] status in + guard let self = self else { return } + self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): player state: \(status.description)") + + switch status { + case .unknown, .buffering, .readyToPlay: + break + case .playing: + try? AVAudioSession.sharedInstance().setCategory(.playback) + try? AVAudioSession.sharedInstance().setActive(true) + case .paused, .stopped, .failed: + try? AVAudioSession.sharedInstance().setCategory(.ambient) // set to ambient to allow mixed (needed for GIFV) + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + } + } + .store(in: &disposeBag) + + player.publisher(for: \.status, options: [.initial, .new]) + .sink(receiveValue: { [weak self] status in + guard let self = self else { return } + switch status { + case .failed: + self.playbackState = .failed + case .readyToPlay: + self.playbackState = .readyToPlay + case .unknown: + self.playbackState = .unknown + @unknown default: + assertionFailure() + } + }) + .store(in: &disposeBag) + NotificationCenter.default.publisher(for: .AVPlayerItemDidPlayToEndTime, object: nil) + .sink { [weak self] notification in + guard let self = self else { return } + guard let playerItem = notification.object as? AVPlayerItem, + let urlAsset = playerItem.asset as? AVURLAsset + else { return } + print(urlAsset.url) + guard urlAsset.url == item.assetURL else { return } + self.playbackState = .stopped + } + .store(in: &disposeBag) + } + +} + +extension MediaPreviewVideoViewModel { + + enum Item { + case video(RemoteVideoContext) + case gif(RemoteGIFContext) + + var previewURL: URL? { + switch self { + case .video(let mediaContext): return mediaContext.previewURL + case .gif(let mediaContext): return mediaContext.previewURL + } + } + + var assetURL: URL? { + switch self { + case .video(let mediaContext): return mediaContext.assetURL + case .gif(let mediaContext): return mediaContext.assetURL + } + } + } + + struct RemoteVideoContext { + let assetURL: URL? + let previewURL: URL? + // let thumbnail: UIImage? + } + + struct RemoteGIFContext { + let assetURL: URL? + let previewURL: URL? + } + +} diff --git a/Mastodon/Scene/Notification/Button/NotificationAvatarButton.swift b/Mastodon/Scene/Notification/Button/NotificationAvatarButton.swift index 6eafdd1dd..26abfbd23 100644 --- a/Mastodon/Scene/Notification/Button/NotificationAvatarButton.swift +++ b/Mastodon/Scene/Notification/Button/NotificationAvatarButton.swift @@ -7,6 +7,7 @@ import UIKit import FLAnimatedImage +import MastodonUI final class NotificationAvatarButton: AvatarButton { @@ -27,7 +28,7 @@ final class NotificationAvatarButton: AvatarButton { override func _init() { super._init() - avatarImageSize = CGSize(width: 35, height: 35) + size = CGSize(width: 35, height: 35) let path: CGPath = { let path = CGMutablePath() diff --git a/Mastodon/Scene/Notification/Cell/NotificationTableViewCell+ViewModel.swift b/Mastodon/Scene/Notification/Cell/NotificationTableViewCell+ViewModel.swift new file mode 100644 index 000000000..7b994076a --- /dev/null +++ b/Mastodon/Scene/Notification/Cell/NotificationTableViewCell+ViewModel.swift @@ -0,0 +1,68 @@ +// +// NotificationView+ViewModel.swift +// Mastodon +// +// Created by MainasuK on 2022-1-21. +// + +import UIKit +import Combine +import CoreDataStack + +extension NotificationTableViewCell { + final class ViewModel { + let value: Value + + init(value: Value) { + self.value = value + } + + enum Value { + case feed(Feed) + } + } +} + +extension NotificationTableViewCell { + + func configure( + tableView: UITableView, + viewModel: ViewModel, + delegate: NotificationTableViewCellDelegate? + ) { + if notificationView.frame == .zero { + // set status view width + notificationView.frame.size.width = tableView.frame.width - containerViewHorizontalMargin + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): did layout for new cell") + + notificationView.statusView.frame.size.width = tableView.frame.width - containerViewHorizontalMargin + notificationView.quoteStatusView.frame.size.width = tableView.frame.width - containerViewHorizontalMargin // the as same width as statusView + } + + switch viewModel.value { + case .feed(let feed): + notificationView.configure(feed: feed) + } + + self.delegate = delegate + + Publishers.CombineLatest( + notificationView.statusView.viewModel.$isContentReveal.removeDuplicates(), + notificationView.quoteStatusView.viewModel.$isContentReveal.removeDuplicates() + ) + .dropFirst() + .receive(on: DispatchQueue.main) + .sink { [weak tableView, weak self] _, _ in + guard let tableView = tableView else { return } + guard let self = self else { return } + self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): tableView updates") + + UIView.performWithoutAnimation { + tableView.beginUpdates() + tableView.endUpdates() + } + } + .store(in: &disposeBag) + } + +} diff --git a/Mastodon/Scene/Notification/Cell/NotificationTableViewCell.swift b/Mastodon/Scene/Notification/Cell/NotificationTableViewCell.swift new file mode 100644 index 000000000..bbdb2afaa --- /dev/null +++ b/Mastodon/Scene/Notification/Cell/NotificationTableViewCell.swift @@ -0,0 +1,100 @@ +// +// NotificationTableViewCell.swift +// Mastodon +// +// Created by MainasuK on 2022-1-21. +// + +import os.log +import UIKit +import Combine +import MastodonUI + +final class NotificationTableViewCell: UITableViewCell { + + let logger = Logger(subsystem: "NotificationTableViewCell", category: "View") + + weak var delegate: NotificationTableViewCellDelegate? + var disposeBag = Set<AnyCancellable>() + private var _disposeBag = Set<AnyCancellable>() + + let notificationView = NotificationView() + + let separatorLine = UIView.separatorLine + + var containerViewLeadingLayoutConstraint: NSLayoutConstraint! + var containerViewTrailingLayoutConstraint: NSLayoutConstraint! + + override func prepareForReuse() { + super.prepareForReuse() + + disposeBag.removeAll() + notificationView.prepareForReuse() + } + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension NotificationTableViewCell { + + private func _init() { + notificationView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(notificationView) + setupContainerViewMarginConstraints() + NSLayoutConstraint.activate([ + notificationView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 16), + containerViewLeadingLayoutConstraint, + containerViewTrailingLayoutConstraint, + contentView.bottomAnchor.constraint(equalTo: notificationView.bottomAnchor), + ]) + updateContainerViewMarginConstraints() + + 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)).priority(.required - 1), + ]) + + notificationView.quoteBackgroundView.backgroundColor = ThemeService.shared.currentTheme.value.secondarySystemBackgroundColor + ThemeService.shared.currentTheme + .sink { [weak self] theme in + guard let self = self else { return } + self.notificationView.quoteBackgroundView.backgroundColor = theme.secondarySystemBackgroundColor + } + .store(in: &_disposeBag) + + notificationView.delegate = self + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + updateContainerViewMarginConstraints() + } + +} + +// MARK: - AdaptiveContainerMarginTableViewCell +extension NotificationTableViewCell: AdaptiveContainerMarginTableViewCell { + var containerView: NotificationView { + notificationView + } +} + +// MARK: - NotificationViewContainerTableViewCell +extension NotificationTableViewCell: NotificationViewContainerTableViewCell { } + +// MARK: - NotificationTableViewCellDelegate +extension NotificationTableViewCell: NotificationViewDelegate { } diff --git a/Mastodon/Scene/Notification/Cell/NotificationTableViewCellDelegate.swift b/Mastodon/Scene/Notification/Cell/NotificationTableViewCellDelegate.swift new file mode 100644 index 000000000..d13ce7195 --- /dev/null +++ b/Mastodon/Scene/Notification/Cell/NotificationTableViewCellDelegate.swift @@ -0,0 +1,88 @@ +// +// NotificationTableViewCellDelegate.swift +// Mastodon +// +// Created by MainasuK on 2022-1-26. +// + +import UIKit +import MastodonUI +import MetaTextKit + +// sourcery: protocolName = "NotificationViewDelegate" +// sourcery: replaceOf = "notificationView(notificationView" +// sourcery: replaceWith = "delegate?.tableViewCell(self, notificationView: notificationView" +protocol NotificationViewContainerTableViewCell: UITableViewCell, AutoGenerateProtocolRelayDelegate { + var delegate: NotificationTableViewCellDelegate? { get } + var notificationView: NotificationView { get } +} + +// MARK: - AutoGenerateProtocolDelegate +// sourcery: protocolName = "NotificationViewDelegate" +// sourcery: replaceOf = "notificationView(_" +// sourcery: replaceWith = "func tableViewCell(_ cell: UITableViewCell," +protocol NotificationTableViewCellDelegate: AnyObject, AutoGenerateProtocolDelegate { + // sourcery:inline:NotificationTableViewCellDelegate.AutoGenerateProtocolDelegate + func tableViewCell(_ cell: UITableViewCell, notificationView: NotificationView, authorAvatarButtonDidPressed button: AvatarButton) + func tableViewCell(_ cell: UITableViewCell, notificationView: NotificationView, menuButton button: UIButton, didSelectAction action: MastodonMenu.Action) + func tableViewCell(_ cell: UITableViewCell, notificationView: NotificationView, statusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta) + func tableViewCell(_ cell: UITableViewCell, notificationView: NotificationView, statusView: StatusView, spoilerOverlayViewDidPressed overlayView: SpoilerOverlayView) + func tableViewCell(_ cell: UITableViewCell, notificationView: NotificationView, statusView: StatusView, mediaGridContainerView: MediaGridContainerView, mediaView: MediaView, didSelectMediaViewAt index: Int) + func tableViewCell(_ cell: UITableViewCell, notificationView: NotificationView, statusView: StatusView, actionToolbarContainer: ActionToolbarContainer, buttonDidPressed button: UIButton, action: ActionToolbarContainer.Action) + func tableViewCell(_ cell: UITableViewCell, notificationView: NotificationView, quoteStatusView: StatusView, authorAvatarButtonDidPressed button: AvatarButton) + func tableViewCell(_ cell: UITableViewCell, notificationView: NotificationView, quoteStatusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta) + func tableViewCell(_ cell: UITableViewCell, notificationView: NotificationView, quoteStatusView: StatusView, spoilerOverlayViewDidPressed overlayView: SpoilerOverlayView) + func tableViewCell(_ cell: UITableViewCell, notificationView: NotificationView, quoteStatusView: StatusView, mediaGridContainerView: MediaGridContainerView, mediaView: MediaView, didSelectMediaViewAt index: Int) + func tableViewCell(_ cell: UITableViewCell, notificationView: NotificationView, accessibilityActivate: Void) + // sourcery:end +} + +// MARK: - AutoGenerateProtocolDelegate +// Protocol Extension +extension NotificationViewDelegate where Self: NotificationViewContainerTableViewCell { + // sourcery:inline:NotificationViewContainerTableViewCell.AutoGenerateProtocolRelayDelegate + func notificationView(_ notificationView: NotificationView, authorAvatarButtonDidPressed button: AvatarButton) { + delegate?.tableViewCell(self, notificationView: notificationView, authorAvatarButtonDidPressed: button) + } + + func notificationView(_ notificationView: NotificationView, menuButton button: UIButton, didSelectAction action: MastodonMenu.Action) { + delegate?.tableViewCell(self, notificationView: notificationView, menuButton: button, didSelectAction: action) + } + + func notificationView(_ notificationView: NotificationView, statusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta) { + delegate?.tableViewCell(self, notificationView: notificationView, statusView: statusView, metaText: metaText, didSelectMeta: meta) + } + + func notificationView(_ notificationView: NotificationView, statusView: StatusView, spoilerOverlayViewDidPressed overlayView: SpoilerOverlayView) { + delegate?.tableViewCell(self, notificationView: notificationView, statusView: statusView, spoilerOverlayViewDidPressed: overlayView) + } + + func notificationView(_ notificationView: NotificationView, statusView: StatusView, mediaGridContainerView: MediaGridContainerView, mediaView: MediaView, didSelectMediaViewAt index: Int) { + delegate?.tableViewCell(self, notificationView: notificationView, statusView: statusView, mediaGridContainerView: mediaGridContainerView, mediaView: mediaView, didSelectMediaViewAt: index) + } + + func notificationView(_ notificationView: NotificationView, statusView: StatusView, actionToolbarContainer: ActionToolbarContainer, buttonDidPressed button: UIButton, action: ActionToolbarContainer.Action) { + delegate?.tableViewCell(self, notificationView: notificationView, statusView: statusView, actionToolbarContainer: actionToolbarContainer, buttonDidPressed: button, action: action) + } + + func notificationView(_ notificationView: NotificationView, quoteStatusView: StatusView, authorAvatarButtonDidPressed button: AvatarButton) { + delegate?.tableViewCell(self, notificationView: notificationView, quoteStatusView: quoteStatusView, authorAvatarButtonDidPressed: button) + } + + func notificationView(_ notificationView: NotificationView, quoteStatusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta) { + delegate?.tableViewCell(self, notificationView: notificationView, quoteStatusView: quoteStatusView, metaText: metaText, didSelectMeta: meta) + } + + func notificationView(_ notificationView: NotificationView, quoteStatusView: StatusView, spoilerOverlayViewDidPressed overlayView: SpoilerOverlayView) { + delegate?.tableViewCell(self, notificationView: notificationView, quoteStatusView: quoteStatusView, spoilerOverlayViewDidPressed: overlayView) + } + + func notificationView(_ notificationView: NotificationView, quoteStatusView: StatusView, mediaGridContainerView: MediaGridContainerView, mediaView: MediaView, didSelectMediaViewAt index: Int) { + delegate?.tableViewCell(self, notificationView: notificationView, quoteStatusView: quoteStatusView, mediaGridContainerView: mediaGridContainerView, mediaView: mediaView, didSelectMediaViewAt: index) + } + + func notificationView(_ notificationView: NotificationView, accessibilityActivate: Void) { + delegate?.tableViewCell(self, notificationView: notificationView, accessibilityActivate: accessibilityActivate) + } + // sourcery:end +} diff --git a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController+DataSourceProvider.swift b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController+DataSourceProvider.swift new file mode 100644 index 000000000..c058ee921 --- /dev/null +++ b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController+DataSourceProvider.swift @@ -0,0 +1,44 @@ +// +// NotificationTimelineViewController+DataSourceProvider.swift +// Mastodon +// +// Created by MainasuK on 2022-1-26. +// + +import UIKit + +extension NotificationTimelineViewController: DataSourceProvider { + func item(from source: DataSourceItem.Source) async -> DataSourceItem? { + var _indexPath = source.indexPath + if _indexPath == nil, let cell = source.tableViewCell { + _indexPath = await self.indexPath(for: cell) + } + guard let indexPath = _indexPath else { return nil } + + guard let item = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else { + return nil + } + + switch item { + case .feed(let record): + let managedObjectContext = context.managedObjectContext + let item: DataSourceItem? = try? await managedObjectContext.perform { + guard let feed = record.object(in: managedObjectContext) else { return nil } + guard feed.kind == .notificationAll || feed.kind == .notificationMentions else { return nil } + if let notification = feed.notification { + return .notification(record: .init(objectID: notification.objectID)) + } else { + return nil + } + } + return item + default: + return nil + } + } + + @MainActor + private func indexPath(for cell: UITableViewCell) async -> IndexPath? { + return tableView.indexPath(for: cell) + } +} diff --git a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController.swift b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController.swift new file mode 100644 index 000000000..bdb4d05cb --- /dev/null +++ b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController.swift @@ -0,0 +1,306 @@ +// +// NotificationTimelineViewController.swift +// Mastodon +// +// Created by MainasuK on 2022-1-21. +// + +import os.log +import UIKit +import Combine +import CoreDataStack +import MastodonLocalization + +final class NotificationTimelineViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { + + let logger = Logger(subsystem: "NotificationTimelineViewController", category: "ViewController") + + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + let mediaPreviewTransitionController = MediaPreviewTransitionController() + + var disposeBag = Set<AnyCancellable>() + var observations = Set<NSKeyValueObservation>() + + var viewModel: NotificationTimelineViewModel! + + private(set) lazy var refreshControl: UIRefreshControl = { + let refreshControl = UIRefreshControl() + refreshControl.addTarget(self, action: #selector(NotificationTimelineViewController.refreshControlValueChanged(_:)), for: .valueChanged) + return refreshControl + }() + + private(set) lazy var tableView: UITableView = { + let tableView = UITableView() + tableView.backgroundColor = .clear + tableView.rowHeight = UITableView.automaticDimension + tableView.separatorStyle = .none + return tableView + }() + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension NotificationTimelineViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + 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( + tableView: tableView, + notificationTableViewCellDelegate: 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.loadOldestStateMachine.enter(NotificationTimelineViewModel.LoadOldestState.Loading.self) + } + .store(in: &disposeBag) + + // setup refresh control + tableView.refreshControl = refreshControl + viewModel.didLoadLatest + .receive(on: DispatchQueue.main) + .sink { [weak self] in + guard let self = self else { return } + UIView.animate(withDuration: 0.5) { [weak self] in + guard let self = self else { return } + self.refreshControl.endRefreshing() + } + } + .store(in: &disposeBag) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + refreshControl.endRefreshing() + tableView.deselectRow(with: transitionCoordinator, animated: animated) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + if !viewModel.isLoadingLatest { + let now = Date() + if let timestamp = viewModel.lastAutomaticFetchTimestamp { + if now.timeIntervalSince(timestamp) > 60 { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): auto fetch latest timeline…") + Task { + await viewModel.loadLatest() + } + viewModel.lastAutomaticFetchTimestamp = now + } else { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): auto fetch latest timeline skip. Reason: updated in recent 60s") + } + } else { + Task { + await viewModel.loadLatest() + } + viewModel.lastAutomaticFetchTimestamp = now + } + } + } + +} + +extension NotificationTimelineViewController { + + @objc private func refreshControlValueChanged(_ sender: UIRefreshControl) { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") + + Task { + await viewModel.loadLatest() + } + } + +} + +// MARK: - UITableViewDelegate +extension NotificationTimelineViewController: UITableViewDelegate, AutoGenerateTableViewDelegate { + // sourcery:inline:NotificationTimelineViewController.AutoGenerateTableViewDelegate + + // Generated using Sourcery + // DO NOT EDIT + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + aspectTableView(tableView, didSelectRowAt: 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) + } + + // sourcery:end + + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + guard let item = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else { + return + } + + // check item type inside `loadMore` + Task { + await viewModel.loadMore(item: item) + } + } + +} + +// MARK: - NotificationTableViewCellDelegate +extension NotificationTimelineViewController: NotificationTableViewCellDelegate { } + +// MARK: - ScrollViewContainer +extension NotificationTimelineViewController: ScrollViewContainer { + + var scrollView: UIScrollView? { tableView } + +} + +extension NotificationTimelineViewController { + override var keyCommands: [UIKeyCommand]? { + return navigationKeyCommands + } +} + +extension NotificationTimelineViewController: TableViewControllerNavigateable { + + func navigate(direction: TableViewNavigationDirection) { + if let indexPathForSelectedRow = tableView.indexPathForSelectedRow { + // navigate up/down on the current selected item + navigateToStatus(direction: direction, indexPath: indexPathForSelectedRow) + } else { + // set first visible item selected + navigateToFirstVisibleStatus() + } + } + + private func navigateToStatus(direction: TableViewNavigationDirection, indexPath: IndexPath) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + let items = diffableDataSource.snapshot().itemIdentifiers + guard let selectedItem = diffableDataSource.itemIdentifier(for: indexPath), + let selectedItemIndex = items.firstIndex(of: selectedItem) else { + return + } + + let _navigateToItem: NotificationItem? = { + var index = selectedItemIndex + while 0..<items.count ~= index { + index = { + switch direction { + case .up: return index - 1 + case .down: return index + 1 + } + }() + guard 0..<items.count ~= index else { return nil } + let item = items[index] + + guard Self.validNavigateableItem(item) else { continue } + return item + } + return nil + }() + + guard let item = _navigateToItem, let indexPath = diffableDataSource.indexPath(for: item) else { return } + let scrollPosition: UITableView.ScrollPosition = overrideNavigationScrollPosition ?? Self.navigateScrollPosition(tableView: tableView, indexPath: indexPath) + tableView.selectRow(at: indexPath, animated: true, scrollPosition: scrollPosition) + } + + private func navigateToFirstVisibleStatus() { + guard let indexPathsForVisibleRows = tableView.indexPathsForVisibleRows else { return } + guard let diffableDataSource = viewModel.diffableDataSource else { return } + + var visibleItems: [NotificationItem] = indexPathsForVisibleRows.sorted().compactMap { indexPath in + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return nil } + guard Self.validNavigateableItem(item) else { return nil } + return item + } + if indexPathsForVisibleRows.first?.row != 0, visibleItems.count > 1 { + // drop first when visible not the first cell of table + visibleItems.removeFirst() + } + guard let item = visibleItems.first, let indexPath = diffableDataSource.indexPath(for: item) else { return } + let scrollPosition: UITableView.ScrollPosition = overrideNavigationScrollPosition ?? Self.navigateScrollPosition(tableView: tableView, indexPath: indexPath) + tableView.selectRow(at: indexPath, animated: true, scrollPosition: scrollPosition) + } + + static func validNavigateableItem(_ item: NotificationItem) -> Bool { + switch item { + case .feed: + return true + default: + return false + } + } + + func open() { + guard let indexPathForSelectedRow = tableView.indexPathForSelectedRow else { return } + guard let diffableDataSource = viewModel.diffableDataSource else { return } + guard let item = diffableDataSource.itemIdentifier(for: indexPathForSelectedRow) else { return } + + Task { @MainActor in + switch item { + case .feed(let record): + guard let feed = record.object(in: self.context.managedObjectContext) else { return } + guard let notification = feed.notification else { return } + + if let stauts = notification.status { + let threadViewModel = ThreadViewModel( + context: self.context, + optionalRoot: .root(context: .init(status: .init(objectID: stauts.objectID))) + ) + self.coordinator.present( + scene: .thread(viewModel: threadViewModel), + from: self, + transition: .show + ) + } else { + let profileViewModel = ProfileViewModel( + context: self.context, + optionalMastodonUser: notification.account + ) + self.coordinator.present( + scene: .profile(viewModel: profileViewModel), + from: self, + transition: .show + ) + } + default: + break + } + } // end Task + } + + func navigateKeyCommandHandlerRelay(_ sender: UIKeyCommand) { + navigateKeyCommandHandler(sender) + } + +} diff --git a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+Diffable.swift b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+Diffable.swift new file mode 100644 index 000000000..b32eae76b --- /dev/null +++ b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+Diffable.swift @@ -0,0 +1,126 @@ +// +// NotificationTimelineViewModel+Diffable.swift +// Mastodon +// +// Created by MainasuK on 2022-1-21. +// + +import os.log +import UIKit +import CoreData +import CoreDataStack + +extension NotificationTimelineViewModel { + + func setupDiffableDataSource( + tableView: UITableView, + notificationTableViewCellDelegate: NotificationTableViewCellDelegate + ) { + diffableDataSource = NotificationSection.diffableDataSource( + tableView: tableView, + context: context, + configuration: NotificationSection.Configuration( + notificationTableViewCellDelegate: notificationTableViewCellDelegate, + filterContext: .notifications, + activeFilters: context.statusFilterService.$activeFilters + ) + ) + + var snapshot = NSDiffableDataSourceSnapshot<NotificationSection, NotificationItem>() + snapshot.appendSections([.main]) + diffableDataSource?.apply(snapshot) + + feedFetchedResultsController.$records + .receive(on: DispatchQueue.main) + .sink { [weak self] records in + guard let self = self else { return } + guard let diffableDataSource = self.diffableDataSource else { return } + self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): incoming \(records.count) objects") + + Task { + let start = CACurrentMediaTime() + defer { + let end = CACurrentMediaTime() + self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): cost \(end - start, format: .fixed(precision: 4))s to process \(records.count) feeds") + } + let oldSnapshot = diffableDataSource.snapshot() + var newSnapshot: NSDiffableDataSourceSnapshot<NotificationSection, NotificationItem> = { + let newItems = records.map { record in + NotificationItem.feed(record: record) + } + var snapshot = NSDiffableDataSourceSnapshot<NotificationSection, NotificationItem>() + snapshot.appendSections([.main]) + snapshot.appendItems(newItems, toSection: .main) + return snapshot + }() + + let parentManagedObjectContext = self.context.managedObjectContext + let managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) + managedObjectContext.parent = parentManagedObjectContext + try? await managedObjectContext.perform { + let anchors: [Feed] = { + let request = Feed.sortedFetchRequest + request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ + Feed.hasMorePredicate(), + self.feedFetchedResultsController.predicate, + ]) + do { + return try managedObjectContext.fetch(request) + } catch { + assertionFailure(error.localizedDescription) + return [] + } + }() + + let itemIdentifiers = newSnapshot.itemIdentifiers + for (index, item) in itemIdentifiers.enumerated() { + guard case let .feed(record) = item else { continue } + guard anchors.contains(where: { feed in feed.objectID == record.objectID }) else { continue } + let isLast = index + 1 == itemIdentifiers.count + if isLast { + newSnapshot.insertItems([.bottomLoader], afterItem: item) + } else { + newSnapshot.insertItems([.feedLoader(record: record)], afterItem: item) + } + } + } + + let hasChanges = newSnapshot.itemIdentifiers != oldSnapshot.itemIdentifiers + if !hasChanges { + self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): snapshot not changes") + self.didLoadLatest.send() + return + } else { + self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): snapshot has changes") + } + + await self.updateSnapshotUsingReloadData(snapshot: newSnapshot) + self.didLoadLatest.send() + self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): applied new snapshot") + } // end Task + } + .store(in: &disposeBag) + } // end func setupDiffableDataSource + +} + +extension NotificationTimelineViewModel { + + @MainActor func updateDataSource( + snapshot: NSDiffableDataSourceSnapshot<NotificationSection, NotificationItem>, + animatingDifferences: Bool + ) async { + diffableDataSource?.apply(snapshot, animatingDifferences: animatingDifferences) + } + + @MainActor func updateSnapshotUsingReloadData( + snapshot: NSDiffableDataSourceSnapshot<NotificationSection, NotificationItem> + ) async { + if #available(iOS 15.0, *) { + await self.diffableDataSource?.applySnapshotUsingReloadData(snapshot) + } else { + diffableDataSource?.applySnapshot(snapshot, animated: false, completion: nil) + } + } + +} diff --git a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+LoadOldestState.swift b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+LoadOldestState.swift new file mode 100644 index 000000000..bc67a6304 --- /dev/null +++ b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+LoadOldestState.swift @@ -0,0 +1,146 @@ +// +// NotificationTimelineViewModel+LoadOldestState.swift +// Mastodon +// +// Created by MainasuK on 2022-1-21. +// + +import CoreDataStack +import Foundation +import GameplayKit +import MastodonSDK +import os.log + +extension NotificationTimelineViewModel { + class LoadOldestState: GKState, NamingState { + + let logger = Logger(subsystem: "NotificationTimelineViewModel.LoadOldestState", category: "StateMachine") + + let id = UUID() + + var name: String { + String(describing: Self.self) + } + + weak var viewModel: NotificationTimelineViewModel? + + init(viewModel: NotificationTimelineViewModel) { + self.viewModel = viewModel + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + let previousState = previousState as? NotificationTimelineViewModel.LoadOldestState + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [\(self.id.uuidString)] enter \(self.name), previous: \(previousState?.name ?? "<nil>")") + } + + @MainActor + func enter(state: LoadOldestState.Type) { + stateMachine?.enter(state) + } + + deinit { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [\(self.id.uuidString)] \(self.name)") + } + } +} + +extension NotificationTimelineViewModel.LoadOldestState { + class Initial: NotificationTimelineViewModel.LoadOldestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + guard let viewModel = viewModel else { return false } + guard !viewModel.feedFetchedResultsController.records.isEmpty else { return false } + return stateClass == Loading.self + } + } + + class Loading: NotificationTimelineViewModel.LoadOldestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + stateClass == Fail.self || stateClass == Idle.self || stateClass == NoMore.self + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + guard let authenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { + assertionFailure() + stateMachine.enter(Fail.self) + return + } + + guard let lastFeedRecord = viewModel.feedFetchedResultsController.records.last else { + stateMachine.enter(Fail.self) + return + } + let scope = viewModel.scope + + Task { + let managedObjectContext = viewModel.context.managedObjectContext + let _maxID: Mastodon.Entity.Notification.ID? = try await managedObjectContext.perform { + guard let feed = lastFeedRecord.object(in: managedObjectContext), + let notification = feed.notification + else { return nil } + return notification.id + } + + guard let maxID = _maxID else { + await self.enter(state: Fail.self) + return + } + + do { + let response = try await viewModel.context.apiService.notifications( + maxID: maxID, + scope: scope, + authenticationBox: authenticationBox + ) + + let notifications = response.value + // enter no more state when no new statuses + if notifications.isEmpty || (notifications.count == 1 && notifications[0].id == maxID) { + await self.enter(state: NoMore.self) + } else { + await self.enter(state: Idle.self) + } + + } catch { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch statues failed: \(error.localizedDescription)") + await self.enter(state: Fail.self) + } + } // Task + } + } + + class Fail: NotificationTimelineViewModel.LoadOldestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + stateClass == Loading.self || stateClass == Idle.self + } + } + + class Idle: NotificationTimelineViewModel.LoadOldestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + stateClass == Loading.self + } + } + + class NoMore: NotificationTimelineViewModel.LoadOldestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + // reset state if needs + stateClass == Idle.self + } + + override func didEnter(from previousState: GKState?) { + guard let viewModel = viewModel else { return } + guard let diffableDataSource = viewModel.diffableDataSource else { + assertionFailure() + return + } + DispatchQueue.main.async { + var snapshot = diffableDataSource.snapshot() + snapshot.deleteItems([.bottomLoader]) + diffableDataSource.apply(snapshot) + } + } + } +} diff --git a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel.swift b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel.swift new file mode 100644 index 000000000..ee2ac8a0e --- /dev/null +++ b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel.swift @@ -0,0 +1,196 @@ +// +// NotificationTimelineViewModel.swift +// Mastodon +// +// Created by MainasuK on 2022-1-21. +// + +import os.log +import UIKit +import Combine +import CoreDataStack +import GameplayKit +import MastodonSDK + +final class NotificationTimelineViewModel { + + let logger = Logger(subsystem: "NotificationTimelineViewModel", category: "ViewModel") + + var disposeBag = Set<AnyCancellable>() + + // input + let context: AppContext + let scope: Scope + let feedFetchedResultsController: FeedFetchedResultsController + let listBatchFetchViewModel = ListBatchFetchViewModel() + @Published var isLoadingLatest = false + @Published var lastAutomaticFetchTimestamp: Date? + + // output + var diffableDataSource: UITableViewDiffableDataSource<NotificationSection, NotificationItem>? + var didLoadLatest = PassthroughSubject<Void, Never>() + + // bottom loader + private(set) lazy var loadOldestStateMachine: GKStateMachine = { + // exclude timeline middle fetcher state + let stateMachine = GKStateMachine(states: [ + LoadOldestState.Initial(viewModel: self), + LoadOldestState.Loading(viewModel: self), + LoadOldestState.Fail(viewModel: self), + LoadOldestState.Idle(viewModel: self), + LoadOldestState.NoMore(viewModel: self), + ]) + stateMachine.enter(LoadOldestState.Initial.self) + return stateMachine + }() + + init( + context: AppContext, + scope: Scope + ) { + self.context = context + self.scope = scope + self.feedFetchedResultsController = FeedFetchedResultsController(managedObjectContext: context.managedObjectContext) + // end init + + context.authenticationService.activeMastodonAuthenticationBox + .sink { [weak self] authenticationBox in + guard let self = self else { return } + guard let authenticationBox = authenticationBox else { + self.feedFetchedResultsController.predicate = Feed.nonePredicate() + return + } + + let predicate = NotificationTimelineViewModel.feedPredicate( + authenticationBox: authenticationBox, + scope: scope + ) + self.feedFetchedResultsController.predicate = predicate + } + .store(in: &disposeBag) + } + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension NotificationTimelineViewModel { + enum Scope: Hashable, CaseIterable { + case everything + case mentions + + var includeTypes: [MastodonNotificationType]? { + switch self { + case .everything: return nil + case .mentions: return [.mention, .status] + } + } + + + var excludeTypes: [MastodonNotificationType]? { + switch self { + case .everything: return nil + case .mentions: return [.follow, .followRequest, .reblog, .favourite, .poll] + } + } + + var _excludeTypes: [Mastodon.Entity.Notification.NotificationType]? { + switch self { + case .everything: return nil + case .mentions: return [.follow, .followRequest, .reblog, .favourite, .poll] + } + } + } + + static func feedPredicate( + authenticationBox: MastodonAuthenticationBox, + scope: Scope + ) -> NSPredicate { + let domain = authenticationBox.domain + let userID = authenticationBox.userID + let acct = Feed.Acct.mastodon( + domain: domain, + userID: userID + ) + + let predicate: NSPredicate = { + switch scope { + case .everything: + return NSCompoundPredicate(andPredicateWithSubpredicates: [ + Feed.hasNotificationPredicate(), + Feed.predicate( + kind: .notificationAll, + acct: acct + ) + ]) + case .mentions: + return NSCompoundPredicate(andPredicateWithSubpredicates: [ + Feed.hasNotificationPredicate(), + Feed.predicate( + kind: .notificationMentions, + acct: acct + ), + Feed.notificationTypePredicate(types: scope.includeTypes ?? []) + ]) + } + }() + return predicate + } + +} + +extension NotificationTimelineViewModel { + + // load lastest + func loadLatest() async { + guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } + + isLoadingLatest = true + defer { isLoadingLatest = false } + + do { + _ = try await context.apiService.notifications( + maxID: nil, + scope: scope, + authenticationBox: authenticationBox + ) + } catch { + didLoadLatest.send() + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): \(error.localizedDescription)") + } + } + + // load timeline gap + func loadMore(item: NotificationItem) async { + guard case let .feedLoader(record) = item else { return } + guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } + + let managedObjectContext = context.managedObjectContext + let key = "LoadMore@\(record.objectID)" + + // return when already loading state + guard managedObjectContext.cache(froKey: key) == nil else { return } + + guard let feed = record.object(in: managedObjectContext) else { return } + guard let maxID = feed.notification?.id else { return } + // keep transient property live + managedObjectContext.cache(feed, key: key) + defer { + managedObjectContext.cache(nil, key: key) + } + + // fetch data + do { + _ = try await context.apiService.notifications( + maxID: maxID, + scope: scope, + authenticationBox: authenticationBox + ) + } catch { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch more failure: \(error.localizedDescription)") + } + } + +} diff --git a/Mastodon/Scene/Notification/NotificationViewController+StatusProvider.swift b/Mastodon/Scene/Notification/NotificationViewController+StatusProvider.swift deleted file mode 100644 index 57272404e..000000000 --- a/Mastodon/Scene/Notification/NotificationViewController+StatusProvider.swift +++ /dev/null @@ -1,77 +0,0 @@ -// -// NotificationViewController+StatusProvider.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-7-1. -// - -import UIKit -import Combine -import CoreData -import CoreDataStack - -extension NotificationViewController: StatusProvider { - func status() -> Future<Status?, Never> { - return Future<Status?, Never> { promise in - promise(.success(nil)) - } - } - - func status(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future<Status?, Never> { - return Future<Status?, Never> { promise in - 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, _), - .notificationStatus(let objectID, _): - self.viewModel.fetchedResultsController.managedObjectContext.perform { - let notification = self.viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! MastodonNotification - promise(.success(notification.status)) - } - case .bottomLoader: - promise(.success(nil)) - } - } - } - - func status(for cell: UICollectionViewCell) -> Future<Status?, Never> { - return Future<Status?, Never> { promise in - promise(.success(nil)) - } - } - - var managedObjectContext: NSManagedObjectContext { - viewModel.fetchedResultsController.managedObjectContext - } - - var tableViewDiffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>? { - return nil - } - - func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item? { - return nil - } - - func items(indexPaths: [IndexPath]) -> [Item] { - return [] - } - - func statusObjectItems(indexPaths: [IndexPath]) -> [StatusObjectItem] { - guard let diffableDataSource = self.viewModel.diffableDataSource else { return [] } - let items = indexPaths.compactMap { diffableDataSource.itemIdentifier(for: $0)?.statusObjectItem } - return items - } - -} - -// MARK: - UserProvider -extension NotificationViewController: UserProvider { } diff --git a/Mastodon/Scene/Notification/NotificationViewController.swift b/Mastodon/Scene/Notification/NotificationViewController.swift index 0567d04dd..dd4d97047 100644 --- a/Mastodon/Scene/Notification/NotificationViewController.swift +++ b/Mastodon/Scene/Notification/NotificationViewController.swift @@ -5,18 +5,17 @@ // Created by sxiaojian on 2021/4/12. // -import Combine -import CoreData -import CoreDataStack -import GameplayKit -import MastodonSDK -import OSLog +import os.log import UIKit -import Meta -import MetaTextKit -import AVKit +import Combine +import MastodonAsset +import MastodonLocalization +import Tabman +import Pageboy -final class NotificationViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { +final class NotificationViewController: TabmanViewController, NeedsDependency { + + let logger = Logger(subsystem: "NotificationViewController", category: "ViewController") weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } @@ -26,27 +25,23 @@ final class NotificationViewController: UIViewController, NeedsDependency, Media private(set) lazy var viewModel = NotificationViewModel(context: context) - let mediaPreviewTransitionController = MediaPreviewTransitionController() + let pageSegmentedControl = UISegmentedControl() - let segmentControl: UISegmentedControl = { - let control = UISegmentedControl(items: [L10n.Scene.Notification.Title.everything, L10n.Scene.Notification.Title.mentions]) - 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 - tableView.separatorStyle = .none - tableView.tableFooterView = UIView() - tableView.backgroundColor = .clear - return tableView - }() - - let refreshControl = UIRefreshControl() + override func pageboyViewController( + _ pageboyViewController: PageboyViewController, + didScrollToPageAt index: TabmanViewController.PageIndex, + direction: PageboyViewController.NavigationDirection, + animated: Bool + ) { + super.pageboyViewController( + pageboyViewController, + didScrollToPageAt: index, + direction: direction, + animated: animated + ) + + viewModel.currentPageIndex = index + } deinit { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) @@ -65,106 +60,50 @@ extension NotificationViewController { self.view.backgroundColor = theme.secondarySystemBackgroundColor } .store(in: &disposeBag) - segmentControl.translatesAutoresizingMaskIntoConstraints = false - navigationItem.titleView = segmentControl - NSLayoutConstraint.activate([ - segmentControl.widthAnchor.constraint(equalToConstant: 287) - ]) - segmentControl.addTarget(self, action: #selector(NotificationViewController.segmentedControlValueChanged(_:)), for: .valueChanged) - - 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.refreshControl = refreshControl - refreshControl.addTarget(self, action: #selector(NotificationViewController.refreshControlValueChanged(_:)), for: .valueChanged) - - tableView.delegate = self - viewModel.tableView = tableView - viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self - viewModel.setupDiffableDataSource( - for: tableView, - dependency: self, - delegate: self, - statusTableViewCellDelegate: self - ) - viewModel.viewDidLoad.send() - // bind refresh control - viewModel.isFetchingLatestNotification - .receive(on: DispatchQueue.main) - .sink { [weak self] isFetching in - guard let self = self else { return } - if !isFetching { - UIView.animate(withDuration: 0.5) { [weak self] in - guard let self = self else { return } - self.refreshControl.endRefreshing() - } - } - } - .store(in: &disposeBag) + setupSegmentedControl(scopes: viewModel.scopes) + pageSegmentedControl.translatesAutoresizingMaskIntoConstraints = false + navigationItem.titleView = pageSegmentedControl + NSLayoutConstraint.activate([ + pageSegmentedControl.widthAnchor.constraint(greaterThanOrEqualToConstant: 287) + ]) + pageSegmentedControl.addTarget(self, action: #selector(NotificationViewController.pageSegmentedControlValueChanged(_:)), for: .valueChanged) - viewModel.dataSourceDidUpdated - .receive(on: RunLoop.main) - .sink { [weak self] in - guard let self = self else { return } - guard self.viewModel.needsScrollToTopAfterDataSourceUpdate else { return } - self.viewModel.needsScrollToTopAfterDataSourceUpdate = false - DispatchQueue.main.asyncAfter(deadline: .now() + 0.33) { - self.scrollToTop(animated: true) - } - } - .store(in: &disposeBag) - - viewModel.selectedIndex - .removeDuplicates() + dataSource = viewModel + viewModel.$viewControllers .receive(on: DispatchQueue.main) - .sink { [weak self] segment in + .sink { [weak self] viewControllers in guard let self = self else { return } - self.segmentControl.selectedSegmentIndex = segment.rawValue - - // trigger scroll-to-top after data reload - self.viewModel.needsScrollToTopAfterDataSourceUpdate = true + self.reloadData() + self.bounces = viewControllers.count > 1 - guard let domain = self.viewModel.activeMastodonAuthenticationBox.value?.domain, let userID = self.viewModel.activeMastodonAuthenticationBox.value?.userID else { - return - } - - self.viewModel.needsScrollToTopAfterDataSourceUpdate = true - - switch segment { - case .everyThing: - self.viewModel.notificationPredicate.value = MastodonNotification.predicate(domain: domain, userID: userID) - case .mentions: - self.viewModel.notificationPredicate.value = MastodonNotification.predicate(domain: domain, userID: userID, typeRaw: Mastodon.Entity.Notification.NotificationType.mention.rawValue) + } + .store(in: &disposeBag) + + viewModel.viewControllers = viewModel.scopes.map { scope in + createViewController(for: scope) + } + + viewModel.$currentPageIndex + .receive(on: DispatchQueue.main) + .sink { [weak self] currentPageIndex in + guard let self = self else { return } + if self.pageSegmentedControl.selectedSegmentIndex != currentPageIndex { + self.pageSegmentedControl.selectedSegmentIndex = currentPageIndex } } .store(in: &disposeBag) - - segmentControl.observe(\.selectedSegmentIndex, options: [.new]) { [weak self] segmentControl, _ in - guard let self = self else { return } - // scroll to top when select same segment - if segmentControl.selectedSegmentIndex == self.viewModel.selectedIndex.value.rawValue { - self.scrollToTop(animated: true) - } - } - .store(in: &observations) } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - aspectViewWillAppear(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 { - viewModel.loadLatestStateMachine.enter(NotificationViewModel.LoadLatestState.Loading.self) - } +// if tableView.contentOffset.y < view.frame.height * 0.5 { +// viewModel.loadLatestStateMachine.enter(NotificationViewModel.LoadLatestState.Loading.self) +// } // needs trigger manually after onboarding dismiss @@ -174,13 +113,6 @@ extension NotificationViewController { override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - if (self.viewModel.fetchedResultsController.fetchedObjects ?? []).count == 0 { - self.viewModel.loadLatestStateMachine.enter(NotificationViewModel.LoadLatestState.Loading.self) - } - } - // reset notification count context.notificationService.clearNotificationCountForActiveUser() } @@ -195,287 +127,83 @@ extension NotificationViewController { override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) - aspectViewDidDisappear(animated) - } - - override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { - super.viewWillTransition(to: size, with: coordinator) - - coordinator.animate { _ in - // do nothing - } completion: { _ in - self.tableView.reloadData() - } +// aspectViewDidDisappear(animated) } } extension NotificationViewController { - @objc private func segmentedControlValueChanged(_ sender: UISegmentedControl) { - os_log("%{public}s[%{public}ld], %{public}s: select at index: %ld", (#file as NSString).lastPathComponent, #line, #function, sender.selectedSegmentIndex) - - viewModel.selectedIndex.value = NotificationViewModel.NotificationSegment(rawValue: sender.selectedSegmentIndex)! - } - - @objc private func refreshControlValueChanged(_ sender: UIRefreshControl) { - guard viewModel.loadLatestStateMachine.enter(NotificationViewModel.LoadLatestState.Loading.self) else { - sender.endRefreshing() - return + private func setupSegmentedControl(scopes: [NotificationTimelineViewModel.Scope]) { + pageSegmentedControl.removeAllSegments() + for (i, scope) in scopes.enumerated() { + pageSegmentedControl.insertSegment(withTitle: scope.title, at: i, animated: false) } - } -} - -// MARK: - TableViewCellHeightCacheableContainer -extension NotificationViewController: TableViewCellHeightCacheableContainer { - var cellFrameCache: NSCache<NSNumber, NSValue> { 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, _), - .notificationStatus(let objectID, _): - guard let object = try? viewModel.fetchedResultsController.managedObjectContext.existingObject(with: objectID) as? MastodonNotification else { return } - let key = object.objectID.hashValue - let frame = cell.frame - viewModel.cellFrameCache.setObject(NSValue(cgRect: frame), forKey: NSNumber(value: key)) - case .bottomLoader: - break + + // set initial selection + guard !pageSegmentedControl.isSelected else { return } + if viewModel.currentPageIndex < pageSegmentedControl.numberOfSegments { + pageSegmentedControl.selectedSegmentIndex = viewModel.currentPageIndex + } else { + pageSegmentedControl.selectedSegmentIndex = 0 } } - 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, _), - .notificationStatus(let objectID, _): - guard let object = try? viewModel.fetchedResultsController.managedObjectContext.existingObject(with: objectID) as? MastodonNotification 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 - } + private func createViewController(for scope: NotificationTimelineViewModel.Scope) -> UIViewController { + let viewController = NotificationTimelineViewController() + viewController.context = context + viewController.coordinator = coordinator + viewController.viewModel = NotificationTimelineViewModel( + context: context, + scope: scope + ) + return viewController } } - -// MARK: - StatusTableViewControllerAspect -extension NotificationViewController: StatusTableViewControllerAspect { } - -// MARK: - UITableViewDelegate - -extension NotificationViewController: UITableViewDelegate { - - 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 } - 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) { - aspectTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) - } - - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - aspectTableView(tableView, didSelectRowAt: 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) - } - -} - extension NotificationViewController { - private func open(item: NotificationItem) { - switch item { - case .notification(let objectID, _): - let notification = context.managedObjectContext.object(with: objectID) as! MastodonNotification - if let status = notification.status { - let viewModel = ThreadViewModel(context: context, optionalStatus: status) - coordinator.present(scene: .thread(viewModel: viewModel), from: self, transition: .show) - } else { - let viewModel = ProfileViewModel(context: context, optionalMastodonUser: notification.account) - coordinator.present(scene: .profile(viewModel: viewModel), from: self, transition: .show) - } - default: - break - } - } -} - -// MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate - -extension NotificationViewController: ContentOffsetAdjustableTimelineViewControllerDelegate { - func navigationBar() -> UINavigationBar? { - navigationController?.navigationBar - } -} - -// MARK: - NotificationTableViewCellDelegate -extension NotificationViewController: NotificationTableViewCellDelegate { - - func notificationStatusTableViewCell(_ cell: NotificationStatusTableViewCell, avatarImageViewDidPressed imageView: UIImageView) { - guard let diffableDataSource = viewModel.diffableDataSource else { return } - guard let indexPath = tableView.indexPath(for: cell) else { return } - guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } - switch item { - case .notification(let objectID, _): - guard let notification = try? viewModel.fetchedResultsController.managedObjectContext.existingObject(with: objectID) as? MastodonNotification else { return } - let viewModel = ProfileViewModel(context: context, optionalMastodonUser: notification.account) - coordinator.present(scene: .profile(viewModel: viewModel), from: self, transition: .show) - default: - break - } - } - - func notificationStatusTableViewCell(_ cell: NotificationStatusTableViewCell, authorNameLabelDidPressed label: MetaLabel) { - guard let diffableDataSource = viewModel.diffableDataSource else { return } - guard let indexPath = tableView.indexPath(for: cell) else { return } - guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } - switch item { - case .notification(let objectID, _): - guard let notification = try? viewModel.fetchedResultsController.managedObjectContext.existingObject(with: objectID) as? MastodonNotification else { return } - let viewModel = ProfileViewModel(context: context, optionalMastodonUser: notification.account) - coordinator.present(scene: .profile(viewModel: viewModel), from: self, transition: .show) - default: - break - } - } - - func notificationTableViewCell(_ cell: NotificationStatusTableViewCell, notification: MastodonNotification, acceptButtonDidPressed button: UIButton) { - viewModel.acceptFollowRequest(notification: notification) - } - - func notificationTableViewCell(_ cell: NotificationStatusTableViewCell, notification: MastodonNotification, rejectButtonDidPressed button: UIButton) { - viewModel.rejectFollowRequest(notification: notification) - } - - func userNameLabelDidPressed(notification: MastodonNotification) { - let viewModel = CachedProfileViewModel(context: context, mastodonUser: notification.account) - DispatchQueue.main.async { - self.coordinator.present(scene: .profile(viewModel: viewModel), from: self, transition: .show) - } - } - - func parent() -> UIViewController { - self - } - - func notificationStatusTableViewCell(_ cell: NotificationStatusTableViewCell, statusView: StatusView, revealContentWarningButtonDidPressed button: UIButton) { - StatusProviderFacade.responseToStatusContentWarningRevealAction(provider: self, cell: cell) - } - - func notificationStatusTableViewCell(_ cell: NotificationStatusTableViewCell, statusView: StatusView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { - StatusProviderFacade.responseToStatusContentWarningRevealAction(provider: self, cell: cell) - } - - func notificationStatusTableViewCell(_ cell: NotificationStatusTableViewCell, statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { - StatusProviderFacade.responseToStatusContentWarningRevealAction(provider: self, cell: cell) - } - - func notificationStatusTableViewCell(_ cell: NotificationStatusTableViewCell, statusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta) { - StatusProviderFacade.responseToStatusMetaTextAction(provider: self, cell: cell, metaText: metaText, didSelectMeta: meta) - } -} - -// MARK: - UIScrollViewDelegate - -extension NotificationViewController { - func scrollViewDidScroll(_ scrollView: UIScrollView) { - handleScrollViewDidScroll(scrollView) + @objc private func pageSegmentedControlValueChanged(_ sender: UISegmentedControl) { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") + + let index = sender.selectedSegmentIndex + scrollToPage(.at(index: index), animated: true, completion: nil) } } // MARK: - ScrollViewContainer extension NotificationViewController: ScrollViewContainer { - - var scrollView: UIScrollView { tableView } - - func scrollToTop(animated: Bool) { - let indexPath = IndexPath(row: 0, section: 0) - guard viewModel.diffableDataSource?.itemIdentifier(for: indexPath) != nil else { return } - tableView.scrollToRow(at: indexPath, at: .top, animated: true) + var scrollView: UIScrollView? { + guard let viewController = currentViewController as? NotificationTimelineViewController else { + return nil + } + return viewController.scrollView } } -// MARK: - LoadMoreConfigurableTableViewContainer -extension NotificationViewController: LoadMoreConfigurableTableViewContainer { - typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell - typealias LoadingState = NotificationViewModel.LoadOldestState.Loading - var loadMoreConfigurableTableView: UITableView { tableView } - 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 { - case showEverything - case showMentions + case everything + case mentions var title: String { switch self { - case .showEverything: return L10n.Scene.Notification.Keyobard.showEverything - case .showMentions: return L10n.Scene.Notification.Keyobard.showMentions + case .everything: return L10n.Scene.Notification.Keyobard.showEverything + case .mentions: return L10n.Scene.Notification.Keyobard.showMentions } } // UIKeyCommand input var input: String { switch self { - case .showEverything: return "[" // + shift + command - case .showMentions: return "]" // + shift + command + case .everything: return "[" // + shift + command + case .mentions: return "]" // + shift + command } } var modifierFlags: UIKeyModifierFlags { switch self { - case .showEverything: return [.shift, .command] - case .showMentions: return [.shift, .command] + case .everything: return [.shift, .command] + case .mentions: return [.shift, .command] } } @@ -504,100 +232,18 @@ extension NotificationViewController { @objc private func showCategory(_ sender: UIKeyCommand) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) guard let rawValue = sender.propertyList as? String, - let category = CategorySwitch(rawValue: rawValue) else { return } + let category = CategorySwitch(rawValue: rawValue) + else { return } switch category { - case .showEverything: - viewModel.selectedIndex.value = .everyThing - case .showMentions: - viewModel.selectedIndex.value = .mentions + case .everything: + scrollToPage(.first, animated: true, completion: nil) + case .mentions: + scrollToPage(.last, animated: true, completion: nil) } } override var keyCommands: [UIKeyCommand]? { - return categorySwitchKeyCommands + navigationKeyCommands + return categorySwitchKeyCommands } } - -extension NotificationViewController: TableViewControllerNavigateable { - - func navigate(direction: TableViewNavigationDirection) { - if let indexPathForSelectedRow = tableView.indexPathForSelectedRow { - // navigate up/down on the current selected item - navigateToStatus(direction: direction, indexPath: indexPathForSelectedRow) - } else { - // set first visible item selected - navigateToFirstVisibleStatus() - } - } - - private func navigateToStatus(direction: TableViewNavigationDirection, indexPath: IndexPath) { - guard let diffableDataSource = viewModel.diffableDataSource else { return } - let items = diffableDataSource.snapshot().itemIdentifiers - guard let selectedItem = diffableDataSource.itemIdentifier(for: indexPath), - let selectedItemIndex = items.firstIndex(of: selectedItem) else { - return - } - - let _navigateToItem: NotificationItem? = { - var index = selectedItemIndex - while 0..<items.count ~= index { - index = { - switch direction { - case .up: return index - 1 - case .down: return index + 1 - } - }() - guard 0..<items.count ~= index else { return nil } - let item = items[index] - - guard Self.validNavigateableItem(item) else { continue } - return item - } - return nil - }() - - guard let item = _navigateToItem, let indexPath = diffableDataSource.indexPath(for: item) else { return } - let scrollPosition: UITableView.ScrollPosition = overrideNavigationScrollPosition ?? Self.navigateScrollPosition(tableView: tableView, indexPath: indexPath) - tableView.selectRow(at: indexPath, animated: true, scrollPosition: scrollPosition) - } - - private func navigateToFirstVisibleStatus() { - guard let indexPathsForVisibleRows = tableView.indexPathsForVisibleRows else { return } - guard let diffableDataSource = viewModel.diffableDataSource else { return } - - var visibleItems: [NotificationItem] = indexPathsForVisibleRows.sorted().compactMap { indexPath in - guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return nil } - guard Self.validNavigateableItem(item) else { return nil } - return item - } - if indexPathsForVisibleRows.first?.row != 0, visibleItems.count > 1 { - // drop first when visible not the first cell of table - visibleItems.removeFirst() - } - guard let item = visibleItems.first, let indexPath = diffableDataSource.indexPath(for: item) else { return } - let scrollPosition: UITableView.ScrollPosition = overrideNavigationScrollPosition ?? Self.navigateScrollPosition(tableView: tableView, indexPath: indexPath) - tableView.selectRow(at: indexPath, animated: true, scrollPosition: scrollPosition) - } - - static func validNavigateableItem(_ item: NotificationItem) -> Bool { - switch item { - case .notification: - return true - default: - return false - } - } - - func open() { - guard let indexPathForSelectedRow = tableView.indexPathForSelectedRow else { return } - guard let diffableDataSource = viewModel.diffableDataSource else { return } - guard let item = diffableDataSource.itemIdentifier(for: indexPathForSelectedRow) else { return } - open(item: item) - } - - func navigateKeyCommandHandlerRelay(_ sender: UIKeyCommand) { - navigateKeyCommandHandler(sender) - } - -} diff --git a/Mastodon/Scene/Notification/NotificationViewModel+Diffable.swift b/Mastodon/Scene/Notification/NotificationViewModel+Diffable.swift deleted file mode 100644 index 6c7a70e43..000000000 --- a/Mastodon/Scene/Notification/NotificationViewModel+Diffable.swift +++ /dev/null @@ -1,117 +0,0 @@ -// -// NotificationViewModel+Diffable.swift -// Mastodon -// -// Created by sxiaojian on 2021/4/13. -// - -import CoreData -import CoreDataStack -import os.log -import UIKit -import MastodonSDK - -extension NotificationViewModel { - func setupDiffableDataSource( - for tableView: UITableView, - dependency: NeedsDependency, - delegate: NotificationTableViewCellDelegate, - statusTableViewCellDelegate: StatusTableViewCellDelegate - ) { - diffableDataSource = NotificationSection.tableViewDiffableDataSource( - for: tableView, - dependency: dependency, - managedObjectContext: fetchedResultsController.managedObjectContext, - delegate: delegate, - statusTableViewCellDelegate: statusTableViewCellDelegate - ) - - var snapshot = NSDiffableDataSourceSnapshot<NotificationSection, NotificationItem>() - snapshot.appendSections([.main]) - diffableDataSource.apply(snapshot) - - // workaround to append loader wrong animation issue - snapshot.appendItems([.bottomLoader], toSection: .main) - diffableDataSource.apply(snapshot) - } -} - -extension NotificationViewModel: NSFetchedResultsControllerDelegate { - func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) { - os_log("%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function) - } - - func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { - os_log("%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function) - - guard let tableView = self.tableView else { return } - guard let navigationBar = contentOffsetAdjustableTimelineViewControllerDelegate?.navigationBar() else { return } - - guard let diffableDataSource = self.diffableDataSource else { return } - - let predicate: NSPredicate = { - let notificationTypePredicate = MastodonNotification.predicate( - validTypesRaws: Mastodon.Entity.Notification.NotificationType.knownCases.map { $0.rawValue } - ) - return fetchedResultsController.fetchRequest.predicate.flatMap { - NSCompoundPredicate(andPredicateWithSubpredicates: [$0, notificationTypePredicate]) - } ?? notificationTypePredicate - }() - let parentManagedObjectContext = fetchedResultsController.managedObjectContext - let managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) - managedObjectContext.parent = parentManagedObjectContext - - managedObjectContext.perform { - let notifications: [MastodonNotification] = { - let request = MastodonNotification.sortedFetchRequest - request.returnsObjectsAsFaults = false - request.predicate = predicate - do { - return try managedObjectContext.fetch(request) - } catch { - assertionFailure(error.localizedDescription) - return [] - } - }() - - DispatchQueue.main.async { - let oldSnapshot = diffableDataSource.snapshot() - var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:] - for item in oldSnapshot.itemIdentifiers { - guard case let .notification(objectID, attribute) = item else { continue } - oldSnapshotAttributeDict[objectID] = attribute - } - var newSnapshot = NSDiffableDataSourceSnapshot<NotificationSection, NotificationItem>() - newSnapshot.appendSections([.main]) - - 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) - } - - if !notifications.isEmpty, self.noMoreNotification.value == false { - newSnapshot.appendItems([.bottomLoader], toSection: .main) - } - - self.isFetchingLatestNotification.value = false - - diffableDataSource.apply(newSnapshot, animatingDifferences: false) { [weak self] in - guard let self = self else { return } - self.dataSourceDidUpdated.send() - } - } - } - } - -} diff --git a/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift b/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift deleted file mode 100644 index dac7bb7d3..000000000 --- a/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift +++ /dev/null @@ -1,95 +0,0 @@ -// -// NotificationViewModel+LoadLatestState.swift -// Mastodon -// -// Created by sxiaojian on 2021/4/13. -// - -import CoreData -import CoreDataStack -import Foundation -import GameplayKit -import MastodonSDK -import os.log -import func QuartzCore.CACurrentMediaTime - -extension NotificationViewModel { - class LoadLatestState: GKState { - weak var viewModel: NotificationViewModel? - - init(viewModel: NotificationViewModel) { - 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, debugDescription, previousState.debugDescription) - viewModel?.loadLatestStateMachinePublisher.send(self) - } - } -} - -extension NotificationViewModel.LoadLatestState { - class Initial: NotificationViewModel.LoadLatestState { - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - stateClass == Loading.self - } - } - - class Loading: NotificationViewModel.LoadLatestState { - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - stateClass == Fail.self || stateClass == Idle.self - } - - override func didEnter(from previousState: GKState?) { - super.didEnter(from: previousState) - guard let viewModel = viewModel, let stateMachine = stateMachine else { return } - guard let activeMastodonAuthenticationBox = viewModel.activeMastodonAuthenticationBox.value else { - // sign out when loading will enter here - stateMachine.enter(Fail.self) - return - } - let query = Mastodon.API.Notifications.Query( - maxID: nil, - sinceID: nil, - minID: nil, - limit: nil, - excludeTypes: [], - accountID: nil - ) - viewModel.context.apiService.allNotifications( - domain: activeMastodonAuthenticationBox.domain, - query: query, - mastodonAuthenticationBox: activeMastodonAuthenticationBox - ) - .sink { completion in - switch completion { - case .failure(let error): - 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: - // handle isFetchingLatestTimeline in fetch controller delegate - break - } - - stateMachine.enter(Idle.self) - } receiveValue: { response in - if response.value.isEmpty { - viewModel.isFetchingLatestNotification.value = false - } - } - .store(in: &viewModel.disposeBag) - } - } - - class Fail: NotificationViewModel.LoadLatestState { - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - stateClass == Loading.self || stateClass == Idle.self - } - } - - class Idle: NotificationViewModel.LoadLatestState { - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - stateClass == Loading.self - } - } -} diff --git a/Mastodon/Scene/Notification/NotificationViewModel+LoadOldestState.swift b/Mastodon/Scene/Notification/NotificationViewModel+LoadOldestState.swift deleted file mode 100644 index bf2c03174..000000000 --- a/Mastodon/Scene/Notification/NotificationViewModel+LoadOldestState.swift +++ /dev/null @@ -1,146 +0,0 @@ -// -// NotificationViewModel+LoadOldestState.swift -// Mastodon -// -// Created by sxiaojian on 2021/4/14. -// - -import CoreDataStack -import Foundation -import GameplayKit -import MastodonSDK -import os.log - -extension NotificationViewModel { - class LoadOldestState: GKState { - weak var viewModel: NotificationViewModel? - - init(viewModel: NotificationViewModel) { - 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, debugDescription, previousState.debugDescription) - viewModel?.loadOldestStateMachinePublisher.send(self) - } - } -} - -extension NotificationViewModel.LoadOldestState { - class Initial: NotificationViewModel.LoadOldestState { - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - guard let viewModel = viewModel else { return false } - guard !(viewModel.fetchedResultsController.fetchedObjects ?? []).isEmpty else { return false } - return stateClass == Loading.self - } - } - - class Loading: NotificationViewModel.LoadOldestState { - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - stateClass == Fail.self || stateClass == Idle.self || stateClass == NoMore.self - } - - override func didEnter(from previousState: GKState?) { - super.didEnter(from: previousState) - guard let viewModel = viewModel, let stateMachine = stateMachine else { return } - guard let activeMastodonAuthenticationBox = viewModel.activeMastodonAuthenticationBox.value else { - assertionFailure() - stateMachine.enter(Fail.self) - return - } - let notifications: [MastodonNotification]? = { - let request = MastodonNotification.sortedFetchRequest - request.predicate = MastodonNotification.predicate(domain: activeMastodonAuthenticationBox.domain, userID: activeMastodonAuthenticationBox.userID) - request.returnsObjectsAsFaults = false - do { - return try self.viewModel?.context.managedObjectContext.fetch(request) - } catch { - assertionFailure(error.localizedDescription) - return nil - } - }() - - guard let last = notifications?.last else { - stateMachine.enter(Idle.self) - return - } - - let maxID = last.id - let query = Mastodon.API.Notifications.Query( - maxID: maxID, - sinceID: nil, - minID: nil, - limit: nil, - excludeTypes: [], - accountID: nil - ) - viewModel.context.apiService.allNotifications( - domain: activeMastodonAuthenticationBox.domain, - query: query, - mastodonAuthenticationBox: activeMastodonAuthenticationBox - ) - .sink { completion in - switch completion { - case .failure(let error): - 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: - // handle isFetchingLatestTimeline in fetch controller delegate - break - } - - stateMachine.enter(Idle.self) - } receiveValue: { [weak viewModel] response in - guard let viewModel = viewModel else { return } - switch viewModel.selectedIndex.value { - case .everyThing: - if response.value.isEmpty { - stateMachine.enter(NoMore.self) - } else { - stateMachine.enter(Idle.self) - } - case .mentions: - viewModel.noMoreNotification.value = response.value.isEmpty - let list = response.value.filter { $0.type == Mastodon.Entity.Notification.NotificationType.mention } - if list.isEmpty { - stateMachine.enter(NoMore.self) - } else { - stateMachine.enter(Idle.self) - } - } - } - .store(in: &viewModel.disposeBag) - } - } - - class Fail: NotificationViewModel.LoadOldestState { - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - stateClass == Loading.self || stateClass == Idle.self - } - } - - class Idle: NotificationViewModel.LoadOldestState { - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - stateClass == Loading.self - } - } - - class NoMore: NotificationViewModel.LoadOldestState { - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - // reset state if needs - stateClass == Idle.self - } - - override func didEnter(from previousState: GKState?) { - guard let viewModel = viewModel else { return } - guard let diffableDataSource = viewModel.diffableDataSource else { - assertionFailure() - return - } - DispatchQueue.main.async { - var snapshot = diffableDataSource.snapshot() - snapshot.deleteItems([.bottomLoader]) - diffableDataSource.apply(snapshot) - } - } - } -} diff --git a/Mastodon/Scene/Notification/NotificationViewModel.swift b/Mastodon/Scene/Notification/NotificationViewModel.swift index 98b7deec3..3d4fa6042 100644 --- a/Mastodon/Scene/Notification/NotificationViewModel.swift +++ b/Mastodon/Scene/Notification/NotificationViewModel.swift @@ -5,163 +5,94 @@ // Created by sxiaojian on 2021/4/12. // -import Combine -import CoreData -import CoreDataStack -import Foundation -import GameplayKit -import MastodonSDK +import os.log import UIKit -import OSLog +import Combine +import MastodonAsset +import MastodonLocalization +import Pageboy -final class NotificationViewModel: NSObject { +final class NotificationViewModel { + var disposeBag = Set<AnyCancellable>() // input let context: AppContext - weak var tableView: UITableView? - weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate? - let viewDidLoad = PassthroughSubject<Void, Never>() - let selectedIndex = CurrentValueSubject<NotificationSegment, Never>(.everyThing) - let noMoreNotification = CurrentValueSubject<Bool, Never>(false) - - let activeMastodonAuthenticationBox: CurrentValueSubject<MastodonAuthenticationBox?, Never> - let fetchedResultsController: NSFetchedResultsController<MastodonNotification>! - let notificationPredicate = CurrentValueSubject<NSPredicate?, Never>(nil) - let cellFrameCache = NSCache<NSNumber, NSValue>() - - var needsScrollToTopAfterDataSourceUpdate = false - let dataSourceDidUpdated = PassthroughSubject<Void, Never>() - let isFetchingLatestNotification = CurrentValueSubject<Bool, Never>(false) // output - var diffableDataSource: UITableViewDiffableDataSource<NotificationSection, NotificationItem>! - - // top loader - private(set) lazy var loadLatestStateMachine: GKStateMachine = { - // exclude timeline middle fetcher state - let stateMachine = GKStateMachine(states: [ - LoadLatestState.Initial(viewModel: self), - LoadLatestState.Loading(viewModel: self), - LoadLatestState.Fail(viewModel: self), - LoadLatestState.Idle(viewModel: self), - ]) - stateMachine.enter(LoadLatestState.Initial.self) - return stateMachine - }() - - lazy var loadLatestStateMachinePublisher = CurrentValueSubject<LoadLatestState?, Never>(nil) - - // bottom loader - private(set) lazy var loadOldestStateMachine: GKStateMachine = { - // exclude timeline middle fetcher state - let stateMachine = GKStateMachine(states: [ - LoadOldestState.Initial(viewModel: self), - LoadOldestState.Loading(viewModel: self), - LoadOldestState.Fail(viewModel: self), - LoadOldestState.Idle(viewModel: self), - LoadOldestState.NoMore(viewModel: self), - ]) - stateMachine.enter(LoadOldestState.Initial.self) - return stateMachine - }() + let scopes = NotificationTimelineViewModel.Scope.allCases + @Published var viewControllers: [UIViewController] = [] + @Published var currentPageIndex = 0 - lazy var loadOldestStateMachinePublisher = CurrentValueSubject<LoadOldestState?, Never>(nil) init(context: AppContext) { self.context = context - self.activeMastodonAuthenticationBox = CurrentValueSubject(context.authenticationService.activeMastodonAuthenticationBox.value) - self.fetchedResultsController = { - let fetchRequest = MastodonNotification.sortedFetchRequest - fetchRequest.returnsObjectsAsFaults = false - fetchRequest.fetchBatchSize = 10 - fetchRequest.relationshipKeyPathsForPrefetching = [#keyPath(MastodonNotification.status), #keyPath(MastodonNotification.account)] - let controller = NSFetchedResultsController( - fetchRequest: fetchRequest, - managedObjectContext: context.managedObjectContext, - sectionNameKeyPath: nil, - cacheName: nil - ) - - return controller - }() - - super.init() - fetchedResultsController.delegate = self - context.authenticationService.activeMastodonAuthenticationBox - .sink(receiveValue: { [weak self] box in - guard let self = self else { return } - self.activeMastodonAuthenticationBox.value = box - if let domain = box?.domain, let userID = box?.userID { - self.notificationPredicate.value = MastodonNotification.predicate(domain: domain, userID: userID) - } - }) - .store(in: &disposeBag) - - notificationPredicate - .compactMap { $0 } - .sink { [weak self] predicate in - guard let self = self else { return } - self.fetchedResultsController.fetchRequest.predicate = predicate - do { - self.diffableDataSource?.defaultRowAnimation = .fade - try self.fetchedResultsController.performFetch() - DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in - guard let self = self else { return } - self.diffableDataSource?.defaultRowAnimation = .automatic - } - } catch { - assertionFailure(error.localizedDescription) - } - } - .store(in: &disposeBag) - - viewDidLoad - .sink { [weak self] in - - guard let domain = self?.activeMastodonAuthenticationBox.value?.domain, let userID = self?.activeMastodonAuthenticationBox.value?.userID else { return } - self?.notificationPredicate.value = MastodonNotification.predicate(domain: domain, userID: userID) - } - .store(in: &disposeBag) + // end init } +} - func acceptFollowRequest(notification: MastodonNotification) { - guard let activeMastodonAuthenticationBox = self.activeMastodonAuthenticationBox.value else { return } - context.apiService.acceptFollowRequest(mastodonUserID: notification.account.id, mastodonAuthenticationBox: activeMastodonAuthenticationBox) - .sink { [weak self] completion in - switch completion { - case .failure(let error): - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: accept FollowRequest fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) - case .finished: - self?.loadLatestStateMachine.enter(NotificationViewModel.LoadLatestState.Loading.self) - } - } receiveValue: { _ in - - } - .store(in: &disposeBag) - } - - func rejectFollowRequest(notification: MastodonNotification) { - guard let activeMastodonAuthenticationBox = self.activeMastodonAuthenticationBox.value else { return } - context.apiService.rejectFollowRequest(mastodonUserID: notification.account.id, mastodonAuthenticationBox: activeMastodonAuthenticationBox) - .sink { [weak self] completion in - switch completion { - case .failure(let error): - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: reject FollowRequest fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) - case .finished: - self?.loadLatestStateMachine.enter(NotificationViewModel.LoadLatestState.Loading.self) - } - } receiveValue: { _ in - - } - .store(in: &disposeBag) +extension NotificationTimelineViewModel.Scope { + var title: String { + switch self { + case .everything: + return L10n.Scene.Notification.Title.everything + case .mentions: + return L10n.Scene.Notification.Title.mentions + } } } -extension NotificationViewModel { - enum NotificationSegment: Int { - case everyThing - case mentions +// func acceptFollowRequest(notification: MastodonNotification) { +// guard let activeMastodonAuthenticationBox = self.activeMastodonAuthenticationBox.value else { return } +// context.apiService.acceptFollowRequest(mastodonUserID: notification.account.id, mastodonAuthenticationBox: activeMastodonAuthenticationBox) +// .sink { [weak self] completion in +// switch completion { +// case .failure(let error): +// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: accept FollowRequest fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) +// case .finished: +// break +//// self?.loadLatestStateMachine.enter(NotificationViewModel.LoadLatestState.Loading.self) +// } +// } receiveValue: { _ in +// +// } +// .store(in: &disposeBag) +// } +// +// func rejectFollowRequest(notification: MastodonNotification) { +// guard let activeMastodonAuthenticationBox = self.activeMastodonAuthenticationBox.value else { return } +// context.apiService.rejectFollowRequest(mastodonUserID: notification.account.id, mastodonAuthenticationBox: activeMastodonAuthenticationBox) +// .sink { [weak self] completion in +// switch completion { +// case .failure(let error): +// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: reject FollowRequest fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) +// case .finished: +// break +//// self?.loadLatestStateMachine.enter(NotificationViewModel.LoadLatestState.Loading.self) +// } +// } receiveValue: { _ in +// +// } +// .store(in: &disposeBag) +// } +//} + + +// MARK: - PageboyViewControllerDataSource +extension NotificationViewModel: PageboyViewControllerDataSource { + + func numberOfViewControllers(in pageboyViewController: PageboyViewController) -> Int { + return viewControllers.count } + + func viewController(for pageboyViewController: PageboyViewController, at index: PageboyViewController.PageIndex) -> UIViewController? { + return viewControllers[index] + } + + func defaultPage(for pageboyViewController: PageboyViewController) -> PageboyViewController.Page? { + return .first + } + } + diff --git a/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift b/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift deleted file mode 100644 index 1712468a9..000000000 --- a/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift +++ /dev/null @@ -1,373 +0,0 @@ -// -// NotificationStatusTableViewCell.swift -// Mastodon -// -// Created by sxiaojian on 2021/4/14. -// - -import os.log -import Combine -import Foundation -import CoreDataStack -import UIKit -import MetaTextKit -import Meta -import FLAnimatedImage - -protocol NotificationTableViewCellDelegate: AnyObject { - var context: AppContext! { get } - func parent() -> UIViewController - - func notificationStatusTableViewCell(_ cell: NotificationStatusTableViewCell, avatarImageViewDidPressed imageView: UIImageView) - func notificationStatusTableViewCell(_ cell: NotificationStatusTableViewCell, authorNameLabelDidPressed label: MetaLabel) - - func notificationStatusTableViewCell(_ cell: NotificationStatusTableViewCell, statusView: StatusView, revealContentWarningButtonDidPressed button: UIButton) - func notificationStatusTableViewCell(_ cell: NotificationStatusTableViewCell, statusView: StatusView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) - func notificationStatusTableViewCell(_ cell: NotificationStatusTableViewCell, statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) - func notificationStatusTableViewCell(_ cell: NotificationStatusTableViewCell, statusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta) - - func notificationTableViewCell(_ cell: NotificationStatusTableViewCell, notification: MastodonNotification, acceptButtonDidPressed button: UIButton) - func notificationTableViewCell(_ cell: NotificationStatusTableViewCell, notification: MastodonNotification, rejectButtonDidPressed button: UIButton) - -} - -final class NotificationStatusTableViewCell: UITableViewCell, StatusCell { - - static let actionImageBorderWidth: CGFloat = 2 - static let statusPadding = UIEdgeInsets(top: 50, left: 73, bottom: 24, right: 24) - static let actionImageViewSize = CGSize(width: 24, height: 24) - - var disposeBag = Set<AnyCancellable>() - var pollCountdownSubscription: AnyCancellable? - var delegate: NotificationTableViewCellDelegate? - - var containerStackViewBottomLayoutConstraint: NSLayoutConstraint! - let containerStackView = UIStackView() - - let avatarButton = NotificationAvatarButton() - let traitCollectionDidChange = PassthroughSubject<Void, Never>() - - let contentStackView = UIStackView() - - let titleLabel = MetaLabel(style: .notificationTitle) - - let dotLabel: UILabel = { - let label = UILabel() - label.textColor = Asset.Colors.Label.secondary.color - label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15, weight: .regular), maximumPointSize: 20) - label.text = "·" - return label - }() - let timestampLabel: UILabel = { - let label = UILabel() - label.textColor = Asset.Colors.Label.secondary.color - label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15, weight: .regular), maximumPointSize: 20) - return label - }() - - - let buttonStackView = UIStackView() - - let acceptButton: UIButton = { - let button = UIButton(type: .custom) - let actionImage = UIImage(systemName: "checkmark.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 28, weight: .semibold))?.withRenderingMode(.alwaysTemplate) - button.setImage(actionImage, for: .normal) - button.tintColor = Asset.Colors.Label.secondary.color - return button - }() - - let rejectButton: UIButton = { - let button = UIButton(type: .custom) - let actionImage = UIImage(systemName: "xmark.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 28, weight: .semibold))?.withRenderingMode(.alwaysTemplate) - button.setImage(actionImage, for: .normal) - button.tintColor = Asset.Colors.Label.secondary.color - return button - }() - - let statusContainerView: UIView = { - let view = UIView() - view.layer.masksToBounds = true - view.layer.cornerRadius = 6 - view.layer.cornerCurve = .continuous - view.layer.borderWidth = 2 - view.layer.borderColor = ThemeService.shared.currentTheme.value.notificationStatusBorderColor.cgColor - return view - }() - let statusView = StatusView() - - let separatorLine = UIView.separatorLine - - var separatorLineToEdgeLeadingLayoutConstraint: NSLayoutConstraint! - var separatorLineToEdgeTrailingLayoutConstraint: NSLayoutConstraint! - - var separatorLineToMarginLeadingLayoutConstraint: NSLayoutConstraint! - var separatorLineToMarginTrailingLayoutConstraint: NSLayoutConstraint! - - var isFiltered: Bool = false { - didSet { - configure(isFiltered: isFiltered) - } - } - - let filteredLabel: UILabel = { - let label = UILabel() - label.textColor = Asset.Colors.Label.secondary.color - label.text = L10n.Common.Controls.Timeline.filtered - label.font = .preferredFont(forTextStyle: .body) - return label - }() - - override func prepareForReuse() { - super.prepareForReuse() - isFiltered = false - statusView.updateContentWarningDisplay(isHidden: true, animated: false) - statusView.pollTableView.dataSource = nil - statusView.playerContainerView.reset() - statusView.playerContainerView.isHidden = true - disposeBag.removeAll() - } - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - configure() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - configure() - } - -} - -extension NotificationStatusTableViewCell { - func configure() { - containerStackView.axis = .horizontal - containerStackView.alignment = .top - containerStackView.distribution = .fill - containerStackView.spacing = 14 + 2 // 2pt for status container outline border - containerStackView.layoutMargins = UIEdgeInsets(top: 14, left: 0, bottom: 12, right: 0) - containerStackView.isLayoutMarginsRelativeArrangement = true - containerStackView.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(containerStackView) - containerStackViewBottomLayoutConstraint = contentView.bottomAnchor.constraint(equalTo: containerStackView.bottomAnchor) - NSLayoutConstraint.activate([ - containerStackView.topAnchor.constraint(equalTo: contentView.topAnchor), - containerStackView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), - contentView.readableContentGuide.trailingAnchor.constraint(equalTo: containerStackView.trailingAnchor), - containerStackViewBottomLayoutConstraint.priority(.required - 1), - ]) - - avatarButton.translatesAutoresizingMaskIntoConstraints = false - containerStackView.addArrangedSubview(avatarButton) - NSLayoutConstraint.activate([ - avatarButton.heightAnchor.constraint(equalToConstant: NotificationAvatarButton.containerSize.width).priority(.required - 1), - avatarButton.widthAnchor.constraint(equalToConstant: NotificationAvatarButton.containerSize.height).priority(.required - 1), - ]) - - containerStackView.addArrangedSubview(contentStackView) - contentStackView.axis = .vertical - contentStackView.spacing = 6 - - // header - let actionStackView = UIStackView() - contentStackView.addArrangedSubview(actionStackView) - actionStackView.axis = .horizontal - actionStackView.distribution = .fill - actionStackView.spacing = 4 - - actionStackView.addArrangedSubview(titleLabel) - actionStackView.addArrangedSubview(dotLabel) - actionStackView.addArrangedSubview(timestampLabel) - let timestampPaddingView = UIView() - actionStackView.addArrangedSubview(timestampPaddingView) - titleLabel.setContentHuggingPriority(.required - 3, for: .horizontal) - titleLabel.setContentHuggingPriority(.required - 1, for: .vertical) - titleLabel.setContentCompressionResistancePriority(.required - 3, for: .horizontal) - titleLabel.setContentCompressionResistancePriority(.required - 1, for: .vertical) - dotLabel.setContentHuggingPriority(.required - 2, for: .horizontal) - dotLabel.setContentCompressionResistancePriority(.required - 2, for: .horizontal) - timestampLabel.setContentHuggingPriority(.required - 1, for: .horizontal) - timestampLabel.setContentCompressionResistancePriority(.required - 1, for: .horizontal) - timestampPaddingView.setContentHuggingPriority(.defaultLow, for: .horizontal) - - // follow request - contentStackView.addArrangedSubview(buttonStackView) - buttonStackView.addArrangedSubview(acceptButton) - buttonStackView.addArrangedSubview(rejectButton) - buttonStackView.axis = .horizontal - buttonStackView.distribution = .fillEqually - - // status - contentStackView.addArrangedSubview(statusContainerView) - statusContainerView.layoutMargins = UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12) - statusView.translatesAutoresizingMaskIntoConstraints = false - statusContainerView.addSubview(statusView) - NSLayoutConstraint.activate([ - statusView.topAnchor.constraint(equalTo: statusContainerView.layoutMarginsGuide.topAnchor), - statusView.leadingAnchor.constraint(equalTo: statusContainerView.layoutMarginsGuide.leadingAnchor), - statusView.trailingAnchor.constraint(equalTo: statusContainerView.layoutMarginsGuide.trailingAnchor), - statusView.bottomAnchor.constraint(equalTo: statusContainerView.layoutMarginsGuide.bottomAnchor), - ]) - - setupBackgroundColor(theme: ThemeService.shared.currentTheme.value) - ThemeService.shared.currentTheme - .receive(on: RunLoop.main) - .sink { [weak self] theme in - guard let self = self else { return } - self.setupBackgroundColor(theme: theme) - } - .store(in: &disposeBag) - // remove item don't display - statusView.actionToolbarContainer.removeFromStackView() - // it affect stackView's height, need remove - statusView.headerContainerView.removeFromStackView() - - // adaptive separator - separatorLine.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(separatorLine) - separatorLineToEdgeLeadingLayoutConstraint = separatorLine.leadingAnchor.constraint(equalTo: contentView.leadingAnchor) - separatorLineToEdgeTrailingLayoutConstraint = separatorLine.trailingAnchor.constraint(equalTo: contentView.trailingAnchor) - separatorLineToMarginLeadingLayoutConstraint = separatorLine.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor) - separatorLineToMarginTrailingLayoutConstraint = separatorLine.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor) - NSLayoutConstraint.activate([ - separatorLine.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - separatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView)), - ]) - - filteredLabel.translatesAutoresizingMaskIntoConstraints = false - addSubview(filteredLabel) - NSLayoutConstraint.activate([ - filteredLabel.centerXAnchor.constraint(equalTo: statusContainerView.centerXAnchor), - filteredLabel.centerYAnchor.constraint(equalTo: statusContainerView.centerYAnchor), - ]) - filteredLabel.isHidden = true - - statusView.delegate = self - - avatarButton.addTarget(self, action: #selector(NotificationStatusTableViewCell.avatarButtonDidPressed(_:)), for: .touchUpInside) - let authorNameLabelTapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer - authorNameLabelTapGestureRecognizer.addTarget(self, action: #selector(NotificationStatusTableViewCell.authorNameLabelTapGestureRecognizerHandler(_:))) - titleLabel.addGestureRecognizer(authorNameLabelTapGestureRecognizer) - - resetSeparatorLineLayout() - - 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) - } - - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - - resetSeparatorLineLayout() - setupBackgroundColor(theme: ThemeService.shared.currentTheme.value) - traitCollectionDidChange.send() - } - - private func configure(isFiltered: Bool) { - statusView.alpha = isFiltered ? 0 : 1 - filteredLabel.isHidden = !isFiltered - isUserInteractionEnabled = !isFiltered - } -} - -extension NotificationStatusTableViewCell { - - private func setupBackgroundColor(theme: Theme) { - statusContainerView.layer.borderColor = theme.notificationStatusBorderColor.resolvedColor(with: traitCollection).cgColor - statusContainerView.backgroundColor = UIColor(dynamicProvider: { traitCollection in - return traitCollection.userInterfaceStyle == .light ? theme.systemBackgroundColor : theme.tertiarySystemGroupedBackgroundColor - }) - } - -} - -extension NotificationStatusTableViewCell { - @objc private func avatarButtonDidPressed(_ sender: UIButton) { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - delegate?.notificationStatusTableViewCell(self, avatarImageViewDidPressed: avatarButton.avatarImageView) - } - - @objc private func authorNameLabelTapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - delegate?.notificationStatusTableViewCell(self, authorNameLabelDidPressed: titleLabel) - } -} - -// MARK: - StatusViewDelegate -extension NotificationStatusTableViewCell: StatusViewDelegate { - - func statusView(_ statusView: StatusView, headerInfoLabelDidPressed label: UILabel) { - // do nothing - } - - func statusView(_ statusView: StatusView, avatarImageViewDidPressed imageView: UIImageView) { - // do nothing - } - - func statusView(_ statusView: StatusView, revealContentWarningButtonDidPressed button: UIButton) { - delegate?.notificationStatusTableViewCell(self, statusView: statusView, revealContentWarningButtonDidPressed: button) - } - - func statusView(_ statusView: StatusView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { - delegate?.notificationStatusTableViewCell(self, statusView: statusView, contentWarningOverlayViewDidPressed: contentWarningOverlayView) - } - - func statusView(_ statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { - delegate?.notificationStatusTableViewCell(self, statusView: statusView, playerContainerView: playerContainerView, contentWarningOverlayViewDidPressed: contentWarningOverlayView) - } - - func statusView(_ statusView: StatusView, pollVoteButtonPressed button: UIButton) { - // do nothing - } - - func statusView(_ statusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta) { - delegate?.notificationStatusTableViewCell(self, statusView: statusView, metaText: metaText, didSelectMeta: meta) - } - -} - -extension NotificationStatusTableViewCell { - - private func resetSeparatorLineLayout() { - separatorLineToEdgeLeadingLayoutConstraint.isActive = false - separatorLineToEdgeTrailingLayoutConstraint.isActive = false - separatorLineToMarginLeadingLayoutConstraint.isActive = false - separatorLineToMarginTrailingLayoutConstraint.isActive = false - - if traitCollection.userInterfaceIdiom == .phone { - // to edge - NSLayoutConstraint.activate([ - separatorLineToEdgeLeadingLayoutConstraint, - separatorLineToEdgeTrailingLayoutConstraint, - ]) - } else { - if traitCollection.horizontalSizeClass == .compact { - // to edge - NSLayoutConstraint.activate([ - separatorLineToEdgeLeadingLayoutConstraint, - separatorLineToEdgeTrailingLayoutConstraint, - ]) - } else { - // to margin - NSLayoutConstraint.activate([ - separatorLineToMarginLeadingLayoutConstraint, - separatorLineToMarginTrailingLayoutConstraint, - ]) - } - } - } - -} - -// MARK: - AvatarConfigurableView -extension NotificationStatusTableViewCell: AvatarConfigurableView { - static var configurableAvatarImageSize: CGSize { CGSize(width: 35, height: 35) } - static var configurableAvatarImageCornerRadius: CGFloat { 4 } - var configurableAvatarImageView: FLAnimatedImageView? { avatarButton.avatarImageView } -} diff --git a/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewController.swift b/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewController.swift index 0718938f6..b1b2280d8 100644 --- a/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewController.swift +++ b/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewController.swift @@ -10,6 +10,8 @@ import MastodonSDK import os.log import ThirdPartyMailer import UIKit +import MastodonAsset +import MastodonLocalization final class MastodonConfirmEmailViewController: UIViewController, NeedsDependency { @@ -34,7 +36,7 @@ final class MastodonConfirmEmailViewController: UIViewController, NeedsDependenc let label = UILabel() label.font = UIFontMetrics(forTextStyle: .title1).scaledFont(for: UIFont.systemFont(ofSize: 20)) label.textColor = .secondaryLabel - label.text = L10n.Scene.ConfirmEmail.subtitle(viewModel.email) + label.text = L10n.Scene.ConfirmEmail.subtitle label.numberOfLines = 0 return label }() @@ -46,21 +48,11 @@ final class MastodonConfirmEmailViewController: UIViewController, NeedsDependenc imageView.contentMode = .scaleAspectFit return imageView }() - - let openEmailButton: UIButton = { - let button = PrimaryActionButton() - button.setTitle(L10n.Scene.ConfirmEmail.Button.openEmailApp, for: .normal) - button.addTarget(self, action: #selector(openEmailButtonPressed(_:)), for: UIControl.Event.touchUpInside) - return button - }() - - let dontReceiveButton: UIButton = { - let button = UIButton(type: .system) - button.titleLabel?.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: UIFont.boldSystemFont(ofSize: 15)) - button.setTitleColor(Asset.Colors.brandBlue.color, for: .normal) - button.setTitle(L10n.Scene.ConfirmEmail.Button.dontReceiveEmail, for: .normal) - button.addTarget(self, action: #selector(dontReceiveButtonPressed(_:)), for: UIControl.Event.touchUpInside) - return button + + let navigationActionView: NavigationActionView = { + let navigationActionView = NavigationActionView() + navigationActionView.backgroundColor = Asset.Scene.Onboarding.background.color + return navigationActionView }() deinit { @@ -73,6 +65,8 @@ extension MastodonConfirmEmailViewController { override func viewDidLoad() { + navigationItem.leftBarButtonItem = UIBarButtonItem() + setupOnboardingAppearance() configureTitleLabel() configureMargin() @@ -83,13 +77,12 @@ extension MastodonConfirmEmailViewController { stackView.spacing = 10 stackView.layoutMargins = UIEdgeInsets(top: 10, left: 0, bottom: 23, right: 0) stackView.isLayoutMarginsRelativeArrangement = true - stackView.addArrangedSubview(self.largeTitleLabel) - stackView.addArrangedSubview(self.subtitleLabel) - stackView.addArrangedSubview(self.emailImageView) + stackView.addArrangedSubview(largeTitleLabel) + stackView.addArrangedSubview(subtitleLabel) + stackView.addArrangedSubview(emailImageView) emailImageView.setContentHuggingPriority(.defaultLow, for: .vertical) emailImageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical) - stackView.addArrangedSubview(self.openEmailButton) - stackView.addArrangedSubview(self.dontReceiveButton) + stackView.addArrangedSubview(navigationActionView) view.addSubview(stackView) stackView.translatesAutoresizingMaskIntoConstraints = false @@ -99,10 +92,7 @@ extension MastodonConfirmEmailViewController { stackView.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor), stackView.bottomAnchor.constraint(equalTo: view.readableContentGuide.bottomAnchor), ]) - NSLayoutConstraint.activate([ - self.openEmailButton.heightAnchor.constraint(equalToConstant: 46), - ]) - + self.viewModel.timestampUpdatePublisher .sink { [weak self] _ in guard let self = self else { return } @@ -114,24 +104,27 @@ extension MastodonConfirmEmailViewController { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: swap user access token swap fail: %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) case .finished: // upload avatar and set display name in the background - self.context.apiService.accountUpdateCredentials( - domain: self.viewModel.authenticateInfo.domain, - query: self.viewModel.updateCredentialQuery, - authorization: Mastodon.API.OAuth.Authorization(accessToken: self.viewModel.userToken.accessToken) - ) - .retry(3) - .sink { completion in - switch completion { - case .failure(let error): - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: setup avatar & display name fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) - case .finished: - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: setup avatar & display name success", ((#file as NSString).lastPathComponent), #line, #function) + Just(self.viewModel.userToken.accessToken) + .asyncMap { token in + try await self.context.apiService.accountUpdateCredentials( + domain: self.viewModel.authenticateInfo.domain, + query: self.viewModel.updateCredentialQuery, + authorization: Mastodon.API.OAuth.Authorization(accessToken: token) + ) } - } receiveValue: { _ in - // do nothing - } - .store(in: &self.context.disposeBag) // execute in the background - } + .retry(3) + .sink { completion in + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: setup avatar & display name fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + case .finished: + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: setup avatar & display name success", ((#file as NSString).lastPathComponent), #line, #function) + } + } receiveValue: { _ in + // do nothing + } + .store(in: &self.context.disposeBag) // execute in the background + } // end switch } 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.coordinator.setup() @@ -140,6 +133,13 @@ extension MastodonConfirmEmailViewController { .store(in: &self.disposeBag) } .store(in: &self.disposeBag) + + + navigationActionView.backButton.setTitle(L10n.Scene.ConfirmEmail.Button.resend, for: .normal) + navigationActionView.backButton.addTarget(self, action: #selector(MastodonConfirmEmailViewController.resendButtonPressed(_:)), for: .touchUpInside) + + navigationActionView.nextButton.setTitle(L10n.Scene.ConfirmEmail.Button.openEmailApp, for: .normal) + navigationActionView.nextButton.addTarget(self, action: #selector(MastodonConfirmEmailViewController.openEmailButtonPressed(_:)), for: .touchUpInside) } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { @@ -190,7 +190,7 @@ extension MastodonConfirmEmailViewController { self.coordinator.present(scene: .alertController(alertController: alertController), from: self, transition: .alertController(animated: true, completion: nil)) } - @objc private func dontReceiveButtonPressed(_ sender: UIButton) { + @objc private func resendButtonPressed(_ sender: UIButton) { let alertController = UIAlertController(title: L10n.Scene.ConfirmEmail.DontReceiveEmail.title, message: L10n.Scene.ConfirmEmail.DontReceiveEmail.description, preferredStyle: .alert) let resendAction = UIAlertAction(title: L10n.Scene.ConfirmEmail.DontReceiveEmail.resendEmail, style: .default) { _ in let url = Mastodon.API.resendEmailURL(domain: self.viewModel.authenticateInfo.domain) diff --git a/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewModel.swift b/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewModel.swift index 7ddcefbbf..35480ba98 100644 --- a/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewModel.swift +++ b/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewModel.swift @@ -38,7 +38,7 @@ final class MastodonConfirmEmailViewModel { self.updateCredentialQuery = updateCredentialQuery } - #if DEBUG + #if DEBUG || SNAPSHOT init() { self.context = AppContext.shared self.email = "example.com" diff --git a/Mastodon/Scene/Onboarding/PickServer/CollectionViewCell/PickServerCategoryCollectionViewCell.swift b/Mastodon/Scene/Onboarding/PickServer/CollectionViewCell/PickServerCategoryCollectionViewCell.swift index 9793d40fb..89ca8267b 100644 --- a/Mastodon/Scene/Onboarding/PickServer/CollectionViewCell/PickServerCategoryCollectionViewCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/CollectionViewCell/PickServerCategoryCollectionViewCell.swift @@ -8,14 +8,10 @@ import UIKit class PickServerCategoryCollectionViewCell: UICollectionViewCell { - + var observations = Set<NSKeyValueObservation>() - var categoryView: PickServerCategoryView = { - let view = PickServerCategoryView() - view.translatesAutoresizingMaskIntoConstraints = false - return view - }() + var categoryView = PickServerCategoryView() override func prepareForReuse() { super.prepareForReuse() @@ -35,13 +31,15 @@ class PickServerCategoryCollectionViewCell: UICollectionViewCell { extension PickServerCategoryCollectionViewCell { private func configure() { - contentView.addSubview(categoryView) + backgroundColor = .clear + categoryView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(categoryView) NSLayoutConstraint.activate([ + categoryView.topAnchor.constraint(equalTo: contentView.topAnchor), categoryView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), categoryView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - categoryView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10), - contentView.bottomAnchor.constraint(equalTo: categoryView.bottomAnchor, constant: 10), + contentView.bottomAnchor.constraint(equalTo: categoryView.bottomAnchor), ]) } } diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift index f3570c6c5..2d43faa56 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift @@ -10,10 +10,13 @@ import UIKit import Combine import GameController import AuthenticationServices +import MastodonAsset +import MastodonLocalization final class MastodonPickServerViewController: UIViewController, NeedsDependency { private var disposeBag = Set<AnyCancellable>() + private var observations = Set<NSKeyValueObservation>() private var tableViewObservation: NSKeyValueObservation? weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } @@ -31,21 +34,13 @@ final class MastodonPickServerViewController: UIViewController, NeedsDependency 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! let tableView: UITableView = { let tableView = ControlContainableTableView() - tableView.register(PickServerTitleCell.self, forCellReuseIdentifier: String(describing: PickServerTitleCell.self)) - tableView.register(PickServerCategoriesCell.self, forCellReuseIdentifier: String(describing: PickServerCategoriesCell.self)) - tableView.register(PickServerSearchCell.self, forCellReuseIdentifier: String(describing: PickServerSearchCell.self)) - tableView.register(PickServerCell.self, forCellReuseIdentifier: String(describing: PickServerCell.self)) - tableView.register(PickServerLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: PickServerLoaderTableViewCell.self)) tableView.rowHeight = UITableView.automaticDimension tableView.separatorStyle = .none tableView.backgroundColor = .clear tableView.keyboardDismissMode = .onDrag - tableView.translatesAutoresizingMaskIntoConstraints = false if #available(iOS 15.0, *) { tableView.sectionHeaderTopPadding = .leastNonzeroMagnitude } else { @@ -54,14 +49,11 @@ final class MastodonPickServerViewController: UIViewController, NeedsDependency 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 + let navigationActionView: NavigationActionView = { + let navigationActionView = NavigationActionView() + navigationActionView.backgroundColor = Asset.Scene.Onboarding.background.color + return navigationActionView }() - var buttonContainerBottomLayoutConstraint: NSLayoutConstraint! var mastodonAuthenticationController: MastodonAuthenticationController? @@ -72,16 +64,15 @@ final class MastodonPickServerViewController: UIViewController, NeedsDependency } -extension MastodonPickServerViewController { - +extension MastodonPickServerViewController { override func viewDidLoad() { super.viewDidLoad() + navigationItem.leftBarButtonItem = UIBarButtonItem() + setupOnboardingAppearance() defer { setupNavigationBarBackgroundView() } - configureTitleLabel() - configureMargin() #if DEBUG navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "ellipsis.circle"), style: .plain, target: nil, action: nil) @@ -94,26 +85,34 @@ extension MastodonPickServerViewController { navigationItem.rightBarButtonItem?.menu = UIMenu(title: "Debug Tool", image: nil, identifier: nil, options: [], children: children) #endif - buttonContainer.translatesAutoresizingMaskIntoConstraints = false - buttonContainer.preservesSuperviewLayoutMargins = true - view.addSubview(buttonContainer) - buttonContainerBottomLayoutConstraint = view.bottomAnchor.constraint(equalTo: buttonContainer.bottomAnchor, constant: 0).priority(.defaultHigh) + tableView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(tableView) NSLayoutConstraint.activate([ - buttonContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor), - buttonContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor), - view.safeAreaLayoutGuide.bottomAnchor.constraint(greaterThanOrEqualTo: buttonContainer.bottomAnchor, constant: WelcomeViewController.viewBottomPaddingHeight), - buttonContainerBottomLayoutConstraint, + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), ]) - view.addSubview(nextStepButton) + navigationActionView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(navigationActionView) + defer { + view.bringSubviewToFront(navigationActionView) + } 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), + navigationActionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + navigationActionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + view.bottomAnchor.constraint(equalTo: navigationActionView.bottomAnchor), ]) - + + navigationActionView + .observe(\.bounds, options: [.initial, .new]) { [weak self] navigationActionView, _ in + guard let self = self else { return } + let inset = navigationActionView.frame.height + self.tableView.contentInset.bottom = inset + } + .store(in: &observations) + // fix AutoLayout warning when observe before view appear viewModel.viewWillAppear .receive(on: DispatchQueue.main) @@ -125,26 +124,7 @@ extension MastodonPickServerViewController { } } .store(in: &disposeBag) - - tableViewTopPaddingView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(tableViewTopPaddingView) - tableViewTopPaddingViewHeightLayoutConstraint = tableViewTopPaddingView.heightAnchor.constraint(equalToConstant: 0.0).priority(.defaultHigh) - NSLayoutConstraint.activate([ - tableViewTopPaddingView.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor), - tableViewTopPaddingView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - tableViewTopPaddingView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - tableViewTopPaddingViewHeightLayoutConstraint, - ]) - tableViewTopPaddingView.backgroundColor = Asset.Theme.Mastodon.systemGroupedBackground.color - - view.addSubview(tableView) - NSLayoutConstraint.activate([ - tableView.topAnchor.constraint(equalTo: view.topAnchor), - tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - buttonContainer.topAnchor.constraint(equalTo: tableView.bottomAnchor, constant: 7), - ]) - + emptyStateView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(emptyStateView) emptyStateViewLeadingLayoutConstraint = emptyStateView.leadingAnchor.constraint(equalTo: tableView.leadingAnchor) @@ -153,64 +133,24 @@ extension MastodonPickServerViewController { emptyStateView.topAnchor.constraint(equalTo: view.topAnchor), emptyStateViewLeadingLayoutConstraint, emptyStateViewTrailingLayoutConstraint, - buttonContainer.topAnchor.constraint(equalTo: emptyStateView.bottomAnchor, constant: 21), + navigationActionView.topAnchor.constraint(equalTo: emptyStateView.bottomAnchor, constant: 21), ]) view.sendSubviewToBack(emptyStateView) - - // update layout when keyboard show/dismiss - let keyboardEventPublishers = Publishers.CombineLatest3( - KeyboardResponderService.shared.isShow, - KeyboardResponderService.shared.state, - KeyboardResponderService.shared.endFrame - ) - - keyboardEventPublishers - .sink { [weak self] keyboardEvents in - guard let self = self else { return } - let (isShow, state, endFrame) = keyboardEvents - - // guard external keyboard connected - guard isShow, state == .dock, GCKeyboard.coalesced != nil else { - self.buttonContainerBottomLayoutConstraint.constant = WelcomeViewController.viewBottomPaddingHeight - return - } - - let externalKeyboardToolbarHeight = self.view.frame.maxY - endFrame.minY - guard externalKeyboardToolbarHeight > 0 else { - self.buttonContainerBottomLayoutConstraint.constant = WelcomeViewController.viewBottomPaddingHeight - return - } - - UIView.animate(withDuration: 0.3) { - self.buttonContainerBottomLayoutConstraint.constant = externalKeyboardToolbarHeight + 16 - self.view.layoutIfNeeded() - } - } - .store(in: &disposeBag) - - switch viewModel.mode { - case .signIn: - nextStepButton.setTitle(L10n.Common.Controls.Actions.signIn, for: .normal) - case .signUp: - nextStepButton.setTitle(L10n.Common.Controls.Actions.continue, for: .normal) - } - nextStepButton.addTarget(self, action: #selector(nextStepButtonDidClicked(_:)), for: .touchUpInside) - + tableView.delegate = self viewModel.setupDiffableDataSource( for: tableView, dependency: self, - pickServerCategoriesCellDelegate: self, - pickServerSearchCellDelegate: self, + pickServerServerSectionTableHeaderViewDelegate: self, pickServerCellDelegate: self ) - + viewModel .selectedServer .map { $0 != nil } - .assign(to: \.isEnabled, on: nextStepButton) + .assign(to: \.isEnabled, on: navigationActionView.nextButton) .store(in: &disposeBag) - + Publishers.Merge( viewModel.error, authenticationViewModel.error @@ -229,7 +169,7 @@ extension MastodonPickServerViewController { ) } .store(in: &disposeBag) - + authenticationViewModel .authenticated .flatMap { [weak self] (domain, user) -> AnyPublisher<Result<Bool, Error>, Never> in @@ -249,17 +189,17 @@ extension MastodonPickServerViewController { } } .store(in: &disposeBag) - + authenticationViewModel.isAuthenticating .receive(on: DispatchQueue.main) .sink { [weak self] isAuthenticating in guard let self = self else { return } - isAuthenticating ? self.nextStepButton.showLoading() : self.nextStepButton.stopLoading() + isAuthenticating ? self.navigationActionView.nextButton.showLoading() : self.navigationActionView.nextButton.stopLoading() } .store(in: &disposeBag) - + viewModel.emptyStateViewState - .receive(on: RunLoop.main) + .receive(on: DispatchQueue.main) .sink { [weak self] state in guard let self = self else { return } switch state { @@ -284,6 +224,9 @@ extension MastodonPickServerViewController { } } .store(in: &disposeBag) + + navigationActionView.backButton.addTarget(self, action: #selector(MastodonPickServerViewController.backButtonDidPressed(_:)), for: .touchUpInside) + navigationActionView.nextButton.addTarget(self, action: #selector(MastodonPickServerViewController.nextButtonDidPressed(_:)), for: .touchUpInside) } override func viewWillAppear(_ animated: Bool) { @@ -291,43 +234,31 @@ extension MastodonPickServerViewController { viewModel.viewWillAppear.send() } + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + tableView.flashScrollIndicators() + } + 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 - private func nextStepButtonDidClicked(_ sender: UIButton) { + @objc private func backButtonDidPressed(_ sender: UIButton) { + navigationController?.popViewController(animated: true) + } + + @objc private func nextButtonDidPressed(_ sender: UIButton) { switch viewModel.mode { - case .signIn: - doSignIn() - case .signUp: - doSignUp() + case .signIn: doSignIn() + case .signUp: doSignUp() } } @@ -442,8 +373,8 @@ extension MastodonPickServerViewController { self.coordinator.present(scene: .mastodonServerRules(viewModel: mastodonServerRulesViewModel), from: self, transition: .show) } else { let mastodonRegisterViewModel = MastodonRegisterViewModel( - domain: server.domain, context: self.context, + domain: server.domain, authenticateInfo: response.authenticateInfo, instance: response.instance.value, applicationToken: response.applicationToken.value @@ -458,16 +389,6 @@ extension MastodonPickServerViewController { // MARK: - UITableViewDelegate extension MastodonPickServerViewController: UITableViewDelegate { - func scrollViewDidScroll(_ scrollView: UIScrollView) { - guard scrollView === tableView else { return } - let offsetY = scrollView.contentOffset.y + scrollView.safeAreaInsets.top - if offsetY < 0 { - tableViewTopPaddingViewHeightLayoutConstraint.constant = abs(offsetY) - } else { - tableViewTopPaddingViewHeightLayoutConstraint.constant = 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 } @@ -500,87 +421,89 @@ extension MastodonPickServerViewController: UITableViewDelegate { guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } switch item { - case .categoryPicker: - guard let cell = cell as? PickServerCategoriesCell else { return } - guard let diffableDataSource = cell.diffableDataSource else { return } - let snapshot = diffableDataSource.snapshot() - - let item = viewModel.selectCategoryItem.value - guard let section = snapshot.indexOfSection(.main), - let row = snapshot.indexOfItem(item) else { return } - cell.collectionView.selectItem(at: IndexPath(item: row, section: section), animated: false, scrollPosition: .centeredHorizontally) - case .search: - guard let cell = cell as? PickServerSearchCell else { return } - cell.searchTextField.text = viewModel.searchText.value +// case .categoryPicker: +// guard let cell = cell as? PickServerCategoriesCell else { return } +// guard let diffableDataSource = cell.diffableDataSource else { return } +// let snapshot = diffableDataSource.snapshot() +// +// let item = viewModel.selectCategoryItem.value +// guard let section = snapshot.indexOfSection(.main), +// let row = snapshot.indexOfItem(item) else { return } +// cell.collectionView.selectItem(at: IndexPath(item: row, section: section), animated: false, scrollPosition: .centeredHorizontally) +// case .search: +// guard let cell = cell as? PickServerSearchCell else { return } +// cell.searchTextField.text = viewModel.searchText.value default: break } } + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + guard let diffableDataSource = viewModel.diffableDataSource else { return nil } + let snapshot = diffableDataSource.snapshot() + guard section < snapshot.numberOfSections else { return nil } + let section = snapshot.sectionIdentifiers[section] + + switch section { + case .servers: + return viewModel.serverSectionHeaderView + default: + return UIView() + } + } + + func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + guard let diffableDataSource = viewModel.diffableDataSource else { return .leastNonzeroMagnitude } + let snapshot = diffableDataSource.snapshot() + guard section < snapshot.numberOfSections else { return .leastNonzeroMagnitude } + let section = snapshot.sectionIdentifiers[section] + + switch section { + case .servers: + return PickServerServerSectionTableHeaderView.height + default: + return .leastNonzeroMagnitude + } + } + } extension MastodonPickServerViewController { private func updateEmptyStateViewLayout() { - guard let diffableDataSource = self.viewModel.diffableDataSource else { return } - guard let indexPath = diffableDataSource.indexPath(for: .search) else { return } - 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 - } +// guard let diffableDataSource = self.viewModel.diffableDataSource else { return } +// guard let indexPath = diffableDataSource.indexPath(for: .search) else { return } +// 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 +// } } } -// MARK: - PickServerCategoriesCellDelegate -extension MastodonPickServerViewController: PickServerCategoriesCellDelegate { - func pickServerCategoriesCell(_ cell: PickServerCategoriesCell, collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - guard let diffableDataSource = cell.diffableDataSource else { return } +// MARK: - PickServerServerSectionTableHeaderViewDelegate +extension MastodonPickServerViewController: PickServerServerSectionTableHeaderViewDelegate { + func pickServerServerSectionTableHeaderView(_ headerView: PickServerServerSectionTableHeaderView, collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard let diffableDataSource = headerView.diffableDataSource else { return } let item = diffableDataSource.itemIdentifier(for: indexPath) viewModel.selectCategoryItem.value = item ?? .all } -} - -// MARK: - PickServerSearchCellDelegate -extension MastodonPickServerViewController: PickServerSearchCellDelegate { - func pickServerSearchCell(_ cell: PickServerSearchCell, searchTextDidChange searchText: String?) { + + func pickServerServerSectionTableHeaderView(_ headerView: PickServerServerSectionTableHeaderView, searchTextDidChange searchText: String?) { viewModel.searchText.send(searchText ?? "") } } // MARK: - PickServerCellDelegate extension MastodonPickServerViewController: PickServerCellDelegate { - func pickServerCell(_ cell: PickServerCell, expandButtonPressed button: UIButton) { - guard let diffableDataSource = viewModel.diffableDataSource else { return } - guard let indexPath = tableView.indexPath(for: cell) else { return } - guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } - guard case let .server(_, attribute) = item else { return } - - attribute.isExpand.toggle() - tableView.beginUpdates() - cell.updateExpandMode(mode: attribute.isExpand ? .expand : .collapse) - tableView.endUpdates() - - // expand attribute change do not needs apply snapshot to diffable data source - // but should I block the viewModel data binding during tableView.beginUpdates/endUpdates? - } + } // MARK: - OnboardingViewControllerAppearance diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+Diffable.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+Diffable.swift index 9da0399e1..35de40b8f 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+Diffable.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+Diffable.swift @@ -6,32 +6,105 @@ // import UIKit +import Combine extension MastodonPickServerViewModel { func setupDiffableDataSource( for tableView: UITableView, dependency: NeedsDependency, - pickServerCategoriesCellDelegate: PickServerCategoriesCellDelegate, - pickServerSearchCellDelegate: PickServerSearchCellDelegate, + pickServerServerSectionTableHeaderViewDelegate: PickServerServerSectionTableHeaderViewDelegate, pickServerCellDelegate: PickServerCellDelegate ) { + // set section header + serverSectionHeaderView.diffableDataSource = CategoryPickerSection.collectionViewDiffableDataSource( + for: serverSectionHeaderView.collectionView, + dependency: dependency + ) + var sectionHeaderSnapshot = NSDiffableDataSourceSnapshot<CategoryPickerSection, CategoryPickerItem>() + sectionHeaderSnapshot.appendSections([.main]) + sectionHeaderSnapshot.appendItems(categoryPickerItems, toSection: .main) + serverSectionHeaderView.delegate = pickServerServerSectionTableHeaderViewDelegate + serverSectionHeaderView.diffableDataSource?.applySnapshot(sectionHeaderSnapshot, animated: false) { [weak self] in + guard let self = self else { return } + guard let indexPath = self.serverSectionHeaderView.diffableDataSource?.indexPath(for: .all) else { return } + self.serverSectionHeaderView.collectionView.selectItem(at: indexPath, animated: false, scrollPosition: .centeredHorizontally) + } + + // set tableView diffableDataSource = PickServerSection.tableViewDiffableDataSource( for: tableView, dependency: dependency, - pickServerCategoriesCellDelegate: pickServerCategoriesCellDelegate, - pickServerSearchCellDelegate: pickServerSearchCellDelegate, pickServerCellDelegate: pickServerCellDelegate ) var snapshot = NSDiffableDataSourceSnapshot<PickServerSection, PickServerItem>() - snapshot.appendSections([.header, .category, .search, .servers]) + snapshot.appendSections([.header, .servers]) snapshot.appendItems([.header], toSection: .header) - snapshot.appendItems([.categoryPicker(items: categoryPickerItems)], toSection: .category) - snapshot.appendItems([.search], toSection: .search) diffableDataSource?.apply(snapshot, animatingDifferences: false, completion: nil) loadIndexedServerStateMachine.enter(LoadIndexedServerState.Loading.self) + + Publishers.CombineLatest( + filteredIndexedServers, + unindexedServers + ) + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] indexedServers, unindexedServers in + guard let self = self else { return } + guard let diffableDataSource = self.diffableDataSource else { return } + + let oldSnapshot = diffableDataSource.snapshot() + var oldSnapshotServerItemAttributeDict: [String : PickServerItem.ServerItemAttribute] = [:] + for item in oldSnapshot.itemIdentifiers { + guard case let .server(server, attribute) = item else { continue } + oldSnapshotServerItemAttributeDict[server.domain] = attribute + } + + var snapshot = NSDiffableDataSourceSnapshot<PickServerSection, PickServerItem>() + snapshot.appendSections([.header, .servers]) + snapshot.appendItems([.header], toSection: .header) + + // TODO: handle filter + var serverItems: [PickServerItem] = [] + for server in indexedServers { + let attribute = oldSnapshotServerItemAttributeDict[server.domain] ?? PickServerItem.ServerItemAttribute(isLast: false, isExpand: false) + attribute.isLast.value = false + let item = PickServerItem.server(server: server, attribute: attribute) + guard !serverItems.contains(item) else { continue } + serverItems.append(item) + } + + if let unindexedServers = unindexedServers { + if !unindexedServers.isEmpty { + for server in unindexedServers { + let attribute = oldSnapshotServerItemAttributeDict[server.domain] ?? PickServerItem.ServerItemAttribute(isLast: false, isExpand: false) + attribute.isLast.value = false + let item = PickServerItem.server(server: server, attribute: attribute) + guard !serverItems.contains(item) else { continue } + serverItems.append(item) + } + } else { + if indexedServers.isEmpty && !self.isLoadingIndexedServers.value { + serverItems.append(.loader(attribute: PickServerItem.LoaderItemAttribute(isLast: false, isEmptyResult: true))) + } + } + } else { + serverItems.append(.loader(attribute: PickServerItem.LoaderItemAttribute(isLast: false, isEmptyResult: false))) + } + + if case let .server(_, attribute) = serverItems.last { + attribute.isLast.value = true + } + if case let .loader(attribute) = serverItems.last { + attribute.isLast = true + } + snapshot.appendItems(serverItems, toSection: .servers) + + diffableDataSource.defaultRowAnimation = .fade + diffableDataSource.apply(snapshot, animatingDifferences: true, completion: nil) + }) + .store(in: &disposeBag) } } diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift index 7a6480118..af38b110b 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift @@ -12,6 +12,7 @@ import GameplayKit import MastodonSDK import CoreDataStack import OrderedCollections +import Tabman class MastodonPickServerViewModel: NSObject { @@ -27,6 +28,8 @@ class MastodonPickServerViewModel: NSObject { } var disposeBag = Set<AnyCancellable>() + + let serverSectionHeaderView = PickServerServerSectionTableHeaderView() // input let mode: PickServerMode @@ -82,68 +85,6 @@ class MastodonPickServerViewModel: NSObject { extension MastodonPickServerViewModel { private func configure() { - Publishers.CombineLatest( - filteredIndexedServers, - unindexedServers - ) - .receive(on: DispatchQueue.main) - .sink(receiveValue: { [weak self] indexedServers, unindexedServers in - guard let self = self else { return } - guard let diffableDataSource = self.diffableDataSource else { return } - - let oldSnapshot = diffableDataSource.snapshot() - var oldSnapshotServerItemAttributeDict: [String : PickServerItem.ServerItemAttribute] = [:] - for item in oldSnapshot.itemIdentifiers { - guard case let .server(server, attribute) = item else { continue } - oldSnapshotServerItemAttributeDict[server.domain] = attribute - } - - var snapshot = NSDiffableDataSourceSnapshot<PickServerSection, PickServerItem>() - snapshot.appendSections([.header, .category, .search, .servers]) - snapshot.appendItems([.header], toSection: .header) - snapshot.appendItems([.categoryPicker(items: self.categoryPickerItems)], toSection: .category) - snapshot.appendItems([.search], toSection: .search) - // TODO: handle filter - var serverItems: [PickServerItem] = [] - for server in indexedServers { - let attribute = oldSnapshotServerItemAttributeDict[server.domain] ?? PickServerItem.ServerItemAttribute(isLast: false, isExpand: false) - attribute.isLast.value = false - let item = PickServerItem.server(server: server, attribute: attribute) - guard !serverItems.contains(item) else { continue } - serverItems.append(item) - } - - if let unindexedServers = unindexedServers { - if !unindexedServers.isEmpty { - for server in unindexedServers { - let attribute = oldSnapshotServerItemAttributeDict[server.domain] ?? PickServerItem.ServerItemAttribute(isLast: false, isExpand: false) - attribute.isLast.value = false - let item = PickServerItem.server(server: server, attribute: attribute) - guard !serverItems.contains(item) else { continue } - serverItems.append(item) - } - } else { - if indexedServers.isEmpty && !self.isLoadingIndexedServers.value { - serverItems.append(.loader(attribute: PickServerItem.LoaderItemAttribute(isLast: false, isEmptyResult: true))) - } - } - } else { - serverItems.append(.loader(attribute: PickServerItem.LoaderItemAttribute(isLast: false, isEmptyResult: false))) - } - - if case let .server(_, attribute) = serverItems.last { - attribute.isLast.value = true - } - if case let .loader(attribute) = serverItems.last { - attribute.isLast = true - } - snapshot.appendItems(serverItems, toSection: .servers) - - diffableDataSource.defaultRowAnimation = .fade - diffableDataSource.apply(snapshot, animatingDifferences: true, completion: nil) - }) - .store(in: &disposeBag) - Publishers.CombineLatest( isLoadingIndexedServers, loadingIndexedServersError @@ -301,3 +242,12 @@ extension MastodonPickServerViewModel { let applicationToken: Mastodon.Response.Content<Mastodon.Entity.Token> } } + +// MARK: - TMBarDataSource +extension MastodonPickServerViewModel: TMBarDataSource { + func barItem(for bar: TMBar, at index: Int) -> TMBarItemable { + let item = categoryPickerItems[index] + let barItem = TMBarItem(title: item.title) + return barItem + } +} diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCategoriesCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCategoriesCell.swift deleted file mode 100644 index 659317752..000000000 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCategoriesCell.swift +++ /dev/null @@ -1,145 +0,0 @@ -// -// PickServerCategoriesCell.swift -// Mastodon -// -// Created by BradGao on 2021/2/23. -// - -import os.log -import UIKit -import MastodonSDK - -protocol PickServerCategoriesCellDelegate: AnyObject { - func pickServerCategoriesCell(_ cell: PickServerCategoriesCell, collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) -} - -final class PickServerCategoriesCell: UITableViewCell { - - weak var delegate: PickServerCategoriesCellDelegate? - - var diffableDataSource: UICollectionViewDiffableDataSource<CategoryPickerSection, CategoryPickerItem>? - - let metricView = UIView() - - let collectionView: UICollectionView = { - let flowLayout = UICollectionViewFlowLayout() - flowLayout.scrollDirection = .horizontal - let view = ControlContainableCollectionView(frame: .zero, collectionViewLayout: flowLayout) - view.register(PickServerCategoryCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: PickServerCategoryCollectionViewCell.self)) - view.backgroundColor = .clear - view.showsHorizontalScrollIndicator = false - view.showsVerticalScrollIndicator = false - view.layer.masksToBounds = false - view.translatesAutoresizingMaskIntoConstraints = false - return view - }() - - override func prepareForReuse() { - super.prepareForReuse() - - delegate = nil - } - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - _init() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - _init() - } -} - -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.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), - ]) - - contentView.addSubview(collectionView) - NSLayoutConstraint.activate([ - collectionView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - collectionView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - 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() - - collectionView.collectionViewLayout.invalidateLayout() - } - -} - -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 { - - func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: indexPath: %s", ((#file as NSString).lastPathComponent), #line, #function, indexPath.debugDescription) - collectionView.selectItem(at: indexPath, animated: true, scrollPosition: .centeredHorizontally) - delegate?.pickServerCategoriesCell(self, collectionView: collectionView, didSelectItemAt: indexPath) - } - - func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets { - layoutIfNeeded() - return UIEdgeInsets(top: 0, left: metricView.frame.minX - collectionView.frame.minX, bottom: 0, right: collectionView.frame.maxX - metricView.frame.maxX) - } - - func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat { - return 16 - } - - func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { - return CGSize(width: 60, height: 80) - } - -} - -extension PickServerCategoriesCell { - - override func accessibilityElementCount() -> Int { - guard let diffableDataSource = diffableDataSource else { return 0 } - return diffableDataSource.snapshot().itemIdentifiers.count - } - - override func accessibilityElement(at index: Int) -> Any? { - guard let item = collectionView.cellForItem(at: IndexPath(item: index, section: 0)) else { return nil } - return item - } - -} diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift index 2f60a5206..669067770 100644 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift @@ -11,9 +11,11 @@ import Combine import MastodonSDK import AlamofireImage import Kanna +import MastodonAsset +import MastodonLocalization protocol PickServerCellDelegate: AnyObject { - func pickServerCell(_ cell: PickServerCell, expandButtonPressed button: UIButton) +// func pickServerCell(_ cell: PickServerCell, expandButtonPressed button: UIButton) } class PickServerCell: UITableViewCell { @@ -21,20 +23,17 @@ class PickServerCell: UITableViewCell { weak var delegate: PickServerCellDelegate? var disposeBag = Set<AnyCancellable>() - - let expandMode = CurrentValueSubject<ExpandMode, Never>(.collapse) - - let containerView: UIView = { - let view = UIView() - view.layoutMargins = UIEdgeInsets(top: 16, left: 16, bottom: 10, right: 16) - view.backgroundColor = Asset.Theme.Mastodon.secondaryGroupedSystemBackground.color - view.translatesAutoresizingMaskIntoConstraints = false + + let containerView: UIStackView = { + let view = UIStackView() + view.axis = .vertical + view.spacing = 4 return view }() let domainLabel: UILabel = { let label = UILabel() - label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold), maximumPointSize: 22) + label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 20, weight: .semibold)) label.textColor = Asset.Colors.Label.primary.color label.adjustsFontForContentSizeCategory = true label.translatesAutoresizingMaskIntoConstraints = false @@ -52,7 +51,7 @@ class PickServerCell: UITableViewCell { let descriptionLabel: UILabel = { let label = UILabel() - label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15, weight: .regular)) + label.font = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .systemFont(ofSize: 13, weight: .regular)) label.numberOfLines = 0 label.textColor = Asset.Colors.Label.primary.color label.adjustsFontForContentSizeCategory = true @@ -60,112 +59,33 @@ class PickServerCell: UITableViewCell { return label }() - let thumbnailActivityIndicator = UIActivityIndicatorView(style: .medium) - - let thumbnailImageView: UIImageView = { - let imageView = UIImageView() - imageView.clipsToBounds = true - imageView.contentMode = .scaleAspectFill - imageView.translatesAutoresizingMaskIntoConstraints = false - return imageView - }() - let infoStackView: UIStackView = { let stackView = UIStackView() stackView.axis = .horizontal - stackView.alignment = .fill - stackView.distribution = .fillEqually - stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.spacing = 16 return stackView }() - let expandBox: UIView = { - let view = UIView() - view.backgroundColor = .clear - view.translatesAutoresizingMaskIntoConstraints = false - return view - }() - - let expandButton: UIButton = { - let button = HitTestExpandedButton(type: .custom) - button.setImage(UIImage(systemName: "chevron.down", withConfiguration: UIImage.SymbolConfiguration(pointSize: 13)), for: .normal) - button.setTitle(L10n.Scene.ServerPicker.Button.seeMore, for: .normal) - button.setTitleColor(Asset.Colors.brandBlue.color, for: .normal) - button.titleLabel?.font = .systemFont(ofSize: 13, weight: .regular) - button.translatesAutoresizingMaskIntoConstraints = false - button.imageView?.transform = CGAffineTransform(scaleX: -1, y: 1) - button.titleLabel?.transform = CGAffineTransform(scaleX: -1, y: 1) - button.transform = CGAffineTransform(scaleX: -1, y: 1) - return button - }() - let separator: UIView = { let view = UIView() - view.backgroundColor = Asset.Theme.Mastodon.systemGroupedBackground.color - view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = Asset.Theme.System.separator.color return view }() let langValueLabel: UILabel = { let label = UILabel() label.textColor = Asset.Colors.Label.primary.color - label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 22, weight: .semibold), maximumPointSize: 27) + label.font = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .systemFont(ofSize: 12, weight: .regular)) label.textAlignment = .center label.adjustsFontForContentSizeCategory = true - label.translatesAutoresizingMaskIntoConstraints = false return label }() let usersValueLabel: UILabel = { let label = UILabel() label.textColor = Asset.Colors.Label.primary.color - label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 22, weight: .semibold), maximumPointSize: 27) - label.textAlignment = .center + label.font = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .systemFont(ofSize: 12, weight: .regular)) label.adjustsFontForContentSizeCategory = true - label.translatesAutoresizingMaskIntoConstraints = false - return label - }() - - let categoryValueLabel: UILabel = { - let label = UILabel() - label.textColor = Asset.Colors.Label.primary.color - label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 22, weight: .semibold), maximumPointSize: 27) - label.textAlignment = .center - label.adjustsFontForContentSizeCategory = true - label.translatesAutoresizingMaskIntoConstraints = false - return label - }() - - let langTitleLabel: UILabel = { - let label = UILabel() - label.textColor = Asset.Colors.Label.primary.color - label.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(for: .systemFont(ofSize: 11, weight: .regular), maximumPointSize: 16) - label.text = L10n.Scene.ServerPicker.Label.language - label.textAlignment = .center - label.adjustsFontForContentSizeCategory = true - label.translatesAutoresizingMaskIntoConstraints = false - return label - }() - - let usersTitleLabel: UILabel = { - let label = UILabel() - label.textColor = Asset.Colors.Label.primary.color - label.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(for: .systemFont(ofSize: 11, weight: .regular), maximumPointSize: 16) - label.text = L10n.Scene.ServerPicker.Label.users - label.textAlignment = .center - label.adjustsFontForContentSizeCategory = true - label.translatesAutoresizingMaskIntoConstraints = false - return label - }() - - let categoryTitleLabel: UILabel = { - let label = UILabel() - label.textColor = Asset.Colors.Label.primary.color - label.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(for: .systemFont(ofSize: 11, weight: .regular), maximumPointSize: 16) - label.text = L10n.Scene.ServerPicker.Label.category - label.textAlignment = .center - label.adjustsFontForContentSizeCategory = true - label.translatesAutoresizingMaskIntoConstraints = false return label }() @@ -175,9 +95,6 @@ class PickServerCell: UITableViewCell { override func prepareForReuse() { super.prepareForReuse() - thumbnailImageView.isHidden = false - thumbnailImageView.af.cancelImageRequest() - thumbnailActivityIndicator.stopAnimating() disposeBag.removeAll() } @@ -197,172 +114,55 @@ class PickServerCell: UITableViewCell { extension PickServerCell { private func _init() { selectionStyle = .none - backgroundColor = .clear - configureMargin() + backgroundColor = Asset.Scene.Onboarding.background.color + + checkbox.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(checkbox) + NSLayoutConstraint.activate([ + checkbox.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor, constant: 1), + checkbox.heightAnchor.constraint(equalToConstant: 32).priority(.required - 1), + checkbox.widthAnchor.constraint(equalToConstant: 32).priority(.required - 1), + ]) + containerView.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(containerView) - containerView.addSubview(domainLabel) - containerView.addSubview(checkbox) - containerView.addSubview(descriptionLabel) - containerView.addSubview(separator) - - containerView.addSubview(expandButton) - - // Always add the expandbox which contains elements only visible in expand mode - containerView.addSubview(expandBox) - expandBox.addSubview(thumbnailImageView) - expandBox.addSubview(infoStackView) - expandBox.isHidden = true - - let verticalInfoStackViewLang = makeVerticalInfoStackView(arrangedView: langValueLabel, langTitleLabel) - let verticalInfoStackViewUsers = makeVerticalInfoStackView(arrangedView: usersValueLabel, usersTitleLabel) - let verticalInfoStackViewCategory = makeVerticalInfoStackView(arrangedView: categoryValueLabel, categoryTitleLabel) - infoStackView.addArrangedSubview(verticalInfoStackViewLang) - infoStackView.addArrangedSubview(verticalInfoStackViewUsers) - infoStackView.addArrangedSubview(verticalInfoStackViewCategory) - - let expandButtonTopConstraintInCollapse = expandButton.topAnchor.constraint(equalTo: descriptionLabel.lastBaselineAnchor, constant: 12).priority(.required - 1) - collapseConstraints.append(expandButtonTopConstraintInCollapse) - - let expandButtonTopConstraintInExpand = expandButton.topAnchor.constraint(equalTo: expandBox.bottomAnchor, constant: 8).priority(.defaultHigh) - expandConstraints.append(expandButtonTopConstraintInExpand) - NSLayoutConstraint.activate([ - // Set background view - containerView.topAnchor.constraint(equalTo: contentView.topAnchor), - containerView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), - contentView.layoutMarginsGuide.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), - contentView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), - - // Set bottom separator - separator.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), - containerView.trailingAnchor.constraint(equalTo: separator.trailingAnchor), - containerView.topAnchor.constraint(equalTo: separator.topAnchor), - separator.heightAnchor.constraint(equalToConstant: 1).priority(.defaultHigh), - - domainLabel.topAnchor.constraint(equalTo: containerView.layoutMarginsGuide.topAnchor), - domainLabel.leadingAnchor.constraint(equalTo: containerView.layoutMarginsGuide.leadingAnchor), - - checkbox.widthAnchor.constraint(equalToConstant: 23), - checkbox.heightAnchor.constraint(equalToConstant: 22), - containerView.layoutMarginsGuide.trailingAnchor.constraint(equalTo: checkbox.trailingAnchor), - checkbox.leadingAnchor.constraint(equalTo: domainLabel.trailingAnchor, constant: 16), - checkbox.centerYAnchor.constraint(equalTo: domainLabel.centerYAnchor), - - descriptionLabel.leadingAnchor.constraint(equalTo: containerView.layoutMarginsGuide.leadingAnchor), - descriptionLabel.topAnchor.constraint(equalTo: domainLabel.bottomAnchor, constant: 8), - containerView.layoutMarginsGuide.trailingAnchor.constraint(equalTo: descriptionLabel.trailingAnchor), - - // Set expandBox constraints - expandBox.leadingAnchor.constraint(equalTo: containerView.layoutMarginsGuide.leadingAnchor), - containerView.layoutMarginsGuide.trailingAnchor.constraint(equalTo: expandBox.trailingAnchor), - expandBox.topAnchor.constraint(equalTo: descriptionLabel.bottomAnchor, constant: 8), - expandBox.bottomAnchor.constraint(equalTo: infoStackView.bottomAnchor).priority(.defaultHigh), - - thumbnailImageView.topAnchor.constraint(equalTo: expandBox.topAnchor), - thumbnailImageView.leadingAnchor.constraint(equalTo: expandBox.leadingAnchor), - expandBox.trailingAnchor.constraint(equalTo: thumbnailImageView.trailingAnchor), - thumbnailImageView.heightAnchor.constraint(equalTo: thumbnailImageView.widthAnchor, multiplier: 151.0 / 303.0).priority(.defaultHigh), - - infoStackView.leadingAnchor.constraint(equalTo: expandBox.leadingAnchor), - expandBox.trailingAnchor.constraint(equalTo: infoStackView.trailingAnchor), - infoStackView.topAnchor.constraint(equalTo: thumbnailImageView.bottomAnchor, constant: 16), - - expandButton.leadingAnchor.constraint(equalTo: containerView.layoutMarginsGuide.leadingAnchor), - containerView.layoutMarginsGuide.trailingAnchor.constraint(equalTo: expandButton.trailingAnchor), - containerView.layoutMarginsGuide.bottomAnchor.constraint(equalTo: expandButton.bottomAnchor), + containerView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 11), + containerView.leadingAnchor.constraint(equalTo: checkbox.trailingAnchor, constant: 22), + containerView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: 11), + checkbox.centerYAnchor.constraint(equalTo: containerView.centerYAnchor), ]) - thumbnailActivityIndicator.translatesAutoresizingMaskIntoConstraints = false - thumbnailImageView.addSubview(thumbnailActivityIndicator) + containerView.addArrangedSubview(domainLabel) + containerView.addArrangedSubview(descriptionLabel) + containerView.setCustomSpacing(6, after: descriptionLabel) + containerView.addArrangedSubview(infoStackView) + + infoStackView.addArrangedSubview(usersValueLabel) + infoStackView.addArrangedSubview(langValueLabel) + infoStackView.addArrangedSubview(UIView()) + + separator.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(separator) NSLayoutConstraint.activate([ - thumbnailActivityIndicator.centerXAnchor.constraint(equalTo: thumbnailImageView.centerXAnchor), - thumbnailActivityIndicator.centerYAnchor.constraint(equalTo: thumbnailImageView.centerYAnchor), + separator.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), + contentView.readableContentGuide.trailingAnchor.constraint(equalTo: separator.trailingAnchor), + separator.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + separator.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView)).priority(.required - 1), ]) - thumbnailActivityIndicator.hidesWhenStopped = true - thumbnailActivityIndicator.stopAnimating() - - NSLayoutConstraint.activate(collapseConstraints) - - domainLabel.setContentHuggingPriority(.required - 1, for: .vertical) - domainLabel.setContentCompressionResistancePriority(.required - 1, for: .vertical) - descriptionLabel.setContentHuggingPriority(.required - 2, for: .vertical) - descriptionLabel.setContentCompressionResistancePriority(.required - 2, for: .vertical) - - 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 - stackView.axis = .vertical - stackView.alignment = .center - stackView.distribution = .equalCentering - stackView.spacing = 2 - arrangedView.forEach { stackView.addArrangedSubview($0) } - return stackView - } - override func setSelected(_ selected: Bool, animated: Bool) { super.setSelected(selected, animated: animated) if selected { checkbox.image = UIImage(systemName: "checkmark.circle.fill") + checkbox.tintColor = Asset.Colors.Label.primary.color } else { checkbox.image = UIImage(systemName: "circle") + checkbox.tintColor = Asset.Colors.Label.secondary.color } } - - @objc - private func expandButtonDidPressed(_ sender: UIButton) { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - delegate?.pickServerCell(self, expandButtonPressed: sender) - } + } -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 { - case collapse - case expand - } - - func updateExpandMode(mode: ExpandMode) { - switch mode { - case .collapse: - expandButton.setImage(UIImage(systemName: "chevron.down", withConfiguration: UIImage.SymbolConfiguration(pointSize: 13)), for: .normal) - expandButton.setTitle(L10n.Scene.ServerPicker.Button.seeMore, for: .normal) - expandBox.isHidden = true - expandButton.isSelected = false - NSLayoutConstraint.deactivate(expandConstraints) - NSLayoutConstraint.activate(collapseConstraints) - case .expand: - expandButton.setImage(UIImage(systemName: "chevron.up", withConfiguration: UIImage.SymbolConfiguration(pointSize: 13)), for: .normal) - expandButton.setTitle(L10n.Scene.ServerPicker.Button.seeLess, for: .normal) - expandBox.isHidden = false - expandButton.isSelected = true - NSLayoutConstraint.activate(expandConstraints) - NSLayoutConstraint.deactivate(collapseConstraints) - } - - expandMode.value = mode - } - -} diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerLoaderTableViewCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerLoaderTableViewCell.swift index 945ecac6a..5649fe579 100644 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerLoaderTableViewCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerLoaderTableViewCell.swift @@ -7,21 +7,15 @@ import UIKit import Combine +import MastodonAsset +import MastodonLocalization final class PickServerLoaderTableViewCell: TimelineLoaderTableViewCell { let containerView: UIView = { let view = UIView() view.layoutMargins = UIEdgeInsets(top: 16, left: 16, bottom: 10, right: 16) - view.backgroundColor = Asset.Theme.Mastodon.secondaryGroupedSystemBackground.color - view.translatesAutoresizingMaskIntoConstraints = false - return view - }() - - let seperator: UIView = { - let view = UIView() - view.backgroundColor = Asset.Theme.Mastodon.systemGroupedBackground.color - view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = .clear return view }() @@ -30,30 +24,22 @@ final class PickServerLoaderTableViewCell: TimelineLoaderTableViewCell { label.text = L10n.Scene.ServerPicker.EmptyState.noResults label.textColor = Asset.Colors.Label.secondary.color label.textAlignment = .center - label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 14, weight: .semibold), maximumPointSize: 19) + label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 14, weight: .semibold)) return label }() override func _init() { super._init() - - configureMargin() - - contentView.addSubview(containerView) - contentView.addSubview(seperator) + + // Set background view + containerView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(containerView) NSLayoutConstraint.activate([ - // Set background view containerView.topAnchor.constraint(equalTo: contentView.topAnchor), 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 - seperator.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), - containerView.trailingAnchor.constraint(equalTo: seperator.trailingAnchor), - containerView.topAnchor.constraint(equalTo: seperator.topAnchor), - seperator.heightAnchor.constraint(equalToConstant: 1).priority(.defaultHigh), + contentView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), ]) emptyStatusLabel.translatesAutoresizingMaskIntoConstraints = false @@ -69,24 +55,7 @@ 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 deleted file mode 100644 index 0a64103d2..000000000 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift +++ /dev/null @@ -1,171 +0,0 @@ -// -// PickServerSearchCell.swift -// Mastodon -// -// Created by BradGao on 2021/2/24. -// - -import UIKit - -protocol PickServerSearchCellDelegate: AnyObject { - func pickServerSearchCell(_ cell: PickServerSearchCell, searchTextDidChange searchText: String?) -} - -class PickServerSearchCell: UITableViewCell { - - weak var delegate: PickServerSearchCellDelegate? - - private var bgView: UIView = { - let view = UIView() - view.backgroundColor = Asset.Theme.Mastodon.secondaryGroupedSystemBackground.color - view.translatesAutoresizingMaskIntoConstraints = false - view.layer.maskedCorners = [ - .layerMinXMinYCorner, - .layerMaxXMinYCorner - ] - view.layer.cornerCurve = .continuous - view.layer.cornerRadius = MastodonPickServerAppearance.tableViewCornerRadius - return view - }() - - private var textFieldBgView: UIView = { - let view = UIView() - view.backgroundColor = Asset.Colors.TextField.background.color - view.translatesAutoresizingMaskIntoConstraints = false - view.layer.masksToBounds = true - view.layer.cornerRadius = 6 - view.layer.cornerCurve = .continuous - return view - }() - - let searchTextField: UITextField = { - let textField = UITextField() - textField.translatesAutoresizingMaskIntoConstraints = false - textField.leftView = { - let imageView = UIImageView( - image: UIImage( - systemName: "magnifyingglass", - withConfiguration: UIImage.SymbolConfiguration(pointSize: 15, weight: .regular) - ) - ) - imageView.tintColor = Asset.Colors.Label.secondary.color.withAlphaComponent(0.6) - - let containerView = UIView() - imageView.translatesAutoresizingMaskIntoConstraints = false - containerView.addSubview(imageView) - NSLayoutConstraint.activate([ - imageView.topAnchor.constraint(equalTo: containerView.topAnchor), - imageView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), - imageView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), - ]) - - let paddingView = UIView() - paddingView.translatesAutoresizingMaskIntoConstraints = false - containerView.addSubview(paddingView) - NSLayoutConstraint.activate([ - paddingView.topAnchor.constraint(equalTo: containerView.topAnchor), - paddingView.leadingAnchor.constraint(equalTo: imageView.trailingAnchor), - paddingView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), - paddingView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), - paddingView.widthAnchor.constraint(equalToConstant: 4).priority(.defaultHigh), - ]) - return containerView - }() - textField.leftViewMode = .always - textField.font = .systemFont(ofSize: 15, weight: .regular) - textField.tintColor = Asset.Colors.Label.primary.color - textField.textColor = Asset.Colors.Label.primary.color - textField.adjustsFontForContentSizeCategory = true - textField.attributedPlaceholder = - NSAttributedString(string: L10n.Scene.ServerPicker.Input.placeholder, - attributes: [.font: UIFont.systemFont(ofSize: 15, weight: .regular), - .foregroundColor: Asset.Colors.Label.secondary.color.withAlphaComponent(0.6)]) - textField.clearButtonMode = .whileEditing - textField.autocapitalizationType = .none - textField.autocorrectionType = .no - textField.returnKeyType = .done - textField.keyboardType = .URL - return textField - }() - - override func prepareForReuse() { - super.prepareForReuse() - - delegate = nil - } - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - _init() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - _init() - } -} - -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 - - contentView.addSubview(bgView) - contentView.addSubview(textFieldBgView) - contentView.addSubview(searchTextField) - - NSLayoutConstraint.activate([ - bgView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), - bgView.topAnchor.constraint(equalTo: contentView.topAnchor), - bgView.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), - bgView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - - textFieldBgView.leadingAnchor.constraint(equalTo: bgView.leadingAnchor, constant: 14), - textFieldBgView.topAnchor.constraint(equalTo: bgView.topAnchor, constant: 12), - bgView.trailingAnchor.constraint(equalTo: textFieldBgView.trailingAnchor, constant: 14), - bgView.bottomAnchor.constraint(equalTo: textFieldBgView.bottomAnchor, constant: 13), - - searchTextField.leadingAnchor.constraint(equalTo: textFieldBgView.leadingAnchor, constant: 11), - searchTextField.topAnchor.constraint(equalTo: textFieldBgView.topAnchor, constant: 4), - textFieldBgView.trailingAnchor.constraint(equalTo: searchTextField.trailingAnchor, constant: 11), - 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 { - @objc private func textFieldDidChange(_ textField: UITextField) { - delegate?.pickServerSearchCell(self, searchTextDidChange: textField.text) - } -} - -// MARK: - UITextFieldDelegate -extension PickServerSearchCell: UITextFieldDelegate { - - func textFieldShouldReturn(_ textField: UITextField) -> Bool { - textField.resignFirstResponder() - return false - } -} diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerTitleCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerTitleCell.swift deleted file mode 100644 index f0d78eb41..000000000 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerTitleCell.swift +++ /dev/null @@ -1,82 +0,0 @@ -// -// PickServerTitleCell.swift -// Mastodon -// -// Created by BradGao on 2021/2/23. -// - -import UIKit - -final class PickServerTitleCell: UITableViewCell { - - let titleLabel: UILabel = { - let label = UILabel() - label.font = UIFontMetrics(forTextStyle: .largeTitle).scaledFont(for: .systemFont(ofSize: 34, weight: .bold)) - label.textColor = Asset.Colors.Label.primary.color - label.text = L10n.Scene.ServerPicker.title - label.adjustsFontForContentSizeCategory = true - label.translatesAutoresizingMaskIntoConstraints = false - label.numberOfLines = 0 - return label - }() - - var containerHeightLayoutConstraint: NSLayoutConstraint! - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - _init() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - _init() - } -} - -extension PickServerTitleCell { - - private func _init() { - selectionStyle = .none - backgroundColor = Asset.Theme.Mastodon.systemGroupedBackground.color - - let container = UIStackView() - container.axis = .vertical - container.translatesAutoresizingMaskIntoConstraints = false - containerHeightLayoutConstraint = container.heightAnchor.constraint(equalToConstant: .leastNonzeroMagnitude) - contentView.addSubview(container) - NSLayoutConstraint.activate([ - 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/PickServer/View/PickServerCategoryView.swift b/Mastodon/Scene/Onboarding/PickServer/View/PickServerCategoryView.swift index 6565fbcfa..822085863 100644 --- a/Mastodon/Scene/Onboarding/PickServer/View/PickServerCategoryView.swift +++ b/Mastodon/Scene/Onboarding/PickServer/View/PickServerCategoryView.swift @@ -7,27 +7,29 @@ import UIKit import MastodonSDK +import MastodonAsset +import MastodonLocalization class PickServerCategoryView: UIView { - var bgShadowView: UIView = { + let highlightedIndicatorView: UIView = { let view = UIView() - view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = Asset.Colors.Label.primary.color return view }() - - var bgView: UIView = { - let view = UIView() - view.translatesAutoresizingMaskIntoConstraints = false - view.layer.masksToBounds = true - view.layer.cornerRadius = 30 - return view - }() - - var titleLabel: UILabel = { + + let emojiLabel: UILabel = { let label = UILabel() label.textAlignment = .center - label.translatesAutoresizingMaskIntoConstraints = false + label.font = .systemFont(ofSize: 34, weight: .regular) + return label + }() + + let titleLabel: UILabel = { + let label = UILabel() + label.textAlignment = .center + label.font = .systemFont(ofSize: 17, weight: .semibold) + label.textColor = Asset.Colors.Label.secondary.color return label }() @@ -45,20 +47,27 @@ class PickServerCategoryView: UIView { extension PickServerCategoryView { private func configure() { - addSubview(bgView) - addSubview(titleLabel) - - bgView.backgroundColor = Asset.Theme.Mastodon.secondaryGroupedSystemBackground.color - + let container = UIStackView() + container.axis = .vertical + container.distribution = .fillProportionally + + container.translatesAutoresizingMaskIntoConstraints = false + addSubview(container) NSLayoutConstraint.activate([ - bgView.leadingAnchor.constraint(equalTo: self.leadingAnchor), - bgView.trailingAnchor.constraint(equalTo: self.trailingAnchor), - bgView.topAnchor.constraint(equalTo: self.topAnchor), - bgView.bottomAnchor.constraint(equalTo: self.bottomAnchor), - - titleLabel.centerXAnchor.constraint(equalTo: self.centerXAnchor), - titleLabel.centerYAnchor.constraint(equalTo: self.centerYAnchor), + container.topAnchor.constraint(equalTo: topAnchor), + container.leadingAnchor.constraint(equalTo: leadingAnchor), + container.trailingAnchor.constraint(equalTo: trailingAnchor), + container.bottomAnchor.constraint(equalTo: bottomAnchor), ]) + + container.addArrangedSubview(emojiLabel) + container.addArrangedSubview(titleLabel) + highlightedIndicatorView.translatesAutoresizingMaskIntoConstraints = false + container.addArrangedSubview(highlightedIndicatorView) + NSLayoutConstraint.activate([ + highlightedIndicatorView.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: self) * 3).priority(.required - 1), + ]) + titleLabel.setContentHuggingPriority(.required - 1, for: .vertical) } } diff --git a/Mastodon/Scene/Onboarding/PickServer/View/PickServerEmptyStateView.swift b/Mastodon/Scene/Onboarding/PickServer/View/PickServerEmptyStateView.swift index 1d2c17c76..a75570087 100644 --- a/Mastodon/Scene/Onboarding/PickServer/View/PickServerEmptyStateView.swift +++ b/Mastodon/Scene/Onboarding/PickServer/View/PickServerEmptyStateView.swift @@ -6,6 +6,8 @@ // import UIKit +import MastodonAsset +import MastodonLocalization final class PickServerEmptyStateView: UIView { @@ -44,13 +46,7 @@ final class PickServerEmptyStateView: UIView { extension PickServerEmptyStateView { private func _init() { - backgroundColor = Asset.Theme.Mastodon.secondaryGroupedSystemBackground.color - layer.maskedCorners = [ - .layerMinXMaxYCorner, - .layerMaxXMaxYCorner - ] - layer.cornerCurve = .continuous - layer.cornerRadius = MastodonPickServerAppearance.tableViewCornerRadius + backgroundColor = .clear let topPaddingView = UIView() topPaddingView.translatesAutoresizingMaskIntoConstraints = false @@ -101,7 +97,7 @@ extension PickServerEmptyStateView { ]) NSLayoutConstraint.activate([ - bottomPaddingView.heightAnchor.constraint(equalTo: topPaddingView.heightAnchor, multiplier: 1.0).priority(.defaultHigh), + topPaddingView.heightAnchor.constraint(equalTo: bottomPaddingView.heightAnchor, multiplier: 2.5).priority(.defaultHigh), // magic scale ]) activityIndicatorView.hidesWhenStopped = true diff --git a/Mastodon/Scene/Onboarding/PickServer/View/PickServerServerSectionTableHeaderView.swift b/Mastodon/Scene/Onboarding/PickServer/View/PickServerServerSectionTableHeaderView.swift new file mode 100644 index 000000000..b2269b9c4 --- /dev/null +++ b/Mastodon/Scene/Onboarding/PickServer/View/PickServerServerSectionTableHeaderView.swift @@ -0,0 +1,206 @@ +// +// PickServerServerSectionTableHeaderView.swift +// Mastodon +// +// Created by MainasuK on 2022-1-4. +// + +import os.log +import UIKit +import Tabman +import MastodonAsset +import MastodonLocalization + +protocol PickServerServerSectionTableHeaderViewDelegate: AnyObject { + func pickServerServerSectionTableHeaderView(_ headerView: PickServerServerSectionTableHeaderView, collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) + func pickServerServerSectionTableHeaderView(_ headerView: PickServerServerSectionTableHeaderView, searchTextDidChange searchText: String?) +} + +final class PickServerServerSectionTableHeaderView: UIView { + + static let collectionViewHeight: CGFloat = 88 + static let searchTextFieldHeight: CGFloat = 38 + static let spacing: CGFloat = 11 + + static let height: CGFloat = collectionViewHeight + spacing + searchTextFieldHeight + spacing + + weak var delegate: PickServerServerSectionTableHeaderViewDelegate? + + var diffableDataSource: UICollectionViewDiffableDataSource<CategoryPickerSection, CategoryPickerItem>? + + static func createCollectionViewLayout() -> UICollectionViewLayout { + let itemSize = NSCollectionLayoutSize(widthDimension: .estimated(88), heightDimension: .absolute(PickServerServerSectionTableHeaderView.collectionViewHeight)) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let groupSize = NSCollectionLayoutSize(widthDimension: itemSize.widthDimension, heightDimension: itemSize.heightDimension) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) + + let section = NSCollectionLayoutSection(group: group) + section.orthogonalScrollingBehavior = .continuous + section.contentInsetsReference = .readableContent + section.interGroupSpacing = 16 + + return UICollectionViewCompositionalLayout(section: section) + } + + let collectionView: UICollectionView = { + let collectionViewLayout = PickServerServerSectionTableHeaderView.createCollectionViewLayout() + let view = ControlContainableCollectionView( + frame: CGRect(origin: .zero, size: CGSize(width: 100, height: PickServerServerSectionTableHeaderView.collectionViewHeight)), + collectionViewLayout: collectionViewLayout + ) + view.register(PickServerCategoryCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: PickServerCategoryCollectionViewCell.self)) + view.backgroundColor = .clear + view.alwaysBounceVertical = false + view.showsHorizontalScrollIndicator = false + view.showsVerticalScrollIndicator = false + view.layer.masksToBounds = false + return view + }() + + let searchTextField: UITextField = { + let textField = UITextField() + textField.backgroundColor = Asset.Scene.Onboarding.searchBarBackground.color + textField.leftView = { + let imageView = UIImageView( + image: UIImage( + systemName: "magnifyingglass", + withConfiguration: UIImage.SymbolConfiguration(pointSize: 15, weight: .regular) + ) + ) + imageView.tintColor = Asset.Colors.Label.secondary.color.withAlphaComponent(0.6) + + let containerView = UIView() + imageView.translatesAutoresizingMaskIntoConstraints = false + containerView.addSubview(imageView) + NSLayoutConstraint.activate([ + imageView.topAnchor.constraint(equalTo: containerView.topAnchor), + imageView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 8), + imageView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + ]) + + let paddingView = UIView() + paddingView.translatesAutoresizingMaskIntoConstraints = false + containerView.addSubview(paddingView) + NSLayoutConstraint.activate([ + paddingView.topAnchor.constraint(equalTo: containerView.topAnchor), + paddingView.leadingAnchor.constraint(equalTo: imageView.trailingAnchor), + paddingView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + paddingView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + paddingView.widthAnchor.constraint(equalToConstant: 4).priority(.defaultHigh), + ]) + return containerView + }() + textField.leftViewMode = .always + textField.font = .systemFont(ofSize: 15, weight: .regular) + textField.tintColor = Asset.Colors.Label.primary.color + textField.textColor = Asset.Colors.Label.primary.color + textField.adjustsFontForContentSizeCategory = true + textField.attributedPlaceholder = + NSAttributedString( + string: L10n.Scene.ServerPicker.Input.placeholder, + attributes: [.font: UIFont.systemFont(ofSize: 15, weight: .regular), + .foregroundColor: Asset.Colors.Label.secondary.color.withAlphaComponent(0.6)]) + textField.clearButtonMode = .whileEditing + textField.autocapitalizationType = .none + textField.autocorrectionType = .no + textField.returnKeyType = .done + textField.keyboardType = .URL + textField.borderStyle = .none + + textField.layer.masksToBounds = true + textField.layer.cornerRadius = 10 + textField.layer.cornerCurve = .continuous + + return textField + }() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + + override func layoutSubviews() { + super.layoutSubviews() + + collectionView.invalidateIntrinsicContentSize() + } + +} + +extension PickServerServerSectionTableHeaderView { + private func _init() { + preservesSuperviewLayoutMargins = true + backgroundColor = Asset.Scene.Onboarding.background.color + + collectionView.translatesAutoresizingMaskIntoConstraints = false + collectionView.preservesSuperviewLayoutMargins = true + addSubview(collectionView) + NSLayoutConstraint.activate([ + collectionView.topAnchor.constraint(equalTo: topAnchor), + collectionView.leadingAnchor.constraint(equalTo: leadingAnchor), + collectionView.trailingAnchor.constraint(equalTo: trailingAnchor), + collectionView.heightAnchor.constraint(equalToConstant: PickServerServerSectionTableHeaderView.collectionViewHeight).priority(.required - 1), + ]) + + searchTextField.translatesAutoresizingMaskIntoConstraints = false + addSubview(searchTextField) + NSLayoutConstraint.activate([ + searchTextField.topAnchor.constraint(equalTo: collectionView.bottomAnchor, constant: PickServerServerSectionTableHeaderView.spacing), + searchTextField.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor), + searchTextField.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor), + bottomAnchor.constraint(equalTo: searchTextField.bottomAnchor, constant: PickServerServerSectionTableHeaderView.spacing), + searchTextField.heightAnchor.constraint(equalToConstant: PickServerServerSectionTableHeaderView.searchTextFieldHeight).priority(.required - 1), + ]) + + collectionView.delegate = self + searchTextField.delegate = self + searchTextField.addTarget(self, action: #selector(PickServerServerSectionTableHeaderView.textFieldDidChange(_:)), for: .editingChanged) + } +} + +extension PickServerServerSectionTableHeaderView { + @objc private func textFieldDidChange(_ textField: UITextField) { + delegate?.pickServerServerSectionTableHeaderView(self, searchTextDidChange: textField.text) + } +} + +// MARK: - UICollectionViewDelegate +extension PickServerServerSectionTableHeaderView: UICollectionViewDelegate { + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: indexPath: %s", ((#file as NSString).lastPathComponent), #line, #function, indexPath.debugDescription) + collectionView.selectItem(at: indexPath, animated: true, scrollPosition: .centeredHorizontally) + delegate?.pickServerServerSectionTableHeaderView(self, collectionView: collectionView, didSelectItemAt: indexPath) + } + +} + +extension PickServerServerSectionTableHeaderView { + + override func accessibilityElementCount() -> Int { + guard let diffableDataSource = diffableDataSource else { return 0 } + return diffableDataSource.snapshot().itemIdentifiers.count + } + + override func accessibilityElement(at index: Int) -> Any? { + guard let item = collectionView.cellForItem(at: IndexPath(item: index, section: 0)) else { return nil } + return item + } + +} + +// MARK: - UITextFieldDelegate +extension PickServerServerSectionTableHeaderView: UITextFieldDelegate { + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + textField.resignFirstResponder() + return false + } + +} diff --git a/Mastodon/Scene/Onboarding/Register/Cell/MastodonRegisterAvatarTableViewCell.swift b/Mastodon/Scene/Onboarding/Register/Cell/MastodonRegisterAvatarTableViewCell.swift new file mode 100644 index 000000000..154385e6a --- /dev/null +++ b/Mastodon/Scene/Onboarding/Register/Cell/MastodonRegisterAvatarTableViewCell.swift @@ -0,0 +1,116 @@ +// +// MastodonRegisterAvatarTableViewCell.swift +// Mastodon +// +// Created by MainasuK on 2022-1-5. +// + +import UIKit +import Combine +import MastodonAsset +import MastodonLocalization + +final class MastodonRegisterAvatarTableViewCell: UITableViewCell { + + static let containerSize = CGSize(width: 88, height: 88) + + var disposeBag = Set<AnyCancellable>() + + let containerView: UIView = { + let view = UIView() + view.backgroundColor = .clear + view.layer.masksToBounds = true + view.layer.cornerCurve = .continuous + view.layer.cornerRadius = 22 + return view + }() + + let avatarButton: HighlightDimmableButton = { + let button = HighlightDimmableButton() + button.backgroundColor = Asset.Theme.Mastodon.secondaryGroupedSystemBackground.color + button.setImage(Asset.Scene.Onboarding.avatarPlaceholder.image, for: .normal) + return button + }() + + let editBannerView: UIView = { + let bannerView = UIView() + bannerView.backgroundColor = UIColor.black.withAlphaComponent(0.5) + bannerView.isUserInteractionEnabled = false + + let label: UILabel = { + let label = UILabel() + label.textColor = .white + label.text = L10n.Common.Controls.Actions.edit + label.font = .systemFont(ofSize: 13, weight: .semibold) + label.textAlignment = .center + label.minimumScaleFactor = 0.5 + label.adjustsFontSizeToFitWidth = true + return label + }() + + label.translatesAutoresizingMaskIntoConstraints = false + bannerView.addSubview(label) + NSLayoutConstraint.activate([ + label.topAnchor.constraint(equalTo: bannerView.topAnchor), + label.leadingAnchor.constraint(equalTo: bannerView.leadingAnchor), + label.trailingAnchor.constraint(equalTo: bannerView.trailingAnchor), + label.bottomAnchor.constraint(equalTo: bannerView.bottomAnchor), + ]) + + return bannerView + }() + + 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 MastodonRegisterAvatarTableViewCell { + + private func _init() { + selectionStyle = .none + backgroundColor = .clear + + containerView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(containerView) + NSLayoutConstraint.activate([ + containerView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 22), + containerView.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + contentView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: 8), + containerView.widthAnchor.constraint(equalToConstant: MastodonRegisterAvatarTableViewCell.containerSize.width).priority(.required - 1), + containerView.heightAnchor.constraint(equalToConstant: MastodonRegisterAvatarTableViewCell.containerSize.height).priority(.required - 1), + ]) + + avatarButton.translatesAutoresizingMaskIntoConstraints = false + containerView.addSubview(avatarButton) + NSLayoutConstraint.activate([ + avatarButton.topAnchor.constraint(equalTo: containerView.topAnchor), + avatarButton.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + avatarButton.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + avatarButton.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + ]) + + editBannerView.translatesAutoresizingMaskIntoConstraints = false + containerView.addSubview(editBannerView) + NSLayoutConstraint.activate([ + editBannerView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + editBannerView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + editBannerView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + editBannerView.heightAnchor.constraint(equalToConstant: 22), + ]) + } + +} diff --git a/Mastodon/Scene/Onboarding/Register/Cell/MastodonRegisterPasswordHintTableViewCell.swift b/Mastodon/Scene/Onboarding/Register/Cell/MastodonRegisterPasswordHintTableViewCell.swift new file mode 100644 index 000000000..1324c2822 --- /dev/null +++ b/Mastodon/Scene/Onboarding/Register/Cell/MastodonRegisterPasswordHintTableViewCell.swift @@ -0,0 +1,50 @@ +// +// MastodonRegisterPasswordHintTableViewCell.swift +// Mastodon +// +// Created by MainasuK on 2022-1-7. +// + +import UIKit +import MastodonAsset +import MastodonLocalization + +final class MastodonRegisterPasswordHintTableViewCell: UITableViewCell { + + let passwordRuleLabel: UILabel = { + let label = UILabel() + label.font = .preferredFont(forTextStyle: .footnote) + label.textColor = Asset.Colors.Label.secondary.color + label.text = L10n.Scene.Register.Input.Password.hint + 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 MastodonRegisterPasswordHintTableViewCell { + + private func _init() { + selectionStyle = .none + backgroundColor = .clear + + passwordRuleLabel.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(passwordRuleLabel) + NSLayoutConstraint.activate([ + passwordRuleLabel.topAnchor.constraint(equalTo: contentView.topAnchor), + passwordRuleLabel.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), + passwordRuleLabel.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), + passwordRuleLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + ]) + } + +} diff --git a/Mastodon/Scene/Onboarding/Register/Cell/MastodonRegisterTextFieldTableViewCell.swift b/Mastodon/Scene/Onboarding/Register/Cell/MastodonRegisterTextFieldTableViewCell.swift new file mode 100644 index 000000000..3daa2eb18 --- /dev/null +++ b/Mastodon/Scene/Onboarding/Register/Cell/MastodonRegisterTextFieldTableViewCell.swift @@ -0,0 +1,140 @@ +// +// MastodonRegisterTextFieldTableViewCell.swift +// Mastodon +// +// Created by MainasuK on 2022-1-7. +// + +import UIKit +import Combine +import MastodonUI +import MastodonAsset +import MastodonLocalization + +final class MastodonRegisterTextFieldTableViewCell: UITableViewCell { + + static let textFieldHeight: CGFloat = 50 + static let textFieldLabelFont = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold), maximumPointSize: 22) + + var disposeBag = Set<AnyCancellable>() + + let textFieldShadowContainer = ShadowBackgroundContainer() + let textField: UITextField = { + let textField = UITextField() + textField.font = MastodonRegisterTextFieldTableViewCell.textFieldLabelFont + textField.backgroundColor = Asset.Scene.Onboarding.textFieldBackground.color + textField.layer.masksToBounds = true + textField.layer.cornerRadius = 10 + textField.layer.cornerCurve = .continuous + return textField + }() + + override func prepareForReuse() { + super.prepareForReuse() + + disposeBag.removeAll() + textFieldShadowContainer.shadowColor = .black + textFieldShadowContainer.shadowAlpha = 0.25 + resetTextField() + } + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension MastodonRegisterTextFieldTableViewCell { + + private func _init() { + selectionStyle = .none + backgroundColor = .clear + + textFieldShadowContainer.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(textFieldShadowContainer) + NSLayoutConstraint.activate([ + textFieldShadowContainer.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 6), + textFieldShadowContainer.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), + textFieldShadowContainer.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: textFieldShadowContainer.bottomAnchor, constant: 6), + ]) + + textField.translatesAutoresizingMaskIntoConstraints = false + textFieldShadowContainer.addSubview(textField) + NSLayoutConstraint.activate([ + textField.topAnchor.constraint(equalTo: textFieldShadowContainer.topAnchor), + textField.leadingAnchor.constraint(equalTo: textFieldShadowContainer.leadingAnchor), + textField.trailingAnchor.constraint(equalTo: textFieldShadowContainer.trailingAnchor), + textField.bottomAnchor.constraint(equalTo: textFieldShadowContainer.bottomAnchor), + textField.heightAnchor.constraint(equalToConstant: MastodonRegisterTextFieldTableViewCell.textFieldHeight).priority(.required - 1), + ]) + + resetTextField() + } + +} + +extension MastodonRegisterTextFieldTableViewCell { + func resetTextField() { + textField.keyboardType = .default + textField.autocorrectionType = .default + textField.autocapitalizationType = .none + textField.attributedPlaceholder = nil + textField.isSecureTextEntry = false + textField.textAlignment = .natural + textField.semanticContentAttribute = .unspecified + + let paddingRect = CGRect(x: 0, y: 0, width: 16, height: 10) + textField.leftView = UIView(frame: paddingRect) + textField.leftViewMode = .always + textField.rightView = UIView(frame: paddingRect) + textField.rightViewMode = .always + } + + func setupTextViewRightView(text: String) { + textField.rightView = { + let containerView = UIView() + + let paddingView = UIView(frame: CGRect(x: 0, y: 0, width: 8, height: MastodonRegisterTextFieldTableViewCell.textFieldHeight)) + paddingView.translatesAutoresizingMaskIntoConstraints = false + containerView.addSubview(paddingView) + NSLayoutConstraint.activate([ + paddingView.topAnchor.constraint(equalTo: containerView.topAnchor), + paddingView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + paddingView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + paddingView.widthAnchor.constraint(equalToConstant: 8).priority(.defaultHigh), + ]) + + let label = UILabel() + label.font = MastodonRegisterTextFieldTableViewCell.textFieldLabelFont + label.textColor = Asset.Colors.Label.primary.color + label.text = text + + label.translatesAutoresizingMaskIntoConstraints = false + containerView.addSubview(label) + NSLayoutConstraint.activate([ + label.topAnchor.constraint(equalTo: containerView.topAnchor), + label.leadingAnchor.constraint(equalTo: paddingView.trailingAnchor), + containerView.trailingAnchor.constraint(equalTo: label.trailingAnchor, constant: 16), + label.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + ]) + return containerView + }() + } + + func setupTextViewPlaceholder(text: String) { + textField.attributedPlaceholder = NSAttributedString( + string: text, + attributes: [ + .foregroundColor: Asset.Colors.Label.secondary.color, + .font: MastodonRegisterTextFieldTableViewCell.textFieldLabelFont + ] + ) + } +} diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift index b1fa1b432..9260f9e21 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift @@ -10,38 +10,10 @@ import Foundation import OSLog import PhotosUI import UIKit +import MastodonAsset +import MastodonLocalization extension MastodonRegisterViewController { - func createMediaContextMenu() -> UIMenu { - var children: [UIMenuElement] = [] - let photoLibraryAction = UIAction(title: L10n.Scene.Compose.MediaSelection.photoLibrary, image: UIImage(systemName: "rectangle.on.rectangle"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in - guard let self = self else { return } - self.present(self.imagePicker, animated: true, completion: nil) - } - children.append(photoLibraryAction) - if UIImagePickerController.isSourceTypeAvailable(.camera) { - let cameraAction = UIAction(title: L10n.Scene.Compose.MediaSelection.camera, image: UIImage(systemName: "camera"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { [weak self] _ in - guard let self = self else { return } - self.present(self.imagePickerController, animated: true, completion: nil) - }) - children.append(cameraAction) - } - let browseAction = UIAction(title: L10n.Scene.Compose.MediaSelection.browse, image: UIImage(systemName: "ellipsis"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in - guard let self = self else { return } - self.present(self.documentPickerController, animated: true, completion: nil) - } - children.append(browseAction) - if self.viewModel.avatarImage.value != nil { - let deleteAction = UIAction(title: L10n.Scene.Register.Input.Avatar.delete, image: UIImage(systemName: "delete.left"), identifier: nil, discoverabilityTitle: nil, attributes: [.destructive], state: .off) { [weak self] _ in - guard let self = self else { return } - self.viewModel.avatarImage.value = nil - } - children.append(deleteAction) - } - - return UIMenu(title: "", image: nil, identifier: nil, options: .displayInline, children: children) - } - private func cropImage(image: UIImage, pickerViewController: UIViewController) { DispatchQueue.main.async { let cropController = CropViewController(croppingStyle: .default, image: image) @@ -49,6 +21,12 @@ extension MastodonRegisterViewController { cropController.setAspectRatioPreset(.presetSquare, animated: true) cropController.aspectRatioPickerButtonHidden = true cropController.aspectRatioLockEnabled = true + + // fix iPad compatibility issue + // ref: https://github.com/TimOliver/TOCropViewController/issues/365#issuecomment-550239604 + cropController.modalTransitionStyle = .crossDissolve + cropController.transitioningDelegate = nil + pickerViewController.dismiss(animated: true, completion: { self.present(cropController, animated: true, completion: nil) }) @@ -57,7 +35,6 @@ extension MastodonRegisterViewController { } // MARK: - PHPickerViewControllerDelegate - extension MastodonRegisterViewController: PHPickerViewControllerDelegate { func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { guard let itemProvider = results.first?.itemProvider, itemProvider.canLoadObject(ofClass: UIImage.self) else { @@ -86,7 +63,6 @@ extension MastodonRegisterViewController: PHPickerViewControllerDelegate { } // MARK: - UIImagePickerControllerDelegate - extension MastodonRegisterViewController: UIImagePickerControllerDelegate & UINavigationControllerDelegate { func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { picker.dismiss(animated: true, completion: nil) @@ -103,7 +79,6 @@ extension MastodonRegisterViewController: UIImagePickerControllerDelegate & UINa } // MARK: - UIDocumentPickerDelegate - extension MastodonRegisterViewController: UIDocumentPickerDelegate { func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { guard let url = urls.first else { return } @@ -121,10 +96,9 @@ extension MastodonRegisterViewController: UIDocumentPickerDelegate { } // MARK: - CropViewControllerDelegate - extension MastodonRegisterViewController: CropViewControllerDelegate { public func cropViewController(_ cropViewController: CropViewController, didCropToImage image: UIImage, withRect cropRect: CGRect, angle: Int) { - self.viewModel.avatarImage.value = image + self.viewModel.avatarImage = image cropViewController.dismiss(animated: true, completion: nil) } } diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift index 8428aaa79..bd2db3d45 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift @@ -11,16 +11,18 @@ import MastodonSDK import os.log import PhotosUI import UIKit +import MastodonAsset +import MastodonLocalization final class MastodonRegisterViewController: UIViewController, NeedsDependency, OnboardingViewControllerAppearance { static let avatarImageMaxSizeInPixel = CGSize(width: 400, height: 400) - static let textFieldLabelFont = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold), maximumPointSize: 22) - static let errorPromptLabelFont = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .systemFont(ofSize: 13, weight: .semibold), maximumPointSize: 18) + let logger = Logger(subsystem: "MastodonRegisterViewController", category: "ViewController") var disposeBag = Set<AnyCancellable>() - + private var observations = Set<NSKeyValueObservation>() + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } @@ -51,236 +53,30 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O let tapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer - let scrollView: UIScrollView = { - let scrollview = UIScrollView() - scrollview.showsVerticalScrollIndicator = false - scrollview.keyboardDismissMode = .interactive - scrollview.alwaysBounceVertical = true - scrollview.clipsToBounds = false // make content could display over bleeding - scrollview.translatesAutoresizingMaskIntoConstraints = false - return scrollview + let tableView: UITableView = { + let tableView = UITableView() + tableView.rowHeight = UITableView.automaticDimension + tableView.separatorStyle = .none + tableView.backgroundColor = .clear + tableView.keyboardDismissMode = .onDrag + if #available(iOS 15.0, *) { + tableView.sectionHeaderTopPadding = .leastNonzeroMagnitude + } else { + // Fallback on earlier versions + } + return tableView }() - let stackView = UIStackView() - - let largeTitleLabel: UILabel = { - let label = UILabel() - label.font = UIFontMetrics(forTextStyle: .largeTitle).scaledFont(for: .systemFont(ofSize: 34, weight: .bold)) - label.textColor = Asset.Colors.Label.primary.color - label.text = L10n.Scene.Register.title - label.numberOfLines = 0 - return label - }() - - let avatarView: UIView = { - let view = UIView() - view.backgroundColor = .clear - return view - }() - - let avatarButton: UIButton = { - let button = HighlightDimmableButton() - let boldFont = UIFont.systemFont(ofSize: 42) - let configuration = UIImage.SymbolConfiguration(font: boldFont) - let image = UIImage(systemName: "person.fill.viewfinder", withConfiguration: configuration) - - button.setImage(image?.withRenderingMode(UIImage.RenderingMode.alwaysTemplate), for: UIControl.State.normal) - button.imageView?.tintColor = Asset.Colors.Label.secondary.color - button.backgroundColor = Asset.Theme.Mastodon.secondaryGroupedSystemBackground.color - button.layer.cornerRadius = 10 - button.clipsToBounds = true - - return button - }() - - let plusIconImageView: UIImageView = { - let icon = UIImageView() - let image = Asset.Circles.plusCircleFill.image.withRenderingMode(.alwaysTemplate) - icon.image = image - icon.tintColor = Asset.Colors.Icon.plus.color - icon.backgroundColor = UIColor(dynamicProvider: { collection in - switch collection.userInterfaceStyle { - case .dark: - return Asset.Theme.Mastodon.secondaryGroupedSystemBackground.color - default: - return .white - } - }) - return icon - }() - - let domainLabel: UILabel = { - let label = UILabel() - label.font = MastodonRegisterViewController.textFieldLabelFont - label.textColor = Asset.Colors.Label.primary.color - return label - }() - - let usernameTextField: UITextField = { - let textField = UITextField() - textField.returnKeyType = .next - textField.autocapitalizationType = .none - textField.autocorrectionType = .no - textField.backgroundColor = Asset.Theme.Mastodon.secondaryGroupedSystemBackground.color - textField.textColor = Asset.Colors.Label.primary.color - textField.attributedPlaceholder = NSAttributedString(string: L10n.Scene.Register.Input.Username.placeholder, - attributes: [NSAttributedString.Key.foregroundColor: Asset.Colors.Label.secondary.color, - NSAttributedString.Key.font: MastodonRegisterViewController.textFieldLabelFont]) - textField.borderStyle = UITextField.BorderStyle.roundedRect - textField.font = MastodonRegisterViewController.textFieldLabelFont - textField.leftView = { - let containerView = UIView() - - let paddingView = UIView(frame: CGRect(x: 0, y: 0, width: 5, height: textField.frame.height)) - paddingView.translatesAutoresizingMaskIntoConstraints = false - containerView.addSubview(paddingView) - NSLayoutConstraint.activate([ - paddingView.topAnchor.constraint(equalTo: containerView.topAnchor), - paddingView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), - paddingView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), - paddingView.widthAnchor.constraint(equalToConstant: 5).priority(.defaultHigh), - ]) - - let label = UILabel() - label.font = MastodonRegisterViewController.textFieldLabelFont - label.textColor = Asset.Colors.Label.primary.color - label.text = " @" - - label.translatesAutoresizingMaskIntoConstraints = false - containerView.addSubview(label) - NSLayoutConstraint.activate([ - label.topAnchor.constraint(equalTo: containerView.topAnchor), - label.leadingAnchor.constraint(equalTo: paddingView.trailingAnchor), - label.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), - label.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), - ]) - return containerView - }() - textField.leftViewMode = .always - return textField - }() - - let usernameErrorPromptLabel: UILabel = { - let label = UILabel() - let color = Asset.Colors.danger.color - let font = MastodonRegisterViewController.errorPromptLabelFont - return label - }() - - let displayNameTextField: UITextField = { - let textField = UITextField() - textField.returnKeyType = .next - textField.autocapitalizationType = .none - textField.autocorrectionType = .no - textField.backgroundColor = Asset.Theme.Mastodon.secondaryGroupedSystemBackground.color - textField.textColor = Asset.Colors.Label.primary.color - textField.attributedPlaceholder = NSAttributedString(string: L10n.Scene.Register.Input.DisplayName.placeholder, - attributes: [NSAttributedString.Key.foregroundColor: Asset.Colors.Label.secondary.color, - NSAttributedString.Key.font: MastodonRegisterViewController.textFieldLabelFont]) - textField.borderStyle = UITextField.BorderStyle.roundedRect - let paddingView = UIView(frame: CGRect(x: 0, y: 0, width: 5, height: textField.frame.height)) - textField.leftView = paddingView - textField.leftViewMode = .always - textField.font = MastodonRegisterViewController.textFieldLabelFont - return textField - }() - - let emailTextField: UITextField = { - let textField = UITextField() - textField.returnKeyType = .next - textField.autocapitalizationType = .none - textField.autocorrectionType = .no - textField.keyboardType = .emailAddress - textField.backgroundColor = Asset.Theme.Mastodon.secondaryGroupedSystemBackground.color - textField.textColor = Asset.Colors.Label.primary.color - textField.attributedPlaceholder = NSAttributedString(string: L10n.Scene.Register.Input.Email.placeholder, - attributes: [NSAttributedString.Key.foregroundColor: Asset.Colors.Label.secondary.color, - NSAttributedString.Key.font: MastodonRegisterViewController.textFieldLabelFont]) - textField.borderStyle = UITextField.BorderStyle.roundedRect - let paddingView = UIView(frame: CGRect(x: 0, y: 0, width: 5, height: textField.frame.height)) - textField.leftView = paddingView - textField.leftViewMode = .always - textField.font = MastodonRegisterViewController.textFieldLabelFont - return textField - }() - - let emailErrorPromptLabel: UILabel = { - let label = UILabel() - let color = Asset.Colors.danger.color - let font = MastodonRegisterViewController.errorPromptLabelFont - return label - }() - - let passwordTextField: UITextField = { - let textField = UITextField() - textField.returnKeyType = .next // set to "Return" depends on if the last input field or not - textField.autocapitalizationType = .none - textField.autocorrectionType = .no - textField.keyboardType = .asciiCapable - textField.isSecureTextEntry = true - textField.backgroundColor = Asset.Theme.Mastodon.secondaryGroupedSystemBackground.color - textField.textColor = Asset.Colors.Label.primary.color - textField.attributedPlaceholder = NSAttributedString(string: L10n.Scene.Register.Input.Password.placeholder, - attributes: [NSAttributedString.Key.foregroundColor: Asset.Colors.Label.secondary.color, - NSAttributedString.Key.font: MastodonRegisterViewController.textFieldLabelFont]) - textField.borderStyle = UITextField.BorderStyle.roundedRect - let paddingView = UIView(frame: CGRect(x: 0, y: 0, width: 5, height: textField.frame.height)) - textField.leftView = paddingView - textField.leftViewMode = .always - textField.font = MastodonRegisterViewController.textFieldLabelFont - return textField - }() - - let passwordCheckLabel: UILabel = { - let label = UILabel() - label.numberOfLines = 0 - return label - }() - - let passwordErrorPromptLabel: UILabel = { - let label = UILabel() - let color = Asset.Colors.danger.color - let font = MastodonRegisterViewController.errorPromptLabelFont - return label - }() - - - lazy var reasonTextField: UITextField = { - let textField = UITextField() - textField.returnKeyType = .next // set to "Return" depends on if the last input field or not - textField.autocapitalizationType = .none - textField.autocorrectionType = .no - textField.backgroundColor = Asset.Theme.Mastodon.secondaryGroupedSystemBackground.color - textField.textColor = Asset.Colors.Label.primary.color - textField.attributedPlaceholder = NSAttributedString(string: L10n.Scene.Register.Input.Invite.registrationUserInviteRequest, - attributes: [NSAttributedString.Key.foregroundColor: Asset.Colors.Label.secondary.color, - NSAttributedString.Key.font: MastodonRegisterViewController.textFieldLabelFont]) - textField.borderStyle = UITextField.BorderStyle.roundedRect - let paddingView = UIView(frame: CGRect(x: 0, y: 0, width: 5, height: textField.frame.height)) - textField.leftView = paddingView - textField.leftViewMode = .always - textField.font = MastodonRegisterViewController.textFieldLabelFont - return textField - }() - - let reasonErrorPromptLabel: UILabel = { - let label = UILabel() - let color = Asset.Colors.danger.color - let font = MastodonRegisterViewController.errorPromptLabelFont - return label - }() - - let buttonContainer = UIView() - let signUpButton: PrimaryActionButton = { - let button = PrimaryActionButton() - button.isEnabled = false - button.setTitle(L10n.Common.Controls.Actions.continue, for: .normal) - return button + let navigationActionView: NavigationActionView = { + let navigationActionView = NavigationActionView() + navigationActionView.backgroundColor = Asset.Scene.Onboarding.background.color + return navigationActionView }() deinit { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function) } + } extension MastodonRegisterViewController { @@ -288,518 +84,203 @@ extension MastodonRegisterViewController { override func viewDidLoad() { super.viewDidLoad() + navigationItem.leftBarButtonItem = UIBarButtonItem() + setupOnboardingAppearance() - configureTitleLabel() defer { setupNavigationBarBackgroundView() - configureFormLayout() } - avatarButton.menu = createMediaContextMenu() - avatarButton.showsMenuAsPrimaryAction = true + 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), + ]) - domainLabel.text = "@" + viewModel.domain + " " - domainLabel.sizeToFit() - passwordCheckLabel.attributedText = MastodonRegisterViewModel.attributeStringForPassword(validateState: .empty) - usernameTextField.rightView = domainLabel - usernameTextField.rightViewMode = .always - usernameTextField.delegate = self - displayNameTextField.delegate = self - emailTextField.delegate = self - passwordTextField.delegate = self - - // gesture - view.addGestureRecognizer(tapGestureRecognizer) - tapGestureRecognizer.addTarget(self, action: #selector(tapGestureRecognizerHandler)) - - // stackView - stackView.axis = .vertical - stackView.distribution = .fill - stackView.spacing = 40 - stackView.layoutMargins = UIEdgeInsets(top: 20, left: 0, bottom: 26, right: 0) - stackView.isLayoutMarginsRelativeArrangement = true - stackView.addArrangedSubview(largeTitleLabel) - stackView.addArrangedSubview(avatarView) - - 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 { - formTableStackView.addArrangedSubview(reasonTextField) + navigationActionView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(navigationActionView) + defer { + view.bringSubviewToFront(navigationActionView) } - - usernameErrorPromptLabel.translatesAutoresizingMaskIntoConstraints = false - formTableStackView.addSubview(usernameErrorPromptLabel) NSLayoutConstraint.activate([ - usernameErrorPromptLabel.topAnchor.constraint(equalTo: usernameTextField.bottomAnchor, constant: 6), - usernameErrorPromptLabel.leadingAnchor.constraint(equalTo: usernameTextField.leadingAnchor), - usernameErrorPromptLabel.trailingAnchor.constraint(equalTo: usernameTextField.trailingAnchor), + navigationActionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + navigationActionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + view.bottomAnchor.constraint(equalTo: navigationActionView.bottomAnchor), ]) - emailErrorPromptLabel.translatesAutoresizingMaskIntoConstraints = false - formTableStackView.addSubview(emailErrorPromptLabel) - NSLayoutConstraint.activate([ - emailErrorPromptLabel.topAnchor.constraint(equalTo: emailTextField.bottomAnchor, constant: 6), - emailErrorPromptLabel.leadingAnchor.constraint(equalTo: emailTextField.leadingAnchor), - emailErrorPromptLabel.trailingAnchor.constraint(equalTo: emailTextField.trailingAnchor), - ]) + navigationActionView + .observe(\.bounds, options: [.initial, .new]) { [weak self] navigationActionView, _ in + guard let self = self else { return } + let inset = navigationActionView.frame.height + self.tableView.contentInset.bottom = inset + } + .store(in: &observations) - passwordErrorPromptLabel.translatesAutoresizingMaskIntoConstraints = false - formTableStackView.addSubview(passwordErrorPromptLabel) - NSLayoutConstraint.activate([ - passwordErrorPromptLabel.topAnchor.constraint(equalTo: passwordCheckLabel.bottomAnchor, constant: 2), - passwordErrorPromptLabel.leadingAnchor.constraint(equalTo: passwordTextField.leadingAnchor), - passwordErrorPromptLabel.trailingAnchor.constraint(equalTo: passwordTextField.trailingAnchor), - ]) - - // scrollView - view.addSubview(scrollView) - NSLayoutConstraint.activate([ - scrollView.frameLayoutGuide.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor), - scrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor), - view.readableContentGuide.trailingAnchor.constraint(equalTo: scrollView.frameLayoutGuide.trailingAnchor), - scrollView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.layoutMarginsGuide.bottomAnchor), - scrollView.frameLayoutGuide.widthAnchor.constraint(equalTo: scrollView.contentLayoutGuide.widthAnchor), - ]) - - // stackView - scrollView.addSubview(stackView) - stackView.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - stackView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), - stackView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), - stackView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), - stackView.widthAnchor.constraint(equalTo: scrollView.contentLayoutGuide.widthAnchor), - scrollView.contentLayoutGuide.bottomAnchor.constraint(equalTo: stackView.bottomAnchor), - ]) - - // photoview - avatarView.translatesAutoresizingMaskIntoConstraints = false - avatarView.addSubview(avatarButton) - NSLayoutConstraint.activate([ - avatarView.heightAnchor.constraint(equalToConstant: 92).priority(.required - 1), - ]) - avatarButton.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - 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), - ]) - - plusIconImageView.translatesAutoresizingMaskIntoConstraints = false - avatarView.addSubview(plusIconImageView) - NSLayoutConstraint.activate([ - plusIconImageView.centerXAnchor.constraint(equalTo: avatarButton.trailingAnchor), - plusIconImageView.centerYAnchor.constraint(equalTo: avatarButton.bottomAnchor), - ]) - - // textfield - NSLayoutConstraint.activate([ - 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 - formTableStackView.setCustomSpacing(6, after: passwordTextField) - formTableStackView.setCustomSpacing(32, after: passwordCheckLabel) + navigationActionView.backButton.addTarget(self, action: #selector(MastodonRegisterViewController.backButtonPressed(_:)), for: .touchUpInside) + navigationActionView.nextButton.addTarget(self, action: #selector(MastodonRegisterViewController.nextButtonPressed(_:)), for: .touchUpInside) - // return - if viewModel.approvalRequired { - reasonTextField.returnKeyType = .done - } else { - passwordTextField.returnKeyType = .done - } - - // button - formTableStackView.addArrangedSubview(buttonContainer) - signUpButton.translatesAutoresizingMaskIntoConstraints = false - buttonContainer.addSubview(signUpButton) - NSLayoutConstraint.activate([ - signUpButton.topAnchor.constraint(equalTo: buttonContainer.topAnchor), - 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(.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(), - KeyboardResponderService.shared.endFrame.eraseToAnyPublisher() - ) - .sink(receiveValue: { [weak self] state, endFrame in - guard let self = self else { return } - - guard state == .dock else { - self.scrollView.contentInset.bottom = 0.0 - self.scrollView.verticalScrollIndicatorInsets.bottom = 0.0 - return - } - - let contentFrame = self.view.convert(self.scrollView.frame, to: nil) - let padding = contentFrame.maxY - endFrame.minY - guard padding > 0 else { - self.scrollView.contentInset.bottom = 0.0 - self.scrollView.verticalScrollIndicatorInsets.bottom = 0.0 - return - } - - self.scrollView.contentInset.bottom = padding + 16 - self.scrollView.verticalScrollIndicatorInsets.bottom = padding + 16 - - if self.passwordTextField.isFirstResponder { - let contentFrame = self.buttonContainer.convert(self.signUpButton.frame, to: nil) - let labelPadding = contentFrame.maxY - endFrame.minY - let contentOffsetY = self.scrollView.contentOffset.y - DispatchQueue.main.async { - self.scrollView.setContentOffset(CGPoint(x: 0, y: contentOffsetY + labelPadding + 16.0), animated: true) - } - } - }) - .store(in: &disposeBag) - - avatarButton.publisher(for: \.isHighlighted, options: .new) - .receive(on: DispatchQueue.main) - .sink { [weak self] isHighlighted in - guard let self = self else { return } - let alpha: CGFloat = isHighlighted ? 0.6 : 1 - self.plusIconImageView.alpha = alpha - } - .store(in: &disposeBag) - - viewModel.isRegistering - .receive(on: DispatchQueue.main) - .sink { [weak self] isRegistering in - guard let self = self else { return } - isRegistering ? self.signUpButton.showLoading() : self.signUpButton.stopLoading() - } - .store(in: &disposeBag) - - viewModel.usernameValidateState - .receive(on: DispatchQueue.main) - .sink { [weak self] validateState in - guard let self = self else { return } - self.setTextFieldValidAppearance(self.usernameTextField, validateState: validateState) - } - .store(in: &disposeBag) - viewModel.usernameErrorPrompt - .receive(on: DispatchQueue.main) - .sink { [weak self] prompt in - guard let self = self else { return } - self.usernameErrorPromptLabel.attributedText = prompt - } - .store(in: &disposeBag) - viewModel.displayNameValidateState - .receive(on: DispatchQueue.main) - .sink { [weak self] validateState in - guard let self = self else { return } - self.setTextFieldValidAppearance(self.displayNameTextField, validateState: validateState) - } - .store(in: &disposeBag) - viewModel.emailValidateState - .receive(on: DispatchQueue.main) - .sink { [weak self] validateState in - guard let self = self else { return } - self.setTextFieldValidAppearance(self.emailTextField, validateState: validateState) - } - .store(in: &disposeBag) - viewModel.emailErrorPrompt - .receive(on: DispatchQueue.main) - .sink { [weak self] prompt in - guard let self = self else { return } - self.emailErrorPromptLabel.attributedText = prompt - } - .store(in: &disposeBag) - viewModel.passwordValidateState - .receive(on: DispatchQueue.main) - .sink { [weak self] validateState in - guard let self = self else { return } - self.setTextFieldValidAppearance(self.passwordTextField, validateState: validateState) - self.passwordCheckLabel.attributedText = MastodonRegisterViewModel.attributeStringForPassword(validateState: validateState) - } - .store(in: &disposeBag) - viewModel.passwordErrorPrompt - .receive(on: DispatchQueue.main) - .sink { [weak self] prompt in - guard let self = self else { return } - self.passwordErrorPromptLabel.attributedText = prompt - } - .store(in: &disposeBag) - viewModel.reasonErrorPrompt - .receive(on: DispatchQueue.main) - .sink { [weak self] prompt in - guard let self = self else { return } - self.reasonErrorPromptLabel.attributedText = prompt - } - .store(in: &disposeBag) - - viewModel.isAllValid + viewModel.$isAllValid .receive(on: DispatchQueue.main) .sink { [weak self] isAllValid in guard let self = self else { return } - self.signUpButton.isEnabled = isAllValid + self.navigationActionView.nextButton.isEnabled = isAllValid } .store(in: &disposeBag) + + viewModel.setupDiffableDataSource(tableView: tableView) + +// KeyboardResponderService +// .configure( +// scrollView: tableView, +// layoutNeedsUpdate: viewModel.viewDidAppear.eraseToAnyPublisher() +// ) +// .store(in: &disposeBag) - viewModel.error - .receive(on: DispatchQueue.main) - .sink { [weak self] error in - guard let self = self else { return } - guard let error = error as? Mastodon.API.Error else { return } - let alertController = UIAlertController(for: error, title: "Sign Up Failure", preferredStyle: .alert) - let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil) - alertController.addAction(okAction) - self.coordinator.present( - scene: .alertController(alertController: alertController), - from: nil, - transition: .alertController(animated: true, completion: nil) - ) - } - .store(in: &disposeBag) + // gesture + view.addGestureRecognizer(tapGestureRecognizer) + tapGestureRecognizer.addTarget(self, action: #selector(tapGestureRecognizerHandler)) - viewModel.avatarImage +// // return +// if viewModel.approvalRequired { +// reasonTextField.returnKeyType = .done +// } else { +// passwordTextField.returnKeyType = .done +// } +// +// viewModel.usernameValidateState +// .receive(on: DispatchQueue.main) +// .sink { [weak self] validateState in +// guard let self = self else { return } +// self.setTextFieldValidAppearance(self.usernameTextField, validateState: validateState) +// } +// .store(in: &disposeBag) +// viewModel.usernameErrorPrompt +// .receive(on: DispatchQueue.main) +// .sink { [weak self] prompt in +// guard let self = self else { return } +// self.usernameErrorPromptLabel.attributedText = prompt +// } +// .store(in: &disposeBag) +// viewModel.displayNameValidateState +// .receive(on: DispatchQueue.main) +// .sink { [weak self] validateState in +// guard let self = self else { return } +// self.setTextFieldValidAppearance(self.displayNameTextField, validateState: validateState) +// } +// .store(in: &disposeBag) +// viewModel.emailValidateState +// .receive(on: DispatchQueue.main) +// .sink { [weak self] validateState in +// guard let self = self else { return } +// self.setTextFieldValidAppearance(self.emailTextField, validateState: validateState) +// } +// .store(in: &disposeBag) +// viewModel.emailErrorPrompt +// .receive(on: DispatchQueue.main) +// .sink { [weak self] prompt in +// guard let self = self else { return } +// self.emailErrorPromptLabel.attributedText = prompt +// } +// .store(in: &disposeBag) +// viewModel.passwordValidateState +// .receive(on: DispatchQueue.main) +// .sink { [weak self] validateState in +// guard let self = self else { return } +// self.setTextFieldValidAppearance(self.passwordTextField, validateState: validateState) +// self.passwordCheckLabel.attributedText = MastodonRegisterViewModel.attributeStringForPassword(validateState: validateState) +// } +// .store(in: &disposeBag) +// viewModel.passwordErrorPrompt +// .receive(on: DispatchQueue.main) +// .sink { [weak self] prompt in +// guard let self = self else { return } +// self.passwordErrorPromptLabel.attributedText = prompt +// } +// .store(in: &disposeBag) +// viewModel.reasonErrorPrompt +// .receive(on: DispatchQueue.main) +// .sink { [weak self] prompt in +// guard let self = self else { return } +// self.reasonErrorPromptLabel.attributedText = prompt +// } +// .store(in: &disposeBag) +// viewModel.error +// .receive(on: DispatchQueue.main) +// .sink { [weak self] error in +// guard let self = self else { return } +// guard let error = error as? Mastodon.API.Error else { return } +// let alertController = UIAlertController(for: error, title: "Sign Up Failure", preferredStyle: .alert) +// let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil) +// alertController.addAction(okAction) +// self.coordinator.present( +// scene: .alertController(alertController: alertController), +// from: nil, +// transition: .alertController(animated: true, completion: nil) +// ) +// } +// .store(in: &disposeBag) +// + + viewModel.avatarMediaMenuActionPublisher .receive(on: DispatchQueue.main) - .sink{ [weak self] image in + .sink { [weak self] action in guard let self = self else { return } - self.avatarButton.menu = self.createMediaContextMenu() - if let avatar = image { - self.avatarButton.setImage(avatar, for: .normal) - } else { - let boldFont = UIFont.systemFont(ofSize: 42) - let configuration = UIImage.SymbolConfiguration(font: boldFont) - let image = UIImage(systemName: "person.fill.viewfinder", withConfiguration: configuration) - self.avatarButton.setImage(image?.withRenderingMode(UIImage.RenderingMode.alwaysTemplate), for: UIControl.State.normal) + switch action { + case .photoLibrary: + self.present(self.imagePicker, animated: true, completion: nil) + case .camera: + self.present(self.imagePickerController, animated: true, completion: nil) + case .browse: + self.present(self.documentPickerController, animated: true, completion: nil) + case .delete: + self.viewModel.avatarImage = nil } } .store(in: &disposeBag) - NotificationCenter.default - .publisher(for: UITextField.textDidChangeNotification, object: usernameTextField) + + viewModel.$isRegistering .receive(on: DispatchQueue.main) - .sink { [weak self] _ in + .sink { [weak self] isRegistering in guard let self = self else { return } - self.viewModel.username.value = self.usernameTextField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + isRegistering ? self.navigationActionView.nextButton.showLoading() : self.navigationActionView.nextButton.stopLoading() } .store(in: &disposeBag) - - NotificationCenter.default - .publisher(for: UITextField.textDidChangeNotification, object: displayNameTextField) - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - guard let self = self else { return } - self.viewModel.displayName.value = self.displayNameTextField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - } - .store(in: &disposeBag) - - NotificationCenter.default - .publisher(for: UITextField.textDidChangeNotification, object: emailTextField) - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - guard let self = self else { return } - self.viewModel.email.value = self.emailTextField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - } - .store(in: &disposeBag) - - NotificationCenter.default - .publisher(for: UITextField.textDidChangeNotification, object: passwordTextField) - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - guard let self = self else { return } - self.viewModel.password.value = self.passwordTextField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - } - .store(in: &disposeBag) - - if viewModel.approvalRequired { - reasonTextField.delegate = self - NSLayoutConstraint.activate([ - reasonTextField.heightAnchor.constraint(equalToConstant: 50).priority(.defaultHigh), - ]) - reasonErrorPromptLabel.translatesAutoresizingMaskIntoConstraints = false - stackView.addSubview(reasonErrorPromptLabel) - NSLayoutConstraint.activate([ - reasonErrorPromptLabel.topAnchor.constraint(equalTo: reasonTextField.bottomAnchor, constant: 6), - reasonErrorPromptLabel.leadingAnchor.constraint(equalTo: reasonTextField.leadingAnchor), - reasonErrorPromptLabel.trailingAnchor.constraint(equalTo: reasonTextField.trailingAnchor), - ]) - - viewModel.reasonValidateState - .receive(on: DispatchQueue.main) - .sink { [weak self] validateState in - guard let self = self else { return } - self.setTextFieldValidAppearance(self.reasonTextField, validateState: validateState) - } - .store(in: &disposeBag) - NotificationCenter.default - .publisher(for: UITextField.textDidChangeNotification, object: reasonTextField) - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - guard let self = self else { return } - self.viewModel.reason.value = self.reasonTextField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - } - .store(in: &disposeBag) - } - - signUpButton.addTarget(self, action: #selector(MastodonRegisterViewController.signUpButtonPressed(_:)), for: .touchUpInside) } - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - plusIconImageView.layer.cornerRadius = plusIconImageView.frame.width / 2 - plusIconImageView.layer.masksToBounds = true - } - - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) - configureTitleLabel() - configureFormLayout() - } -} - -extension MastodonRegisterViewController: UITextFieldDelegate { - func textFieldDidBeginEditing(_ textField: UITextField) { - let text = textField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - - switch textField { - case usernameTextField: - viewModel.username.value = text - case displayNameTextField: - viewModel.displayName.value = text - case emailTextField: - viewModel.email.value = text - case passwordTextField: - viewModel.password.value = text - case reasonTextField: - viewModel.reason.value = text - default: - break - } + viewModel.viewDidAppear.send() } - func textFieldDidEndEditing(_ textField: UITextField) { - let text = textField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - - switch textField { - case usernameTextField: - viewModel.username.value = text - case displayNameTextField: - viewModel.displayName.value = text - case emailTextField: - viewModel.email.value = text - case passwordTextField: - viewModel.password.value = text - case reasonTextField: - viewModel.reason.value = text - default: - break - } - } - - func textFieldShouldReturn(_ textField: UITextField) -> Bool { - switch textField { - case usernameTextField: - displayNameTextField.becomeFirstResponder() - case displayNameTextField: - emailTextField.becomeFirstResponder() - case emailTextField: - passwordTextField.becomeFirstResponder() - case passwordTextField: - if viewModel.approvalRequired { - reasonTextField.becomeFirstResponder() - } else { - passwordTextField.resignFirstResponder() - } - case reasonTextField: - reasonTextField.resignFirstResponder() - default: - break - } - return true - } - - func showShadowWithColor(color: UIColor, textField: UITextField) { - // To apply Shadow - textField.layer.shadowOpacity = 1 - 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 - } - - private func setTextFieldValidAppearance(_ textField: UITextField, validateState: MastodonRegisterViewModel.ValidateState) { - switch validateState { - case .empty: - showShadowWithColor(color: textField.isFirstResponder ? Asset.Colors.brandBlue.color : .clear, textField: textField) - case .valid: - showShadowWithColor(color: Asset.Colors.TextField.valid.color, textField: textField) - case .invalid: - showShadowWithColor(color: Asset.Colors.TextField.invalid.color, textField: textField) - } - } } 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) } - @objc private func signUpButtonPressed(_ sender: UIButton) { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function) - guard viewModel.isAllValid.value else { return } - - guard !viewModel.isRegistering.value else { return } - viewModel.isRegistering.value = true + @objc private func backButtonPressed(_ sender: UIButton) { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") + navigationController?.popViewController(animated: true) + } - let username = viewModel.username.value - let email = viewModel.email.value - let password = viewModel.password.value + @objc private func nextButtonPressed(_ sender: UIButton) { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") + + guard viewModel.isAllValid else { return } + + guard !viewModel.isRegistering else { return } + viewModel.isRegistering = true + + let username = viewModel.username + let email = viewModel.email + let password = viewModel.password + let reason = viewModel.reason let locale: String = { guard let url = Bundle.main.url(forResource: "local-codes", withExtension: "json"), @@ -814,7 +295,7 @@ extension MastodonRegisterViewController { guard localCode[code] != nil else { return "en" } return code }() - + // pick device preferred language guard let identifier = Locale.preferredLanguages.first else { return fallbackLanguageCode @@ -843,19 +324,19 @@ extension MastodonRegisterViewController { return languageCode } return firstMatchExtendCode - + }() let query = Mastodon.API.Account.RegisterQuery( - reason: viewModel.reason.value, + reason: reason, username: username, email: email, password: password, agreement: true, // user confirmed in the server rules scene locale: locale ) - + var retryCount = 0 - + // register without show server rules context.apiService.accountRegister( domain: viewModel.domain, @@ -864,7 +345,7 @@ extension MastodonRegisterViewController { ) .tryCatch { [weak self] error -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Token>, Error> in guard let self = self else { throw error } - guard let error = self.viewModel.error.value as? Mastodon.API.Error, + guard let error = self.viewModel.error as? Mastodon.API.Error, case let .generic(errorEntity) = error.mastodonError, errorEntity.error == "Validation failed: Locale is not included in the list" else { @@ -891,10 +372,10 @@ extension MastodonRegisterViewController { .receive(on: DispatchQueue.main) .sink { [weak self] completion in guard let self = self else { return } - self.viewModel.isRegistering.value = false + self.viewModel.isRegistering = false switch completion { case .failure(let error): - self.viewModel.error.send(error) + self.viewModel.error = error case .finished: break } @@ -902,9 +383,9 @@ extension MastodonRegisterViewController { guard let self = self else { return } let userToken = response.value let updateCredentialQuery: Mastodon.API.Account.UpdateCredentialQuery = { - let displayName: String? = self.viewModel.displayName.value.isEmpty ? nil : self.viewModel.displayName.value + let displayName: String? = self.viewModel.name.isEmpty ? nil : self.viewModel.name let avatar: Mastodon.Query.MediaAttachment? = { - guard let avatarImage = self.viewModel.avatarImage.value else { return nil } + guard let avatarImage = self.viewModel.avatarImage else { return nil } guard avatarImage.size.width <= MastodonRegisterViewController.avatarImageMaxSizeInPixel.width else { return .png(avatarImage.af.imageScaled(to: MastodonRegisterViewController.avatarImageMaxSizeInPixel).pngData()) } @@ -920,4 +401,67 @@ extension MastodonRegisterViewController { } .store(in: &disposeBag) } + +} + +extension MastodonRegisterViewController: UITextFieldDelegate { +// func textFieldDidBeginEditing(_ textField: UITextField) { +// let text = textField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" +// +// switch textField { +// case usernameTextField: +// viewModel.username.value = text +// case displayNameTextField: +// viewModel.displayName.value = text +// case emailTextField: +// viewModel.email.value = text +// case passwordTextField: +// viewModel.password.value = text +// case reasonTextField: +// viewModel.reason.value = text +// default: +// break +// } +// } +// +// func textFieldDidEndEditing(_ textField: UITextField) { +// let text = textField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" +// +// switch textField { +// case usernameTextField: +// viewModel.username.value = text +// case displayNameTextField: +// viewModel.displayName.value = text +// case emailTextField: +// viewModel.email.value = text +// case passwordTextField: +// viewModel.password.value = text +// case reasonTextField: +// viewModel.reason.value = text +// default: +// break +// } +// } +// +// func textFieldShouldReturn(_ textField: UITextField) -> Bool { +// switch textField { +// case usernameTextField: +// displayNameTextField.becomeFirstResponder() +// case displayNameTextField: +// emailTextField.becomeFirstResponder() +// case emailTextField: +// passwordTextField.becomeFirstResponder() +// case passwordTextField: +// if viewModel.approvalRequired { +// reasonTextField.becomeFirstResponder() +// } else { +// passwordTextField.resignFirstResponder() +// } +// case reasonTextField: +// reasonTextField.resignFirstResponder() +// default: +// break +// } +// return true +// } } diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel+Diffable.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel+Diffable.swift new file mode 100644 index 000000000..beb16890b --- /dev/null +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel+Diffable.swift @@ -0,0 +1,237 @@ +// +// MastodonRegisterViewModel+Diffable.swift +// Mastodon +// +// Created by MainasuK on 2022-1-5. +// + +import UIKit +import Combine +import MastodonAsset +import MastodonLocalization + +extension MastodonRegisterViewModel { + func setupDiffableDataSource( + tableView: UITableView + ) { + tableView.register(OnboardingHeadlineTableViewCell.self, forCellReuseIdentifier: String(describing: OnboardingHeadlineTableViewCell.self)) + tableView.register(MastodonRegisterAvatarTableViewCell.self, forCellReuseIdentifier: String(describing: MastodonRegisterAvatarTableViewCell.self)) + tableView.register(MastodonRegisterTextFieldTableViewCell.self, forCellReuseIdentifier: String(describing: MastodonRegisterTextFieldTableViewCell.self)) + tableView.register(MastodonRegisterPasswordHintTableViewCell.self, forCellReuseIdentifier: String(describing: MastodonRegisterPasswordHintTableViewCell.self)) + + diffableDataSource = UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item in + switch item { + case .header(let domain): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: OnboardingHeadlineTableViewCell.self), for: indexPath) as! OnboardingHeadlineTableViewCell + cell.titleLabel.text = L10n.Scene.Register.title(domain) + cell.subTitleLabel.isHidden = true + return cell + case .avatar: + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: MastodonRegisterAvatarTableViewCell.self), for: indexPath) as! MastodonRegisterAvatarTableViewCell + self.configureAvatar(cell: cell) + return cell + case .name: + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: MastodonRegisterTextFieldTableViewCell.self), for: indexPath) as! MastodonRegisterTextFieldTableViewCell + cell.setupTextViewPlaceholder(text: L10n.Scene.Register.Input.DisplayName.placeholder) + cell.textField.keyboardType = .default + cell.textField.autocapitalizationType = .words + cell.textField.text = self.name + NotificationCenter.default.publisher(for: UITextField.textDidChangeNotification, object: cell.textField) + .receive(on: DispatchQueue.main) + .compactMap { notification in + guard let textField = notification.object as? UITextField else { + assertionFailure() + return nil + } + return textField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + } + .assign(to: \.name, on: self) + .store(in: &cell.disposeBag) + return cell + case .username: + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: MastodonRegisterTextFieldTableViewCell.self), for: indexPath) as! MastodonRegisterTextFieldTableViewCell + cell.setupTextViewRightView(text: "@" + self.domain) + cell.setupTextViewPlaceholder(text: L10n.Scene.Register.Input.Username.placeholder) + cell.textField.keyboardType = .alphabet + cell.textField.autocorrectionType = .no + cell.textField.text = self.username + cell.textField.textAlignment = .left + cell.textField.semanticContentAttribute = .forceLeftToRight + NotificationCenter.default.publisher(for: UITextField.textDidChangeNotification, object: cell.textField) + .receive(on: DispatchQueue.main) + .compactMap { notification in + guard let textField = notification.object as? UITextField else { + assertionFailure() + return nil + } + return textField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + } + .assign(to: \.username, on: self) + .store(in: &cell.disposeBag) + self.configureTextFieldCell(cell: cell, validateState: self.$usernameValidateState) + return cell + case .email: + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: MastodonRegisterTextFieldTableViewCell.self), for: indexPath) as! MastodonRegisterTextFieldTableViewCell + cell.setupTextViewPlaceholder(text: L10n.Scene.Register.Input.Email.placeholder) + cell.textField.keyboardType = .emailAddress + cell.textField.autocorrectionType = .no + cell.textField.text = self.email + NotificationCenter.default.publisher(for: UITextField.textDidChangeNotification, object: cell.textField) + .receive(on: DispatchQueue.main) + .compactMap { notification in + guard let textField = notification.object as? UITextField else { + assertionFailure() + return nil + } + return textField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + } + .assign(to: \.email, on: self) + .store(in: &cell.disposeBag) + self.configureTextFieldCell(cell: cell, validateState: self.$emailValidateState) + return cell + case .password: + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: MastodonRegisterTextFieldTableViewCell.self), for: indexPath) as! MastodonRegisterTextFieldTableViewCell + cell.setupTextViewPlaceholder(text: L10n.Scene.Register.Input.Password.placeholder) + cell.textField.keyboardType = .alphabet + cell.textField.autocorrectionType = .no + cell.textField.isSecureTextEntry = true + cell.textField.text = self.password + cell.textField.textAlignment = .left + cell.textField.semanticContentAttribute = .forceLeftToRight + NotificationCenter.default.publisher(for: UITextField.textDidChangeNotification, object: cell.textField) + .receive(on: DispatchQueue.main) + .compactMap { notification in + guard let textField = notification.object as? UITextField else { + assertionFailure() + return nil + } + return textField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + } + .assign(to: \.password, on: self) + .store(in: &cell.disposeBag) + self.configureTextFieldCell(cell: cell, validateState: self.$passwordValidateState) + return cell + case .hint: + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: MastodonRegisterPasswordHintTableViewCell.self), for: indexPath) as! MastodonRegisterPasswordHintTableViewCell + return cell + case .reason: + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: MastodonRegisterTextFieldTableViewCell.self), for: indexPath) as! MastodonRegisterTextFieldTableViewCell + cell.setupTextViewPlaceholder(text: L10n.Scene.Register.Input.Invite.registrationUserInviteRequest) + cell.textField.keyboardType = .default + cell.textField.text = self.reason + NotificationCenter.default.publisher(for: UITextField.textDidChangeNotification, object: cell.textField) + .receive(on: DispatchQueue.main) + .compactMap { notification in + guard let textField = notification.object as? UITextField else { + assertionFailure() + return nil + } + return textField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + } + .assign(to: \.reason, on: self) + .store(in: &cell.disposeBag) + self.configureTextFieldCell(cell: cell, validateState: self.$reasonValidateState) + return cell + default: + assertionFailure() + return UITableViewCell() + } + } + + var snapshot = NSDiffableDataSourceSnapshot<RegisterSection, RegisterItem>() + snapshot.appendSections([.main]) + snapshot.appendItems([.header(domain: domain)], toSection: .main) + snapshot.appendItems([.avatar, .name, .username, .email, .password, .hint], toSection: .main) + if approvalRequired { + snapshot.appendItems([.reason], toSection: .main) + } + diffableDataSource?.applySnapshot(snapshot, animated: false, completion: nil) + } +} + +extension MastodonRegisterViewModel { + private func configureAvatar(cell: MastodonRegisterAvatarTableViewCell) { + self.$avatarImage + .receive(on: DispatchQueue.main) + .sink { [weak self, weak cell] image in + guard let self = self else { return } + guard let cell = cell else { return } + let image = image ?? Asset.Scene.Onboarding.avatarPlaceholder.image + cell.avatarButton.setImage(image, for: .normal) + cell.avatarButton.menu = self.createAvatarMediaContextMenu() + cell.avatarButton.showsMenuAsPrimaryAction = true + } + .store(in: &cell.disposeBag) + } + + enum AvatarMediaMenuAction { + case photoLibrary + case camera + case browse + case delete + } + + private func createAvatarMediaContextMenu() -> UIMenu { + var children: [UIMenuElement] = [] + + // Photo Library + let photoLibraryAction = UIAction(title: L10n.Scene.Compose.MediaSelection.photoLibrary, image: UIImage(systemName: "rectangle.on.rectangle"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in + guard let self = self else { return } + self.avatarMediaMenuActionPublisher.send(.photoLibrary) + } + children.append(photoLibraryAction) + + // Camera + if UIImagePickerController.isSourceTypeAvailable(.camera) { + let cameraAction = UIAction(title: L10n.Scene.Compose.MediaSelection.camera, image: UIImage(systemName: "camera"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { [weak self] _ in + guard let self = self else { return } + self.avatarMediaMenuActionPublisher.send(.camera) + }) + children.append(cameraAction) + } + + // Browse + let browseAction = UIAction(title: L10n.Scene.Compose.MediaSelection.browse, image: UIImage(systemName: "ellipsis"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in + guard let self = self else { return } + self.avatarMediaMenuActionPublisher.send(.browse) + } + children.append(browseAction) + + // Delete + if avatarImage != nil { + let deleteAction = UIAction(title: L10n.Scene.Register.Input.Avatar.delete, image: UIImage(systemName: "delete.left"), identifier: nil, discoverabilityTitle: nil, attributes: [.destructive], state: .off) { [weak self] _ in + guard let self = self else { return } + self.avatarMediaMenuActionPublisher.send(.delete) + } + children.append(deleteAction) + } + + return UIMenu(title: "", image: nil, identifier: nil, options: .displayInline, children: children) + } + + private func configureTextFieldCell( + cell: MastodonRegisterTextFieldTableViewCell, + validateState: Published<ValidateState>.Publisher + ) { + Publishers.CombineLatest( + validateState, + cell.textField.publisher(for: \.isFirstResponder) + ) + .receive(on: DispatchQueue.main) + .sink { [weak cell] validateState, isFirstResponder in + guard let cell = cell else { return } + switch validateState { + case .empty: + cell.textFieldShadowContainer.shadowColor = isFirstResponder ? Asset.Colors.brandBlue.color : .black + cell.textFieldShadowContainer.shadowAlpha = isFirstResponder ? 1 : 0.25 + case .valid: + cell.textFieldShadowContainer.shadowColor = Asset.Colors.TextField.valid.color + cell.textFieldShadowContainer.shadowAlpha = 1 + case .invalid: + cell.textFieldShadowContainer.shadowColor = Asset.Colors.TextField.invalid.color + cell.textFieldShadowContainer.shadowAlpha = 1 + } + } + .store(in: &cell.disposeBag) + } +} diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift index 6c9e07542..1ef9cf47a 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift @@ -9,23 +9,26 @@ import Combine import Foundation import MastodonSDK import UIKit +import MastodonAsset +import MastodonLocalization final class MastodonRegisterViewModel { var disposeBag = Set<AnyCancellable>() // input + let context: AppContext let domain: String let authenticateInfo: AuthenticationViewModel.AuthenticateInfo let instance: Mastodon.Entity.Instance let applicationToken: Mastodon.Entity.Token - let context: AppContext - - let username = CurrentValueSubject<String, Never>("") - let displayName = CurrentValueSubject<String, Never>("") - let email = CurrentValueSubject<String, Never>("") - let password = CurrentValueSubject<String, Never>("") - let reason = CurrentValueSubject<String, Never>("") - let avatarImage = CurrentValueSubject<UIImage?, Never>(nil) + let viewDidAppear = CurrentValueSubject<Void, Never>(Void()) + + @Published var avatarImage: UIImage? = nil + @Published var name = "" + @Published var username = "" + @Published var email = "" + @Published var password = "" + @Published var reason = "" let usernameErrorPrompt = CurrentValueSubject<NSAttributedString?, Never>(nil) let emailErrorPrompt = CurrentValueSubject<NSAttributedString?, Never>(nil) @@ -33,21 +36,25 @@ final class MastodonRegisterViewModel { let reasonErrorPrompt = CurrentValueSubject<NSAttributedString?, Never>(nil) // output + var diffableDataSource: UITableViewDiffableDataSource<RegisterSection, RegisterItem>? let approvalRequired: Bool let applicationAuthorization: Mastodon.API.OAuth.Authorization - let usernameValidateState = CurrentValueSubject<ValidateState, Never>(.empty) - let displayNameValidateState = CurrentValueSubject<ValidateState, Never>(.empty) - let emailValidateState = CurrentValueSubject<ValidateState, Never>(.empty) - let passwordValidateState = CurrentValueSubject<ValidateState, Never>(.empty) - let reasonValidateState = CurrentValueSubject<ValidateState, Never>(.empty) + + @Published var usernameValidateState: ValidateState = .empty + @Published var displayNameValidateState: ValidateState = .empty + @Published var emailValidateState: ValidateState = .empty + @Published var passwordValidateState: ValidateState = .empty + @Published var reasonValidateState: ValidateState = .empty - let isRegistering = CurrentValueSubject<Bool, Never>(false) - let isAllValid = CurrentValueSubject<Bool, Never>(false) - let error = CurrentValueSubject<Error?, Never>(nil) + @Published var isRegistering = false + @Published var isAllValid = false + @Published var error: Error? = nil + + let avatarMediaMenuActionPublisher = PassthroughSubject<AvatarMediaMenuAction, Never>() init( - domain: String, context: AppContext, + domain: String, authenticateInfo: AuthenticationViewModel.AuthenticateInfo, instance: Mastodon.Entity.Instance, applicationToken: Mastodon.Entity.Token @@ -60,7 +67,15 @@ final class MastodonRegisterViewModel { self.approvalRequired = instance.approvalRequired ?? false self.applicationAuthorization = Mastodon.API.OAuth.Authorization(accessToken: applicationToken.accessToken) - username + $name + .map { name in + guard !name.isEmpty else { return .empty } + return .valid + } + .assign(to: \.displayNameValidateState, on: self) + .store(in: &disposeBag) + + $username .map { username in guard !username.isEmpty else { return .empty } var isValid = true @@ -79,114 +94,120 @@ final class MastodonRegisterViewModel { } return isValid ? .valid : .invalid } - .assign(to: \.value, on: usernameValidateState) + .assign(to: \.usernameValidateState, on: self) .store(in: &disposeBag) - username - .filter { !$0.isEmpty } - .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) - .removeDuplicates() - .compactMap { [weak self] text -> AnyPublisher<Result<Mastodon.Response.Content<Mastodon.Entity.Account>, Error>, Never>? in - guard let self = self else { return nil } - let query = Mastodon.API.Account.AccountLookupQuery(acct: text) - return context.apiService.accountLookup(domain: domain, query: query, authorization: self.applicationAuthorization) - .map { - response -> Result<Mastodon.Response.Content<Mastodon.Entity.Account>, Error> in - Result.success(response) - } - .catch { error in - Just(Result.failure(error)) - } - .eraseToAnyPublisher() - } - .switchToLatest() - .sink { [weak self] result in - guard let self = self else { return } - switch result { - case .success: - let text = L10n.Scene.Register.Error.Reason.taken(L10n.Scene.Register.Error.Item.username) - self.usernameErrorPrompt.value = MastodonRegisterViewModel.errorPromptAttributedString(for: text) - self.usernameValidateState.value = .invalid - case .failure: - break - } - } - .store(in: &disposeBag) - - usernameValidateState - .sink { [weak self] validateState in - if validateState == .valid { - self?.usernameErrorPrompt.value = nil - } - } - .store(in: &disposeBag) + // TODO: check username available +// username +// .filter { !$0.isEmpty } +// .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) +// .removeDuplicates() +// .compactMap { [weak self] text -> AnyPublisher<Result<Mastodon.Response.Content<Mastodon.Entity.Account>, Error>, Never>? in +// guard let self = self else { return nil } +// let query = Mastodon.API.Account.AccountLookupQuery(acct: text) +// return context.apiService.accountLookup(domain: domain, query: query, authorization: self.applicationAuthorization) +// .map { +// response -> Result<Mastodon.Response.Content<Mastodon.Entity.Account>, Error> in +// Result.success(response) +// } +// .catch { error in +// Just(Result.failure(error)) +// } +// .eraseToAnyPublisher() +// } +// .switchToLatest() +// .sink { [weak self] result in +// guard let self = self else { return } +// switch result { +// case .success: +// let text = L10n.Scene.Register.Error.Reason.taken(L10n.Scene.Register.Error.Item.username) +// self.usernameErrorPrompt.value = MastodonRegisterViewModel.errorPromptAttributedString(for: text) +// self.usernameValidateState.value = .invalid +// case .failure: +// break +// } +// } +// .store(in: &disposeBag) +// +// usernameValidateState +// .sink { [weak self] validateState in +// if validateState == .valid { +// self?.usernameErrorPrompt.value = nil +// } +// } +// .store(in: &disposeBag) - displayName - .map { displayname in - guard !displayname.isEmpty else { return .empty } - return .valid - } - .assign(to: \.value, on: displayNameValidateState) - .store(in: &disposeBag) - email + $email .map { email in guard !email.isEmpty else { return .empty } return MastodonRegisterViewModel.isValidEmail(email) ? .valid : .invalid } - .assign(to: \.value, on: emailValidateState) + .assign(to: \.emailValidateState, on: self) .store(in: &disposeBag) - password + + $password .map { password in guard !password.isEmpty else { return .empty } return password.count >= 8 ? .valid : .invalid } - .assign(to: \.value, on: passwordValidateState) + .assign(to: \.passwordValidateState, on: self) .store(in: &disposeBag) + if approvalRequired { - reason + $reason .map { invite in guard !invite.isEmpty else { return .empty } return .valid } - .assign(to: \.value, on: reasonValidateState) + .assign(to: \.reasonValidateState, on: self) .store(in: &disposeBag) } - error - .sink { [weak self] error in - guard let self = self else { return } - let error = error as? Mastodon.API.Error - let mastodonError = error?.mastodonError - if case let .generic(genericMastodonError) = mastodonError, - let details = genericMastodonError.details - { - self.usernameErrorPrompt.value = details.usernameErrorDescriptions.first.flatMap { MastodonRegisterViewModel.errorPromptAttributedString(for: $0) } - self.emailErrorPrompt.value = details.emailErrorDescriptions.first.flatMap { MastodonRegisterViewModel.errorPromptAttributedString(for: $0) } - self.passwordErrorPrompt.value = details.passwordErrorDescriptions.first.flatMap { MastodonRegisterViewModel.errorPromptAttributedString(for: $0) } - self.reasonErrorPrompt.value = details.reasonErrorDescriptions.first.flatMap { MastodonRegisterViewModel.errorPromptAttributedString(for: $0) } - } else { - self.usernameErrorPrompt.value = nil - self.emailErrorPrompt.value = nil - self.passwordErrorPrompt.value = nil - self.reasonErrorPrompt.value = nil - } - } - .store(in: &disposeBag) - +// error +// .sink { [weak self] error in +// guard let self = self else { return } +// let error = error as? Mastodon.API.Error +// let mastodonError = error?.mastodonError +// if case let .generic(genericMastodonError) = mastodonError, +// let details = genericMastodonError.details +// { +// self.usernameErrorPrompt.value = details.usernameErrorDescriptions.first.flatMap { MastodonRegisterViewModel.errorPromptAttributedString(for: $0) } +// self.emailErrorPrompt.value = details.emailErrorDescriptions.first.flatMap { MastodonRegisterViewModel.errorPromptAttributedString(for: $0) } +// self.passwordErrorPrompt.value = details.passwordErrorDescriptions.first.flatMap { MastodonRegisterViewModel.errorPromptAttributedString(for: $0) } +// self.reasonErrorPrompt.value = details.reasonErrorDescriptions.first.flatMap { MastodonRegisterViewModel.errorPromptAttributedString(for: $0) } +// } else { +// self.usernameErrorPrompt.value = nil +// self.emailErrorPrompt.value = nil +// self.passwordErrorPrompt.value = nil +// self.reasonErrorPrompt.value = nil +// } +// } +// .store(in: &disposeBag) +// let publisherOne = Publishers.CombineLatest4( - usernameValidateState.eraseToAnyPublisher(), - displayNameValidateState.eraseToAnyPublisher(), - emailValidateState.eraseToAnyPublisher(), - passwordValidateState.eraseToAnyPublisher() + $usernameValidateState, + $displayNameValidateState, + $emailValidateState, + $passwordValidateState ) - .map { $0.0 == .valid && $0.1 == .valid && $0.2 == .valid && $0.3 == .valid } + .map { + $0.0 == .valid && + $0.1 == .valid && + $0.2 == .valid && + $0.3 == .valid + } + + let publisherTwo = $reasonValidateState.map { reasonValidateState -> Bool in + guard self.approvalRequired else { return true } + return reasonValidateState == .valid + } Publishers.CombineLatest( publisherOne, - approvalRequired ? reasonValidateState.map { $0 == .valid }.eraseToAnyPublisher() : Just(true).eraseToAnyPublisher() + publisherTwo ) .map { $0 && $1 } - .assign(to: \.value, on: isAllValid) + .assign(to: \.isAllValid, on: self) .store(in: &disposeBag) } } diff --git a/Mastodon/Scene/Onboarding/ServerRules/Cell/ServerRulesTableViewCell.swift b/Mastodon/Scene/Onboarding/ServerRules/Cell/ServerRulesTableViewCell.swift new file mode 100644 index 000000000..a6fc25a40 --- /dev/null +++ b/Mastodon/Scene/Onboarding/ServerRules/Cell/ServerRulesTableViewCell.swift @@ -0,0 +1,85 @@ +// +// ServerRulesTableViewCell.swift +// Mastodon +// +// Created by MainasuK on 2022-1-5. +// + +import UIKit +import MastodonAsset +import MastodonLocalization + +final class ServerRulesTableViewCell: UITableViewCell { + + static let margin: CGFloat = 23 + + let indexImageView: UIImageView = { + let imageView = UIImageView() + imageView.tintColor = Asset.Colors.Label.primary.color + return imageView + }() + + let ruleLabel: UILabel = { + let label = UILabel() + label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold)) + label.textColor = Asset.Colors.Label.primary.color + label.numberOfLines = 0 + return label + }() + + let separalerLine: UIView = { + let view = UIView() + view.backgroundColor = Asset.Theme.System.separator.color + return view + }() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ServerRulesTableViewCell { + + private func _init() { + selectionStyle = .none + backgroundColor = .clear + + indexImageView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(indexImageView) + NSLayoutConstraint.activate([ + indexImageView.topAnchor.constraint(greaterThanOrEqualTo: contentView.topAnchor, constant: ServerRulesTableViewCell.margin), + indexImageView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), + contentView.bottomAnchor.constraint(greaterThanOrEqualTo: indexImageView.bottomAnchor, constant: ServerRulesTableViewCell.margin), + indexImageView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + indexImageView.widthAnchor.constraint(equalToConstant: 32).priority(.required - 1), + indexImageView.heightAnchor.constraint(equalToConstant: 32).priority(.required - 1), + ]) + + ruleLabel.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(ruleLabel) + NSLayoutConstraint.activate([ + ruleLabel.topAnchor.constraint(greaterThanOrEqualTo: contentView.topAnchor, constant: ServerRulesTableViewCell.margin), + ruleLabel.leadingAnchor.constraint(equalTo: indexImageView.trailingAnchor, constant: 16), + ruleLabel.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), + contentView.bottomAnchor.constraint(greaterThanOrEqualTo: ruleLabel.bottomAnchor, constant: ServerRulesTableViewCell.margin), + ruleLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + ]) + + separalerLine.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(separalerLine) + NSLayoutConstraint.activate([ + separalerLine.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), + separalerLine.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), + separalerLine.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + separalerLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView)).priority(.required - 1), + ]) + } + +} diff --git a/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift b/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift index e93d06e19..2f13ad193 100644 --- a/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift +++ b/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift @@ -11,11 +11,16 @@ import Combine import MastodonSDK import SafariServices import MetaTextKit +import MastodonAsset +import MastodonLocalization final class MastodonServerRulesViewController: UIViewController, NeedsDependency { - var disposeBag = Set<AnyCancellable>() + let logger = Logger(subsystem: "MastodonServerRulesViewController", category: "ViewController") + var disposeBag = Set<AnyCancellable>() + private var observations = Set<NSKeyValueObservation>() + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } @@ -23,67 +28,26 @@ final class MastodonServerRulesViewController: UIViewController, NeedsDependency let stackView = UIStackView() - let largeTitleLabel: UILabel = { - let label = UILabel() - label.font = UIFontMetrics(forTextStyle: .largeTitle).scaledFont(for: .systemFont(ofSize: 34, weight: .bold)) - label.textColor = .label - label.text = L10n.Scene.ServerRules.title - label.numberOfLines = 0 - return label + let tableView: UITableView = { + let tableView = UITableView() + tableView.register(OnboardingHeadlineTableViewCell.self, forCellReuseIdentifier: String(describing: OnboardingHeadlineTableViewCell.self)) + tableView.register(ServerRulesTableViewCell.self, forCellReuseIdentifier: String(describing: ServerRulesTableViewCell.self)) + tableView.rowHeight = UITableView.automaticDimension + tableView.separatorStyle = .none + tableView.backgroundColor = .clear + tableView.keyboardDismissMode = .onDrag + if #available(iOS 15.0, *) { + tableView.sectionHeaderTopPadding = 0 + } else { + // Fallback on earlier versions + } + return tableView }() - - private(set) lazy var subtitleLabel: UILabel = { - let label = UILabel() - label.font = UIFontMetrics(forTextStyle: .title1).scaledFont(for: UIFont.systemFont(ofSize: 20)) - label.textColor = .secondaryLabel - label.text = L10n.Scene.ServerRules.subtitle(viewModel.domain) - label.numberOfLines = 0 - return label - }() - - let rulesLabel: UILabel = { - let label = UILabel() - label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold)) - label.textColor = Asset.Colors.Label.primary.color - label.text = "Rules" - label.numberOfLines = 0 - return label - }() - - let bottomContainerView: UIView = { - let view = UIView() - view.backgroundColor = Asset.Theme.Mastodon.systemGroupedBackground.color - return view - }() - - private(set) lazy var bottomPromptMetaText: MetaText = { - let metaText = MetaText() - metaText.textAttributes = [ - .font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .regular), maximumPointSize: 22), - .foregroundColor: UIColor.label, - ] - metaText.linkAttributes = [ - .font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .regular), maximumPointSize: 22), - .foregroundColor: Asset.Colors.brandBlue.color, - ] - metaText.textView.isEditable = false - metaText.textView.isSelectable = false - metaText.textView.isScrollEnabled = false - metaText.textView.backgroundColor = Asset.Theme.Mastodon.systemGroupedBackground.color // needs background color to prevent server rules text overlap - return metaText - }() - - let confirmButton: PrimaryActionButton = { - let button = PrimaryActionButton() - button.setTitle(L10n.Scene.ServerRules.Button.confirm, for: .normal) - return button - }() - - let scrollView: UIScrollView = { - let scrollView = UIScrollView() - scrollView.alwaysBounceVertical = true - scrollView.showsVerticalScrollIndicator = false - return scrollView + + let navigationActionView: NavigationActionView = { + let navigationActionView = NavigationActionView() + navigationActionView.backgroundColor = Asset.Scene.Onboarding.background.color + return navigationActionView }() deinit { @@ -97,224 +61,96 @@ extension MastodonServerRulesViewController { override func viewDidLoad() { super.viewDidLoad() + navigationItem.leftBarButtonItem = UIBarButtonItem() + setupOnboardingAppearance() - configureTitleLabel() - configureMargin() - configTextView() - defer { setupNavigationBarBackgroundView() } - bottomContainerView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(bottomContainerView) + tableView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(tableView) NSLayoutConstraint.activate([ - view.bottomAnchor.constraint(equalTo: bottomContainerView.bottomAnchor), - bottomContainerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - bottomContainerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), ]) - bottomContainerView.preservesSuperviewLayoutMargins = true + + navigationActionView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(navigationActionView) defer { - view.bringSubviewToFront(bottomContainerView) + view.bringSubviewToFront(navigationActionView) } - - confirmButton.translatesAutoresizingMaskIntoConstraints = false - bottomContainerView.addSubview(confirmButton) NSLayoutConstraint.activate([ - bottomContainerView.layoutMarginsGuide.bottomAnchor.constraint(equalTo: confirmButton.bottomAnchor, constant: MastodonServerRulesViewController.viewBottomPaddingHeight), - confirmButton.leadingAnchor.constraint(equalTo: bottomContainerView.layoutMarginsGuide.leadingAnchor), - bottomContainerView.layoutMarginsGuide.trailingAnchor.constraint(equalTo: confirmButton.trailingAnchor), - confirmButton.heightAnchor.constraint(equalToConstant: MastodonServerRulesViewController.actionButtonHeight).priority(.defaultHigh), + navigationActionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + navigationActionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + view.bottomAnchor.constraint(equalTo: navigationActionView.bottomAnchor), ]) - bottomPromptMetaText.textView.translatesAutoresizingMaskIntoConstraints = false - bottomContainerView.addSubview(bottomPromptMetaText.textView) - NSLayoutConstraint.activate([ - bottomPromptMetaText.textView.frameLayoutGuide.topAnchor.constraint(equalTo: bottomContainerView.topAnchor, constant: 20), - 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), - ]) + navigationActionView + .observe(\.bounds, options: [.initial, .new]) { [weak self] navigationActionView, _ in + guard let self = self else { return } + let inset = navigationActionView.frame.height + self.tableView.contentInset.bottom = inset + } + .store(in: &observations) - scrollView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(scrollView) - NSLayoutConstraint.activate([ - scrollView.frameLayoutGuide.topAnchor.constraint(equalTo: view.topAnchor), - scrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor), - scrollView.frameLayoutGuide.trailingAnchor.constraint(equalTo: view.readableContentGuide.trailingAnchor), - scrollView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.bottomAnchor), - scrollView.frameLayoutGuide.widthAnchor.constraint(equalTo: scrollView.contentLayoutGuide.widthAnchor), - ]) - - 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) - stackView.addArrangedSubview(rulesLabel) + tableView.delegate = self + viewModel.setupDiffableDataSource(tableView: tableView) - stackView.translatesAutoresizingMaskIntoConstraints = false - scrollView.addSubview(stackView) - NSLayoutConstraint.activate([ - stackView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), - stackView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), - stackView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), - scrollView.contentLayoutGuide.bottomAnchor.constraint(equalTo: stackView.bottomAnchor), - ]) - - rulesLabel.attributedText = viewModel.rulesAttributedString - confirmButton.addTarget(self, action: #selector(MastodonServerRulesViewController.confirmButtonPressed(_:)), for: .touchUpInside) + navigationActionView.backButton.addTarget(self, action: #selector(MastodonServerRulesViewController.backButtonPressed(_:)), for: .touchUpInside) + navigationActionView.nextButton.addTarget(self, action: #selector(MastodonServerRulesViewController.nextButtonPressed(_:)), for: .touchUpInside) } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - scrollView.flashScrollIndicators() - } - - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - updateScrollViewContentInset() - } - - override func viewSafeAreaInsetsDidChange() { - super.viewSafeAreaInsetsDidChange() - updateScrollViewContentInset() - } - - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - - setupNavigationBarAppearance() - configureTitleLabel() - configureMargin() + tableView.flashScrollIndicators() } } 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 - } + + @objc private func backButtonPressed(_ sender: UIButton) { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") + navigationController?.popViewController(animated: true) } - 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 - } - } -} + @objc private func nextButtonPressed(_ sender: UIButton) { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") -extension MastodonServerRulesViewController { - func updateScrollViewContentInset() { - view.layoutIfNeeded() - scrollView.contentInset.bottom = bottomContainerView.frame.height - scrollView.verticalScrollIndicatorInsets.bottom = bottomContainerView.frame.height + let viewModel = MastodonRegisterViewModel( + context: context, + domain: viewModel.domain, + authenticateInfo: viewModel.authenticateInfo, + instance: viewModel.instance, + applicationToken: viewModel.applicationToken + ) + coordinator.present(scene: .mastodonRegister(viewModel: viewModel), from: self, transition: .show) } - func configTextView() { - let metaContent = ServerRulesPromptMetaContent(domain: viewModel.domain) - bottomPromptMetaText.configure(content: metaContent) - bottomPromptMetaText.textView.linkDelegate = self - } - - struct ServerRulesPromptMetaContent: MetaContent { - let string: String - let entities: [Meta.Entity] - - init(domain: String) { - let _string = L10n.Scene.ServerRules.prompt(domain) - self.string = _string - - var _entities: [Meta.Entity] = [] - - let termsOfServiceText = L10n.Scene.ServerRules.termsOfService - if let termsOfServiceRange = _string.range(of: termsOfServiceText) { - let url = Mastodon.API.serverRulesURL(domain: domain) - let entity = Meta.Entity(range: NSRange(termsOfServiceRange, in: _string), meta: .url(termsOfServiceText, trimmed: termsOfServiceText, url: url.absoluteString, userInfo: nil)) - _entities.append(entity) - } - - let privacyPolicyText = L10n.Scene.ServerRules.privacyPolicy - if let privacyPolicyRange = _string.range(of: privacyPolicyText) { - let url = Mastodon.API.privacyURL(domain: domain) - let entity = Meta.Entity(range: NSRange(privacyPolicyRange, in: _string), meta: .url(privacyPolicyText, trimmed: privacyPolicyText, url: url.absoluteString, userInfo: nil)) - _entities.append(entity) - } - - self.entities = _entities - } - - func metaAttachment(for entity: Meta.Entity) -> MetaAttachment? { - return nil - } - } - -} - -extension MastodonServerRulesViewController: UITextViewDelegate { - func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { - return false - } -} - -// MARK: - MetaTextViewDelegate -extension MastodonServerRulesViewController: MetaTextViewDelegate { - func metaTextView(_ metaTextView: MetaTextView, didSelectMeta meta: Meta) { - switch meta { - case .url(_, _, let url, _): - guard let url = URL(string: url) else { return } - coordinator.present(scene: .safari(url: url), from: nil, transition: .safariPresent(animated: true, completion: nil)) - default: - break - } - } -} - -extension MastodonServerRulesViewController { - @objc private func confirmButtonPressed(_ sender: UIButton) { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - - let viewModel = MastodonRegisterViewModel(domain: self.viewModel.domain, context: self.context, authenticateInfo: self.viewModel.authenticateInfo, instance: self.viewModel.instance, applicationToken: self.viewModel.applicationToken) - self.coordinator.present(scene: .mastodonRegister(viewModel: viewModel), from: self, transition: .show) - } } // MARK: - OnboardingViewControllerAppearance extension MastodonServerRulesViewController: OnboardingViewControllerAppearance { } -#if canImport(SwiftUI) && DEBUG -import SwiftUI - -struct ServerRulesViewController_Previews: PreviewProvider { - - static var previews: some View { - UIViewControllerPreview { - let viewController = MastodonServerRulesViewController() - return viewController - } - .previewLayout(.fixed(width: 375, height: 800)) +// MARK: - UITableViewDelegate +extension MastodonServerRulesViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + return UIView() } + func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + guard let diffableDataSource = viewModel.diffableDataSource, + section < diffableDataSource.snapshot().numberOfSections + else { return .leastNonzeroMagnitude } + + let sectionItem = diffableDataSource.snapshot().sectionIdentifiers[section] + switch sectionItem { + case .header: + return .leastNonzeroMagnitude + case .rules: + return 16 + } + } } - -#endif diff --git a/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewModel+Diffable.swift b/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewModel+Diffable.swift new file mode 100644 index 000000000..f6385a529 --- /dev/null +++ b/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewModel+Diffable.swift @@ -0,0 +1,26 @@ +// +// MastodonServerRulesViewModel+Diffable.swift +// Mastodon +// +// Created by MainasuK on 2022-1-5. +// + +import UIKit + +extension MastodonServerRulesViewModel { + func setupDiffableDataSource( + tableView: UITableView + ) { + diffableDataSource = ServerRuleSection.tableViewDiffableDataSource(tableView: tableView) + + var snapshot = NSDiffableDataSourceSnapshot<ServerRuleSection, ServerRuleItem>() + snapshot.appendSections([.header, .rules]) + snapshot.appendItems([.header(domain: domain)], toSection: .header) + let ruleItems: [ServerRuleItem] = rules.enumerated().map { i, rule in + let ruleContext = ServerRuleItem.RuleContext(index: i, rule: rule) + return ServerRuleItem.rule(ruleContext) + } + snapshot.appendItems(ruleItems, toSection: .rules) + diffableDataSource?.applySnapshot(snapshot, animated: false, completion: nil) + } +} diff --git a/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewModel.swift b/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewModel.swift index 5936a2c03..29869be09 100644 --- a/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewModel.swift +++ b/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewModel.swift @@ -8,6 +8,8 @@ import UIKit import Combine import MastodonSDK +import MastodonAsset +import MastodonLocalization final class MastodonServerRulesViewModel { @@ -18,6 +20,9 @@ final class MastodonServerRulesViewModel { let instance: Mastodon.Entity.Instance let applicationToken: Mastodon.Entity.Token + // output + var diffableDataSource: UITableViewDiffableDataSource<ServerRuleSection, ServerRuleItem>? + init( domain: String, authenticateInfo: AuthenticationViewModel.AuthenticateInfo, diff --git a/Mastodon/Scene/Onboarding/Share/NavigationActionView.swift b/Mastodon/Scene/Onboarding/Share/NavigationActionView.swift new file mode 100644 index 000000000..c3236bdb4 --- /dev/null +++ b/Mastodon/Scene/Onboarding/Share/NavigationActionView.swift @@ -0,0 +1,104 @@ +// +// NavigationActionView.swift +// Mastodon +// +// Created by MainasuK on 2021-12-31. +// + +import UIKit +import MastodonUI +import MastodonAsset +import MastodonLocalization + +final class NavigationActionView: UIView { + + static let buttonHeight: CGFloat = 50 + + private var observations = Set<NSKeyValueObservation>() + + let buttonContainer: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.spacing = 18 + return stackView + }() + + let backButtonShadowContainer = ShadowBackgroundContainer() + let backButton: PrimaryActionButton = { + let button = PrimaryActionButton() + button.action = .back + button.setTitle(L10n.Common.Controls.Actions.back, for: .normal) + return button + }() + + let nextButtonShadowContainer = ShadowBackgroundContainer() + let nextButton: PrimaryActionButton = { + let button = PrimaryActionButton() + button.action = .next + button.setTitle(L10n.Common.Controls.Actions.next, for: .normal) + return button + }() + + var hidesBackButton: Bool = false { + didSet { backButtonShadowContainer.isHidden = hidesBackButton } + } + + var hidesNextButton: Bool = false { + didSet { nextButtonShadowContainer.isHidden = hidesNextButton } + } + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension NavigationActionView { + + private func _init() { + buttonContainer.translatesAutoresizingMaskIntoConstraints = false + buttonContainer.preservesSuperviewLayoutMargins = true + addSubview(buttonContainer) + NSLayoutConstraint.activate([ + buttonContainer.topAnchor.constraint(equalTo: topAnchor, constant: 16), + buttonContainer.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor), + buttonContainer.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor), + safeAreaLayoutGuide.bottomAnchor.constraint(equalTo: buttonContainer.bottomAnchor, constant: 8), + ]) + + backButtonShadowContainer.translatesAutoresizingMaskIntoConstraints = false + buttonContainer.addArrangedSubview(backButtonShadowContainer) + nextButtonShadowContainer.translatesAutoresizingMaskIntoConstraints = false + buttonContainer.addArrangedSubview(nextButtonShadowContainer) + NSLayoutConstraint.activate([ + backButtonShadowContainer.heightAnchor.constraint(equalToConstant: NavigationActionView.buttonHeight).priority(.required - 1), + nextButtonShadowContainer.heightAnchor.constraint(equalToConstant: NavigationActionView.buttonHeight).priority(.required - 1), + nextButtonShadowContainer.widthAnchor.constraint(equalTo: backButtonShadowContainer.widthAnchor, multiplier: 2).priority(.required - 1), + ]) + + backButton.translatesAutoresizingMaskIntoConstraints = false + backButtonShadowContainer.addSubview(backButton) + NSLayoutConstraint.activate([ + backButton.topAnchor.constraint(equalTo: backButtonShadowContainer.topAnchor), + backButton.leadingAnchor.constraint(equalTo: backButtonShadowContainer.leadingAnchor), + backButton.trailingAnchor.constraint(equalTo: backButtonShadowContainer.trailingAnchor), + backButton.bottomAnchor.constraint(equalTo: backButtonShadowContainer.bottomAnchor), + ]) + + nextButton.translatesAutoresizingMaskIntoConstraints = false + nextButtonShadowContainer.addSubview(nextButton) + NSLayoutConstraint.activate([ + nextButton.topAnchor.constraint(equalTo: nextButtonShadowContainer.topAnchor), + nextButton.leadingAnchor.constraint(equalTo: nextButtonShadowContainer.leadingAnchor), + nextButton.trailingAnchor.constraint(equalTo: nextButtonShadowContainer.trailingAnchor), + nextButton.bottomAnchor.constraint(equalTo: nextButtonShadowContainer.bottomAnchor), + ]) + } + +} diff --git a/Mastodon/Scene/Onboarding/Share/OnboardingHeadlineTableViewCell.swift b/Mastodon/Scene/Onboarding/Share/OnboardingHeadlineTableViewCell.swift new file mode 100644 index 000000000..01070fbe9 --- /dev/null +++ b/Mastodon/Scene/Onboarding/Share/OnboardingHeadlineTableViewCell.swift @@ -0,0 +1,67 @@ +// +// OnboardingHeadlineTableViewCell.swift +// Mastodon +// +// Created by BradGao on 2021/2/23. +// + +import UIKit +import MastodonAsset +import MastodonLocalization + +final class OnboardingHeadlineTableViewCell: UITableViewCell { + + let titleLabel: UILabel = { + let label = UILabel() + label.font = MastodonPickServerViewController.largeTitleFont + label.textColor = MastodonPickServerViewController.largeTitleTextColor + label.text = L10n.Scene.ServerPicker.title + label.adjustsFontForContentSizeCategory = true + label.numberOfLines = 0 + return label + }() + + let subTitleLabel: UILabel = { + let label = UILabel() + label.font = MastodonPickServerViewController.subTitleFont + label.textColor = MastodonPickServerViewController.subTitleTextColor + label.text = L10n.Scene.ServerPicker.subtitle + label.adjustsFontForContentSizeCategory = true + 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 OnboardingHeadlineTableViewCell { + + private func _init() { + selectionStyle = .none + backgroundColor = Asset.Scene.Onboarding.background.color + + let container = UIStackView() + container.axis = .vertical + container.spacing = 16 + container.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(container) + NSLayoutConstraint.activate([ + container.topAnchor.constraint(equalTo: contentView.topAnchor), + container.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), + container.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: 11), + ]) + + container.addArrangedSubview(titleLabel) + container.addArrangedSubview(subTitleLabel) + } + +} diff --git a/Mastodon/Scene/Onboarding/Share/OnboardingNavigationController.swift b/Mastodon/Scene/Onboarding/Share/OnboardingNavigationController.swift new file mode 100644 index 000000000..537102dc9 --- /dev/null +++ b/Mastodon/Scene/Onboarding/Share/OnboardingNavigationController.swift @@ -0,0 +1,51 @@ +// +// OnboardingNavigationController.swift +// Mastodon +// +// Created by MainasuK on 2021-12-31. +// + +import UIKit + +final class OnboardingNavigationController: AdaptiveStatusBarStyleNavigationController { + + private(set) lazy var gradientBorderView = GradientBorderView(frame: view.bounds) + +} + +extension OnboardingNavigationController { + + override func viewDidLoad() { + super.viewDidLoad() + + gradientBorderView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(gradientBorderView) + NSLayoutConstraint.activate([ + gradientBorderView.topAnchor.constraint(equalTo: view.topAnchor), + gradientBorderView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + gradientBorderView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + gradientBorderView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + updateBorderViewDisplay() + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + } + +} + +extension OnboardingNavigationController { + + private func updateBorderViewDisplay() { + switch traitCollection.userInterfaceIdiom { + case .phone: + gradientBorderView.isHidden = true + default: + gradientBorderView.isHidden = false + } + } + +} diff --git a/Mastodon/Scene/Onboarding/Share/OnboardingViewControllerAppearance.swift b/Mastodon/Scene/Onboarding/Share/OnboardingViewControllerAppearance.swift index 17c4699ec..ba1eecfc5 100644 --- a/Mastodon/Scene/Onboarding/Share/OnboardingViewControllerAppearance.swift +++ b/Mastodon/Scene/Onboarding/Share/OnboardingViewControllerAppearance.swift @@ -6,6 +6,8 @@ // import UIKit +import MastodonAsset +import MastodonLocalization protocol OnboardingViewControllerAppearance: UIViewController { static var viewBottomPaddingHeight: CGFloat { get } @@ -15,12 +17,30 @@ protocol OnboardingViewControllerAppearance: UIViewController { extension OnboardingViewControllerAppearance { - static var actionButtonHeight: CGFloat { return 46 } + static var actionButtonHeight: CGFloat { return 50 } static var actionButtonMargin: CGFloat { return 12 } + static var actionButtonMarginExtend: CGFloat { return 80 } static var viewBottomPaddingHeight: CGFloat { return 11 } + static var viewBottomPaddingHeightExtend: CGFloat { return 22 } + + static var largeTitleFont: UIFont { + return UIFontMetrics(forTextStyle: .largeTitle).scaledFont(for: .systemFont(ofSize: 28, weight: .bold)) + } + + static var largeTitleTextColor: UIColor { + return Asset.Colors.Label.primary.color + } + + static var subTitleFont: UIFont { + return UIFontMetrics(forTextStyle: .largeTitle).scaledFont(for: .systemFont(ofSize: 17, weight: .regular)) + } + + static var subTitleTextColor: UIColor { + return Asset.Colors.Label.secondary.color + } func setupOnboardingAppearance() { - view.backgroundColor = Asset.Theme.Mastodon.systemGroupedBackground.color + view.backgroundColor = Asset.Scene.Onboarding.background.color setupNavigationBarAppearance() @@ -37,31 +57,22 @@ extension OnboardingViewControllerAppearance { // use TransparentBackground so view push / dismiss will be more visual nature // please add opaque background for status bar manually if needs - 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 - } + 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() { let navigationBarBackgroundView: UIView = { let view = UIView() - view.backgroundColor = Asset.Theme.Mastodon.systemGroupedBackground.color + view.backgroundColor = Asset.Scene.Onboarding.background.color return view }() diff --git a/Mastodon/Scene/Onboarding/Welcome/View/GradientBorderView.swift b/Mastodon/Scene/Onboarding/Welcome/View/GradientBorderView.swift new file mode 100644 index 000000000..68e7968bf --- /dev/null +++ b/Mastodon/Scene/Onboarding/Welcome/View/GradientBorderView.swift @@ -0,0 +1,63 @@ +// +// GradientBorderView.swift +// Mastodon +// +// Created by MainasuK on 2021-12-31. +// + +import UIKit + +final class GradientBorderView: UIView { + + let gradientLayer = CAGradientLayer() + let maskLayer = CAShapeLayer() + + var cornerRadius: CGFloat = 9 { + didSet { setNeedsLayout() } + } + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension GradientBorderView { + private func _init() { + isUserInteractionEnabled = false + + gradientLayer.frame = bounds + + gradientLayer.colors = [ + UIColor.white.cgColor, + UIColor.white.withAlphaComponent(0.0).cgColor, + ] + + gradientLayer.startPoint = CGPoint(x: 0.5, y: 0) + gradientLayer.endPoint = CGPoint(x: 0.5, y: 1) + + layer.addSublayer(gradientLayer) + + // set blend mode to "Soft Light" + layer.compositingFilter = "softLightBlendMode" + } + + override func layoutSubviews() { + super.layoutSubviews() + + let bezierPath = UIBezierPath(rect: bounds) + bezierPath.append(UIBezierPath(roundedRect: bounds.insetBy(dx: 3, dy: 3), cornerRadius: cornerRadius)) + + maskLayer.fillRule = .evenOdd + maskLayer.path = bezierPath.cgPath + + gradientLayer.frame = bounds + gradientLayer.mask = maskLayer + } +} diff --git a/Mastodon/Scene/Onboarding/Welcome/View/WelcomeIllustrationView.swift b/Mastodon/Scene/Onboarding/Welcome/View/WelcomeIllustrationView.swift index f5d8c41c8..9a5d6c13e 100644 --- a/Mastodon/Scene/Onboarding/Welcome/View/WelcomeIllustrationView.swift +++ b/Mastodon/Scene/Onboarding/Welcome/View/WelcomeIllustrationView.swift @@ -6,20 +6,22 @@ // import UIKit +import MastodonAsset +import MastodonLocalization final class WelcomeIllustrationView: UIView { - - static let artworkImageSize = CGSize(width: 375, height: 1500) - + let cloudBaseImageView = UIImageView() let rightHillImageView = UIImageView() let leftHillImageView = UIImageView() let centerHillImageView = UIImageView() private let cloudBaseImage = Asset.Scene.Welcome.Illustration.cloudBase.image + private let cloudBaseExtendImage = Asset.Scene.Welcome.Illustration.cloudBaseExtend.image private let elephantThreeOnGrassWithTreeTwoImage = Asset.Scene.Welcome.Illustration.elephantThreeOnGrassWithTreeTwo.image private let elephantThreeOnGrassWithTreeThreeImage = Asset.Scene.Welcome.Illustration.elephantThreeOnGrassWithTreeThree.image private let elephantThreeOnGrassImage = Asset.Scene.Welcome.Illustration.elephantThreeOnGrass.image + private let elephantThreeOnGrassExtendImage = Asset.Scene.Welcome.Illustration.elephantThreeOnGrassExtend.image // layout outside let elephantOnAirplaneWithContrailImageView: UIImageView = { @@ -27,6 +29,13 @@ final class WelcomeIllustrationView: UIView { imageView.contentMode = .scaleAspectFill return imageView }() + + var layout: Layout = .compact { + didSet { + setNeedsLayout() + } + } + var aspectLayoutConstraint: NSLayoutConstraint! override init(frame: CGRect) { super.init(frame: frame) @@ -40,6 +49,20 @@ final class WelcomeIllustrationView: UIView { } +extension WelcomeIllustrationView { + enum Layout { + case compact + case regular + + var artworkImageSize: CGSize { + switch self { + case .compact: return CGSize(width: 375, height: 1500) + case .regular: return CGSize(width: 547, height: 3000) + } + } + } +} + extension WelcomeIllustrationView { private func _init() { @@ -62,7 +85,6 @@ extension WelcomeIllustrationView { cloudBaseImageView.leadingAnchor.constraint(equalTo: leadingAnchor), cloudBaseImageView.trailingAnchor.constraint(equalTo: trailingAnchor), cloudBaseImageView.bottomAnchor.constraint(equalTo: bottomAnchor), - cloudBaseImageView.widthAnchor.constraint(equalTo: cloudBaseImageView.heightAnchor, multiplier: WelcomeIllustrationView.artworkImageSize.width / WelcomeIllustrationView.artworkImageSize.height), ]) [ @@ -79,15 +101,28 @@ extension WelcomeIllustrationView { imageView.bottomAnchor.constraint(equalTo: cloudBaseImageView.bottomAnchor), ]) } + + aspectLayoutConstraint = cloudBaseImageView.widthAnchor.constraint(equalTo: cloudBaseImageView.heightAnchor, multiplier: layout.artworkImageSize.width / layout.artworkImageSize.height) + aspectLayoutConstraint.isActive = true } override func layoutSubviews() { super.layoutSubviews() - updateImage() + + switch layout { + case .compact: + layoutCompact() + case .regular: + layoutRegular() + } + + aspectLayoutConstraint.isActive = false + aspectLayoutConstraint = cloudBaseImageView.widthAnchor.constraint(equalTo: cloudBaseImageView.heightAnchor, multiplier: layout.artworkImageSize.width / layout.artworkImageSize.height) + aspectLayoutConstraint.isActive = true } - private func updateImage() { - let size = WelcomeIllustrationView.artworkImageSize + private func layoutCompact() { + let size = layout.artworkImageSize let width = size.width let height = size.height @@ -130,6 +165,50 @@ extension WelcomeIllustrationView { } } + private func layoutRegular() { + let size = layout.artworkImageSize + let width = size.width + let height = size.height + + cloudBaseImageView.image = UIGraphicsImageRenderer(size: size).image { context in + // clear background + UIColor.clear.setFill() + context.fill(CGRect(origin: .zero, size: size)) + + // draw cloud + cloudBaseExtendImage.draw(at: CGPoint(x: 0, y: height - cloudBaseExtendImage.size.height)) + + rightHillImageView.image = UIGraphicsImageRenderer(size: size).image { context in + // clear background + UIColor.clear.setFill() + context.fill(CGRect(origin: .zero, size: size)) + + // draw elephantThreeOnGrassWithTreeTwoImage + // elephantThreeOnGrassWithTreeTwo.bottomY - 25 align to elephantThreeOnGrassImage.centerY + elephantThreeOnGrassWithTreeTwoImage.draw(at: CGPoint(x: width - elephantThreeOnGrassWithTreeTwoImage.size.width, y: height - 0.5 * elephantThreeOnGrassImage.size.height - elephantThreeOnGrassWithTreeTwoImage.size.height - 20)) + } + + leftHillImageView.image = UIGraphicsImageRenderer(size: size).image { context in + // clear background + UIColor.clear.setFill() + context.fill(CGRect(origin: .zero, size: size)) + + // draw elephantThreeOnGrassWithTreeThree + // elephantThreeOnGrassWithTreeThree.bottomY + 30 align to elephantThreeOnGrassImage.centerY + elephantThreeOnGrassWithTreeThreeImage.draw(at: CGPoint(x: -160, y: height - 0.5 * elephantThreeOnGrassImage.size.height - elephantThreeOnGrassWithTreeThreeImage.size.height - 80)) + } + + centerHillImageView.image = UIGraphicsImageRenderer(size: size).image { context in + // clear background + UIColor.clear.setFill() + context.fill(CGRect(origin: .zero, size: size)) + + // draw elephantThreeOnGrass + elephantThreeOnGrassExtendImage.draw(at: CGPoint(x: 0, y: height - elephantThreeOnGrassExtendImage.size.height)) + } + } + } + } #if canImport(SwiftUI) && DEBUG @@ -140,13 +219,17 @@ struct WelcomeIllustrationView_Previews: PreviewProvider { static var previews: some View { Group { UIViewPreview(width: 375) { - WelcomeIllustrationView() + let view = WelcomeIllustrationView() + view.layout = .compact + return view } .previewLayout(.fixed(width: 375, height: 1500)) - UIViewPreview(width: 1125) { - WelcomeIllustrationView() + UIViewPreview(width: 547) { + let view = WelcomeIllustrationView() + view.layout = .regular + return view } - .previewLayout(.fixed(width: 1125, height: 5000)) + .previewLayout(.fixed(width: 547, height: 1500)) } } diff --git a/Mastodon/Scene/Onboarding/Welcome/View/WizardCardView.swift b/Mastodon/Scene/Onboarding/Welcome/View/WizardCardView.swift index 6f18afc94..2ed581373 100644 --- a/Mastodon/Scene/Onboarding/Welcome/View/WizardCardView.swift +++ b/Mastodon/Scene/Onboarding/Welcome/View/WizardCardView.swift @@ -6,6 +6,8 @@ // import UIKit +import MastodonAsset +import MastodonLocalization final class WizardCardView: UIView { diff --git a/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift b/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift index bf33ea13d..2389947a1 100644 --- a/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift +++ b/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift @@ -8,9 +8,13 @@ import os.log import UIKit import Combine +import MastodonAsset +import MastodonLocalization final class WelcomeViewController: UIViewController, NeedsDependency { + let logger = Logger(subsystem: "WelcomeViewController", category: "ViewController") + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } @@ -41,29 +45,35 @@ final class WelcomeViewController: UIViewController, NeedsDependency { return label }() + let buttonContainer = UIStackView() + private(set) lazy var signUpButton: PrimaryActionButton = { let button = PrimaryActionButton() button.adjustsBackgroundImageWhenUserInterfaceStyleChanges = false - button.setTitle(L10n.Common.Controls.Actions.signUp, for: .normal) + button.setTitle(L10n.Scene.Welcome.getStarted, for: .normal) 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 = Asset.Colors.brandBlue.color - button.setTitleColor(titleColor, for: .normal) - button.translatesAutoresizingMaskIntoConstraints = false + button.setTitleColor(.black, for: .normal) return button }() + let signUpButtonShadowView = UIView() - private(set) lazy var signInButton: UIButton = { - let button = UIButton(type: .system) + private(set) lazy var signInButton: PrimaryActionButton = { + let button = PrimaryActionButton() + button.adjustsBackgroundImageWhenUserInterfaceStyleChanges = false 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 = UIColor.white.withAlphaComponent(0.8) + button.setTitle(L10n.Scene.Welcome.logIn, for: .normal) + let backgroundImageColor = Asset.Scene.Welcome.signInButtonBackground.color + let backgroundImageHighlightedColor = Asset.Scene.Welcome.signInButtonBackground.color.withAlphaComponent(0.8) + button.setBackgroundImage(.placeholder(color: backgroundImageColor), for: .normal) + button.setBackgroundImage(.placeholder(color: backgroundImageHighlightedColor), for: .highlighted) + let titleColor: UIColor = UIColor.white.withAlphaComponent(0.9) button.setTitleColor(titleColor, for: .normal) - button.translatesAutoresizingMaskIntoConstraints = false return button }() + let signInButtonShadowView = UIView() deinit { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) @@ -76,7 +86,8 @@ extension WelcomeViewController { override func viewDidLoad() { super.viewDidLoad() - // preferredContentSize = CGSize(width: 547, height: 678) + definesPresentationContext = true + preferredContentSize = CGSize(width: 547, height: 678) navigationController?.navigationBar.prefersLargeTitles = true navigationItem.largeTitleDisplayMode = .never @@ -84,19 +95,48 @@ extension WelcomeViewController { setupOnboardingAppearance() setupIllustrationLayout() - - view.addSubview(signInButton) - view.addSubview(signUpButton) + + buttonContainer.axis = .vertical + buttonContainer.spacing = 12 + buttonContainer.isLayoutMarginsRelativeArrangement = true + + buttonContainer.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(buttonContainer) NSLayoutConstraint.activate([ - signInButton.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor, constant: WelcomeViewController.actionButtonMargin), - view.readableContentGuide.trailingAnchor.constraint(equalTo: signInButton.trailingAnchor, constant: WelcomeViewController.actionButtonMargin), - view.layoutMarginsGuide.bottomAnchor.constraint(equalTo: signInButton.bottomAnchor, constant: WelcomeViewController.viewBottomPaddingHeight), - signInButton.heightAnchor.constraint(equalToConstant: WelcomeViewController.actionButtonHeight).priority(.defaultHigh), - - signInButton.topAnchor.constraint(equalTo: signUpButton.bottomAnchor, constant: 9), - signUpButton.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor, constant: WelcomeViewController.actionButtonMargin), - view.readableContentGuide.trailingAnchor.constraint(equalTo: signUpButton.trailingAnchor, constant: WelcomeViewController.actionButtonMargin), - signUpButton.heightAnchor.constraint(equalToConstant: WelcomeViewController.actionButtonHeight).priority(.defaultHigh), + buttonContainer.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor), + buttonContainer.trailingAnchor.constraint(equalTo: view.readableContentGuide.trailingAnchor), + view.layoutMarginsGuide.bottomAnchor.constraint(equalTo: buttonContainer.bottomAnchor), + ]) + + signUpButton.translatesAutoresizingMaskIntoConstraints = false + buttonContainer.addArrangedSubview(signUpButton) + NSLayoutConstraint.activate([ + signUpButton.heightAnchor.constraint(equalToConstant: WelcomeViewController.actionButtonHeight).priority(.required - 1), + ]) + signInButton.translatesAutoresizingMaskIntoConstraints = false + buttonContainer.addArrangedSubview(signInButton) + NSLayoutConstraint.activate([ + signInButton.heightAnchor.constraint(equalToConstant: WelcomeViewController.actionButtonHeight).priority(.required - 1), + ]) + + signUpButtonShadowView.translatesAutoresizingMaskIntoConstraints = false + buttonContainer.addSubview(signUpButtonShadowView) + buttonContainer.sendSubviewToBack(signUpButtonShadowView) + NSLayoutConstraint.activate([ + signUpButtonShadowView.topAnchor.constraint(equalTo: signUpButton.topAnchor), + signUpButtonShadowView.leadingAnchor.constraint(equalTo: signUpButton.leadingAnchor), + signUpButtonShadowView.trailingAnchor.constraint(equalTo: signUpButton.trailingAnchor), + signUpButtonShadowView.bottomAnchor.constraint(equalTo: signUpButton.bottomAnchor), + ]) + + signInButtonShadowView.translatesAutoresizingMaskIntoConstraints = false + buttonContainer.addSubview(signInButtonShadowView) + buttonContainer.sendSubviewToBack(signInButtonShadowView) + NSLayoutConstraint.activate([ + signInButtonShadowView.topAnchor.constraint(equalTo: signInButton.topAnchor), + signInButtonShadowView.leadingAnchor.constraint(equalTo: signInButton.leadingAnchor), + signInButtonShadowView.trailingAnchor.constraint(equalTo: signInButton.trailingAnchor), + signInButtonShadowView.bottomAnchor.constraint(equalTo: signInButton.bottomAnchor), ]) signUpButton.addTarget(self, action: #selector(signUpButtonDidClicked(_:)), for: .touchUpInside) @@ -109,17 +149,12 @@ extension WelcomeViewController { 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 viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + setupButtonShadowView() } override func viewSafeAreaInsetsDidChange() { @@ -130,18 +165,75 @@ extension WelcomeViewController { if view.safeAreaInsets.bottom == 0 { overlap += 56 } - // shift illustration down for iPad modal - if UIDevice.current.userInterfaceIdiom != .phone { - overlap += 20 - } welcomeIllustrationViewBottomAnchorLayoutConstraint?.constant = overlap } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + setupIllustrationLayout() + setupButtonShadowView() + } } extension WelcomeViewController { + private func setupButtonShadowView() { + signUpButtonShadowView.layer.setupShadow( + color: .black, + alpha: 0.25, + x: 0, + y: 1, + blur: 2, + spread: 0, + roundedRect: signInButtonShadowView.bounds, + byRoundingCorners: .allCorners, + cornerRadii: CGSize(width: 10, height: 10) + ) + signInButtonShadowView.layer.setupShadow( + color: .black, + alpha: 0.25, + x: 0, + y: 1, + blur: 2, + spread: 0, + roundedRect: signInButtonShadowView.bounds, + byRoundingCorners: .allCorners, + cornerRadii: CGSize(width: 10, height: 10) + ) + } + + private func updateButtonContainerLayoutMargins(traitCollection: UITraitCollection) { + switch traitCollection.userInterfaceIdiom { + case .phone: + buttonContainer.layoutMargins = UIEdgeInsets( + top: 0, + left: WelcomeViewController.actionButtonMargin, + bottom: WelcomeViewController.viewBottomPaddingHeight, + right: WelcomeViewController.actionButtonMargin + ) + default: + let margin = traitCollection.horizontalSizeClass == .regular ? WelcomeViewController.actionButtonMarginExtend : WelcomeViewController.actionButtonMargin + buttonContainer.layoutMargins = UIEdgeInsets( + top: 0, + left: margin, + bottom: WelcomeViewController.viewBottomPaddingHeightExtend, + right: margin + ) + } + } + private func setupIllustrationLayout() { + welcomeIllustrationView.layout = { + switch traitCollection.userInterfaceIdiom { + case .phone: + return .compact + default: + return .regular + } + }() + // set logo if logoImageView.superview == nil { view.addSubview(logoImageView) @@ -154,10 +246,11 @@ extension WelcomeViewController { logoImageView.setContentHuggingPriority(.defaultHigh, for: .vertical) } - // set illustration for phone + // set illustration guard welcomeIllustrationView.superview == nil else { return } + welcomeIllustrationView.contentMode = .scaleAspectFit welcomeIllustrationView.translatesAutoresizingMaskIntoConstraints = false welcomeIllustrationViewBottomAnchorLayoutConstraint = welcomeIllustrationView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: 5) @@ -166,7 +259,7 @@ extension WelcomeViewController { NSLayoutConstraint.activate([ view.leftAnchor.constraint(equalTo: welcomeIllustrationView.leftAnchor, constant: 15), welcomeIllustrationView.rightAnchor.constraint(equalTo: view.rightAnchor, constant: 15), - welcomeIllustrationViewBottomAnchorLayoutConstraint! + welcomeIllustrationViewBottomAnchorLayoutConstraint!.priority(.required - 1), ]) welcomeIllustrationView.cloudBaseImageView.addMotionEffect( @@ -216,19 +309,6 @@ extension WelcomeViewController { 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), -// ]) -// } } } @@ -268,21 +348,36 @@ extension WelcomeViewController: OnboardingViewControllerAppearance { // MARK: - UIAdaptivePresentationControllerDelegate extension WelcomeViewController: UIAdaptivePresentationControllerDelegate { + func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") + + // update button layout + updateButtonContainerLayoutMargins(traitCollection: traitCollection) + + let navigationController = navigationController as? OnboardingNavigationController + switch traitCollection.userInterfaceIdiom { case .phone: + navigationController?.gradientBorderView.isHidden = true // 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 -// } + switch traitCollection.horizontalSizeClass { + case .compact: + navigationController?.gradientBorderView.isHidden = true + return .fullScreen + default: + navigationController?.gradientBorderView.isHidden = false + return .formSheet + } } } + func presentationController(_ controller: UIPresentationController, viewControllerForAdaptivePresentationStyle style: UIModalPresentationStyle) -> UIViewController? { + return nil + } + func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool { return false } diff --git a/Mastodon/Scene/Profile/About/Cell/ProfileFieldAddEntryCollectionViewCell.swift b/Mastodon/Scene/Profile/About/Cell/ProfileFieldAddEntryCollectionViewCell.swift new file mode 100644 index 000000000..9f22886e6 --- /dev/null +++ b/Mastodon/Scene/Profile/About/Cell/ProfileFieldAddEntryCollectionViewCell.swift @@ -0,0 +1,78 @@ +// +// ProfileFieldAddEntryCollectionViewCell.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-5-26. +// + +import os.log +import UIKit +import Combine +import MastodonAsset +import MastodonLocalization +import MetaTextKit +import MastodonUI + +final class ProfileFieldAddEntryCollectionViewCell: UICollectionViewCell { + + static let symbolConfiguration = ProfileFieldEditCollectionViewCell.symbolConfiguration + static let insertButtonImage = UIImage(systemName: "plus.circle.fill", withConfiguration: symbolConfiguration) + + let containerStackView = UIStackView() + + let editButton: UIButton = { + let button = HitTestExpandedButton(type: .custom) + button.setImage(ProfileFieldAddEntryCollectionViewCell.insertButtonImage, for: .normal) + button.contentMode = .center + button.tintColor = .systemGreen + return button + }() + + let primaryLabel = MetaLabel(style: .profileFieldValue) + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ProfileFieldAddEntryCollectionViewCell { + + private func _init() { + containerStackView.axis = .horizontal + containerStackView.spacing = 8 + containerStackView.alignment = .center + + contentView.preservesSuperviewLayoutMargins = true + containerStackView.preservesSuperviewLayoutMargins = true + containerStackView.isLayoutMarginsRelativeArrangement = true + + containerStackView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(containerStackView) + NSLayoutConstraint.activate([ + containerStackView.topAnchor.constraint(equalTo: contentView.topAnchor), + containerStackView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), + containerStackView.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), + containerStackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + containerStackView.heightAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh), + ]) + containerStackView.isLayoutMarginsRelativeArrangement = true + + containerStackView.addArrangedSubview(editButton) + containerStackView.addArrangedSubview(primaryLabel) + + editButton.setContentCompressionResistancePriority(.required - 1, for: .horizontal) + editButton.setContentHuggingPriority(.required - 1, for: .horizontal) + editButton.isUserInteractionEnabled = false + + primaryLabel.configure(content: PlaintextMetaContent(string: L10n.Scene.Profile.Fields.addRow)) + primaryLabel.isUserInteractionEnabled = false + } + +} diff --git a/Mastodon/Scene/Profile/About/Cell/ProfileFieldCollectionViewCell.swift b/Mastodon/Scene/Profile/About/Cell/ProfileFieldCollectionViewCell.swift new file mode 100644 index 000000000..ed6f68fec --- /dev/null +++ b/Mastodon/Scene/Profile/About/Cell/ProfileFieldCollectionViewCell.swift @@ -0,0 +1,87 @@ +// +// ProfileFieldCollectionViewCell.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-5-25. +// + +import os.log +import UIKit +import Combine +import MetaTextKit +import MastodonAsset +import MastodonLocalization + +protocol ProfileFieldCollectionViewCellDelegate: AnyObject { + func profileFieldCollectionViewCell(_ cell: ProfileFieldCollectionViewCell, metaLebel: MetaLabel, didSelectMeta meta: Meta) +} + +final class ProfileFieldCollectionViewCell: UICollectionViewCell { + + var disposeBag = Set<AnyCancellable>() + + weak var delegate: ProfileFieldCollectionViewCellDelegate? + + // for custom emoji display + let keyMetaLabel = MetaLabel(style: .profileFieldName) + let valueMetaLabel = MetaLabel(style: .profileFieldValue) + + 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 ProfileFieldCollectionViewCell { + + private func _init() { + // containerStackView: V - [ metaContainer | plainContainer ] + let containerStackView = UIStackView() + containerStackView.axis = .vertical + + contentView.preservesSuperviewLayoutMargins = true + containerStackView.preservesSuperviewLayoutMargins = true + containerStackView.isLayoutMarginsRelativeArrangement = true + containerStackView.translatesAutoresizingMaskIntoConstraints = false + addSubview(containerStackView) + NSLayoutConstraint.activate([ + containerStackView.topAnchor.constraint(equalTo: topAnchor, constant: 11), + containerStackView.leadingAnchor.constraint(equalTo: leadingAnchor), + containerStackView.trailingAnchor.constraint(equalTo: trailingAnchor), + bottomAnchor.constraint(equalTo: containerStackView.bottomAnchor, constant: 11), + ]) + + // metaContainer: V - [ keyMetaLabel | valueMetaLabel ] + let metaContainer = UIStackView() + metaContainer.axis = .vertical + metaContainer.spacing = 2 + containerStackView.addArrangedSubview(metaContainer) + + metaContainer.addArrangedSubview(keyMetaLabel) + metaContainer.addArrangedSubview(valueMetaLabel) + + keyMetaLabel.linkDelegate = self + valueMetaLabel.linkDelegate = self + } + +} + +// MARK: - MetaLabelDelegate +extension ProfileFieldCollectionViewCell: MetaLabelDelegate { + func metaLabel(_ metaLabel: MetaLabel, didSelectMeta meta: Meta) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + delegate?.profileFieldCollectionViewCell(self, metaLebel: metaLabel, didSelectMeta: meta) + } +} diff --git a/Mastodon/Scene/Profile/About/Cell/ProfileFieldEditCollectionViewCell.swift b/Mastodon/Scene/Profile/About/Cell/ProfileFieldEditCollectionViewCell.swift new file mode 100644 index 000000000..43c47f1e1 --- /dev/null +++ b/Mastodon/Scene/Profile/About/Cell/ProfileFieldEditCollectionViewCell.swift @@ -0,0 +1,132 @@ +// +// ProfileFieldEditCollectionViewCell.swift +// Mastodon +// +// Created by MainasuK on 2022-1-22. +// + +import os.log +import UIKit +import Combine +import MetaTextKit +import MastodonAsset +import MastodonLocalization + +protocol ProfileFieldEditCollectionViewCellDelegate: AnyObject { + func profileFieldEditCollectionViewCell(_ cell: ProfileFieldEditCollectionViewCell, editButtonDidPressed button: UIButton) +} + +final class ProfileFieldEditCollectionViewCell: UICollectionViewCell { + + var disposeBag = Set<AnyCancellable>() + + weak var delegate: ProfileFieldEditCollectionViewCellDelegate? + + static let symbolConfiguration = UIImage.SymbolConfiguration(pointSize: 22, weight: .semibold, scale: .medium) + static let removeButtonImage = UIImage(systemName: "minus.circle.fill", withConfiguration: symbolConfiguration) + + let containerStackView = UIStackView() + + let editButton: UIButton = { + let button = HitTestExpandedButton(type: .custom) + button.setImage(ProfileFieldEditCollectionViewCell.removeButtonImage, for: .normal) + button.contentMode = .center + button.tintColor = .systemRed + return button + }() + + // for editing + let keyTextField: UITextField = { + let textField = UITextField() + textField.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 13, weight: .semibold)) + textField.textColor = Asset.Colors.Label.secondary.color + textField.placeholder = L10n.Scene.Profile.Fields.Placeholder.label + return textField + }() + + // for editing + let valueTextField: UITextField = { + let textField = UITextField() + textField.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .regular)) + textField.textColor = Asset.Colors.Label.primary.color + textField.placeholder = L10n.Scene.Profile.Fields.Placeholder.content + return textField + }() + + let reorderBarImageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFit + imageView.image = UIImage(systemName: "line.horizontal.3")?.withConfiguration(UIImage.SymbolConfiguration(pointSize: 20, weight: .regular)).withRenderingMode(.alwaysTemplate) + imageView.tintColor = Asset.Colors.Label.secondary.color + return imageView + }() + + 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 ProfileFieldEditCollectionViewCell { + + private func _init() { + // containerStackView: H: - [ editButton | fieldContainer | reorderBarImageView ] + containerStackView.axis = .horizontal + containerStackView.spacing = 8 + containerStackView.alignment = .center + + contentView.preservesSuperviewLayoutMargins = true + containerStackView.preservesSuperviewLayoutMargins = true + containerStackView.isLayoutMarginsRelativeArrangement = true + + containerStackView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(containerStackView) + NSLayoutConstraint.activate([ + containerStackView.topAnchor.constraint(equalTo: contentView.topAnchor), + containerStackView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), + containerStackView.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), + containerStackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + ]) + + let fieldContainer = UIStackView() + fieldContainer.axis = .vertical + containerStackView.addArrangedSubview(fieldContainer) + + fieldContainer.addArrangedSubview(keyTextField) + fieldContainer.addArrangedSubview(valueTextField) + + containerStackView.addArrangedSubview(editButton) + containerStackView.addArrangedSubview(fieldContainer) + containerStackView.addArrangedSubview(reorderBarImageView) + + // editButton + editButton.setContentHuggingPriority(.required - 1, for: .horizontal) + editButton.setContentHuggingPriority(.required - 1, for: .vertical) + // reorderBarImageView + reorderBarImageView.setContentHuggingPriority(.required - 1, for: .horizontal) + reorderBarImageView.setContentCompressionResistancePriority(.required - 1, for: .horizontal) + + editButton.addTarget(self, action: #selector(ProfileFieldEditCollectionViewCell.editButtonDidPressed(_:)), for: .touchUpInside) + } + +} + +extension ProfileFieldEditCollectionViewCell { + @objc private func editButtonDidPressed(_ sender: UIButton) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + delegate?.profileFieldEditCollectionViewCell(self, editButtonDidPressed: sender) + } +} + diff --git a/Mastodon/Scene/Profile/About/ProfileAboutViewController.swift b/Mastodon/Scene/Profile/About/ProfileAboutViewController.swift new file mode 100644 index 000000000..4879be744 --- /dev/null +++ b/Mastodon/Scene/Profile/About/ProfileAboutViewController.swift @@ -0,0 +1,168 @@ +// +// ProfileAboutViewController.swift +// Mastodon +// +// Created by MainasuK on 2022-1-22. +// + +import os.log +import UIKit +import Combine +import MetaTextKit + +protocol ProfileAboutViewControllerDelegate: AnyObject { + func profileAboutViewController(_ viewController: ProfileAboutViewController, profileFieldCollectionViewCell: ProfileFieldCollectionViewCell, metaLabel: MetaLabel, didSelectMeta meta: Meta) +} + +final class ProfileAboutViewController: UIViewController { + + let logger = Logger(subsystem: "ProfileAboutViewController", category: "ViewController") + + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + weak var delegate: ProfileAboutViewControllerDelegate? + + var disposeBag = Set<AnyCancellable>() + var viewModel: ProfileAboutViewModel! + + let collectionView: UICollectionView = { + var configuration = UICollectionLayoutListConfiguration(appearance: .insetGrouped) + configuration.backgroundColor = .clear + configuration.headerMode = .supplementary + let layout = UICollectionViewCompositionalLayout.list(using: configuration) + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + return collectionView + }() + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension ProfileAboutViewController { + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = ThemeService.shared.currentTheme.value.systemBackgroundColor + ThemeService.shared.currentTheme + .receive(on: DispatchQueue.main) + .sink { [weak self] theme in + guard let self = self else { return } + self.view.backgroundColor = theme.systemBackgroundColor + } + .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), + ]) + + collectionView.delegate = self + viewModel.setupDiffableDataSource( + collectionView: collectionView, + profileFieldCollectionViewCellDelegate: self, + profileFieldEditCollectionViewCellDelegate: self + ) + + let longPressReorderGesture = UILongPressGestureRecognizer( + target: self, + action: #selector(ProfileAboutViewController.longPressReorderGestureHandler(_:)) + ) + collectionView.addGestureRecognizer(longPressReorderGesture) + } + +} + +extension ProfileAboutViewController { + // seealso: ProfileAboutViewModel.setupProfileDiffableDataSource(…) + @objc private func longPressReorderGestureHandler(_ sender: UILongPressGestureRecognizer) { + guard sender.view === collectionView else { + assertionFailure() + return + } + + guard let diffableDataSource = self.viewModel.diffableDataSource else { + collectionView.cancelInteractiveMovement() + return + } + + switch(sender.state) { + case .began: + guard let indexPath = collectionView.indexPathForItem(at: sender.location(in: collectionView)), + let item = diffableDataSource.itemIdentifier(for: indexPath), case .editField = item, + let layoutAttribute = collectionView.layoutAttributesForItem(at: indexPath) else { + break + } + + let point = sender.location(in: collectionView) + guard layoutAttribute.frame.contains(point) else { + return + } + + collectionView.beginInteractiveMovementForItem(at: indexPath) + case .changed: + guard let indexPath = collectionView.indexPathForItem(at: sender.location(in: collectionView)) else { + break + } + guard let item = diffableDataSource.itemIdentifier(for: indexPath), case .editField = 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() + default: + collectionView.cancelInteractiveMovement() + } + } +} + +// MARK: - UICollectionViewDelegate +extension ProfileAboutViewController: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): select \(indexPath.debugDescription)") + + guard let diffableDataSource = viewModel.diffableDataSource else { return } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } + switch item { + case .addEntry: + viewModel.appendFieldItem() + default: + break + } + } +} + +// MARK: - ProfileFieldCollectionViewCellDelegate +extension ProfileAboutViewController: ProfileFieldCollectionViewCellDelegate { + func profileFieldCollectionViewCell(_ cell: ProfileFieldCollectionViewCell, metaLebel: MetaLabel, didSelectMeta meta: Meta) { + delegate?.profileAboutViewController(self, profileFieldCollectionViewCell: cell, metaLabel: metaLebel, didSelectMeta: meta) + } +} + +// MARK: - ProfileFieldEditCollectionViewCellDelegate +extension ProfileAboutViewController: ProfileFieldEditCollectionViewCellDelegate { + func profileFieldEditCollectionViewCell(_ cell: ProfileFieldEditCollectionViewCell, editButtonDidPressed button: UIButton) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + guard let indexPath = collectionView.indexPath(for: cell) else { return } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } + viewModel.removeFieldItem(item: item) + } +} + +// MARK: - ScrollViewContainer +extension ProfileAboutViewController: ScrollViewContainer { + var scrollView: UIScrollView? { + collectionView + } +} diff --git a/Mastodon/Scene/Profile/About/ProfileAboutViewModel+Diffable.swift b/Mastodon/Scene/Profile/About/ProfileAboutViewModel+Diffable.swift new file mode 100644 index 000000000..259cad12d --- /dev/null +++ b/Mastodon/Scene/Profile/About/ProfileAboutViewModel+Diffable.swift @@ -0,0 +1,88 @@ +// +// ProfileAboutViewModel+Diffable.swift +// Mastodon +// +// Created by MainasuK on 2022-1-22. +// + +import os.log +import UIKit +import Combine +import MastodonSDK + +extension ProfileAboutViewModel { + + func setupDiffableDataSource( + collectionView: UICollectionView, + profileFieldCollectionViewCellDelegate: ProfileFieldCollectionViewCellDelegate, + profileFieldEditCollectionViewCellDelegate: ProfileFieldEditCollectionViewCellDelegate + ) { + let diffableDataSource = ProfileFieldSection.diffableDataSource( + collectionView: collectionView, + context: context, + configuration: ProfileFieldSection.Configuration( + profileFieldCollectionViewCellDelegate: profileFieldCollectionViewCellDelegate, + profileFieldEditCollectionViewCellDelegate: profileFieldEditCollectionViewCellDelegate + ) + ) + + diffableDataSource.reorderingHandlers.canReorderItem = { item -> Bool in + switch item { + case .editField: return true + default: return false + } + } + + diffableDataSource.reorderingHandlers.didReorder = { [weak self] transaction in + guard let self = self else { return } + + let items = transaction.finalSnapshot.itemIdentifiers + var fields: [ProfileFieldItem.FieldValue] = [] + for item in items { + guard case let .editField(field) = item else { continue } + fields.append(field) + } + self.editProfileInfo.fields = fields + } + + self.diffableDataSource = diffableDataSource + + Publishers.CombineLatest4( + $isEditing.removeDuplicates(), + displayProfileInfo.$fields.removeDuplicates(), + editProfileInfo.$fields.removeDuplicates(), + $emojiMeta.removeDuplicates() + ) + .throttle(for: 0.3, scheduler: DispatchQueue.main, latest: true) + .sink { [weak self] isEditing, displayFields, editingFields, emojiMeta in + guard let self = self else { return } + guard let diffableDataSource = self.diffableDataSource else { return } + + var snapshot = NSDiffableDataSourceSnapshot<ProfileFieldSection, ProfileFieldItem>() + snapshot.appendSections([.main]) + + let fields: [ProfileFieldItem.FieldValue] = isEditing ? editingFields : displayFields + var items: [ProfileFieldItem] = fields.map { field in + if isEditing { + return ProfileFieldItem.editField(field: field) + } else { + return ProfileFieldItem.field(field: field) + } + } + + if isEditing, fields.count < ProfileHeaderViewModel.maxProfileFieldCount { + items.append(.addEntry) + } + + if !isEditing, items.isEmpty { + items.append(.noResult) + } + + snapshot.appendItems(items, toSection: .main) + + diffableDataSource.apply(snapshot, animatingDifferences: false, completion: nil) + } + .store(in: &disposeBag) + } + +} diff --git a/Mastodon/Scene/Profile/About/ProfileAboutViewModel.swift b/Mastodon/Scene/Profile/About/ProfileAboutViewModel.swift new file mode 100644 index 000000000..c7ef895dd --- /dev/null +++ b/Mastodon/Scene/Profile/About/ProfileAboutViewModel.swift @@ -0,0 +1,106 @@ +// +// ProfileAboutViewModel.swift +// Mastodon +// +// Created by MainasuK on 2022-1-22. +// + +import os.log +import UIKit +import Combine +import MastodonSDK +import MastodonMeta +import Kanna + +final class ProfileAboutViewModel { + + var disposeBag = Set<AnyCancellable>() + + // input + let context: AppContext + @Published var isEditing = false + @Published var accountForEdit: Mastodon.Entity.Account? + @Published var emojiMeta: MastodonContent.Emojis = [:] + + // output + var diffableDataSource: UICollectionViewDiffableDataSource<ProfileFieldSection, ProfileFieldItem>? + + let displayProfileInfo = ProfileInfo() + let editProfileInfo = ProfileInfo() + let editProfileInfoDidInitialized = CurrentValueSubject<Void, Never>(Void()) // needs trigger initial event + + init(context: AppContext) { + self.context = context + // end init + + Publishers.CombineLatest( + $isEditing.removeDuplicates(), // only trigger when value toggle + $accountForEdit + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] isEditing, account in + guard let self = self else { return } + guard isEditing else { return } + + // setup editing value when toggle to editing + self.editProfileInfo.fields = account?.source?.fields?.compactMap { field in + ProfileFieldItem.FieldValue( + name: field.name, + value: field.value, + emojiMeta: [:] // no use for editing + ) + } ?? [] + self.editProfileInfoDidInitialized.send() + } + .store(in: &disposeBag) + } + +} + +extension ProfileAboutViewModel { + class ProfileInfo { + @Published var fields: [ProfileFieldItem.FieldValue] = [] + } +} + +extension ProfileAboutViewModel { + func appendFieldItem() { + var fields = editProfileInfo.fields + guard fields.count < ProfileHeaderViewModel.maxProfileFieldCount else { return } + fields.append(ProfileFieldItem.FieldValue(name: "", value: "", emojiMeta: [:])) + editProfileInfo.fields = fields + } + + func removeFieldItem(item: ProfileFieldItem) { + var fields = editProfileInfo.fields + guard case let .editField(field) = item else { return } + guard let removeIndex = fields.firstIndex(of: field) else { return } + fields.remove(at: removeIndex) + editProfileInfo.fields = fields + } +} + +// MARK: - ProfileViewModelEditable +extension ProfileAboutViewModel: ProfileViewModelEditable { + func isEdited() -> Bool { + guard isEditing else { return false } + + let isFieldsEqual: Bool = { + let originalFields = self.accountForEdit?.source?.fields?.compactMap { field in + ProfileFieldItem.FieldValue(name: field.name, value: field.value, emojiMeta: [:]) + } ?? [] + let editFields = editProfileInfo.fields + guard editFields.count == originalFields.count else { return false } + for (editField, originalField) in zip(editFields, originalFields) { + guard editField.name.value == originalField.name.value, + editField.value.value == originalField.value.value else { + return false + } + } + return true + }() + guard isFieldsEqual else { return true } + + return false + } +} diff --git a/Mastodon/Scene/Profile/CachedProfileViewModel.swift b/Mastodon/Scene/Profile/CachedProfileViewModel.swift index 083724be1..c33a905a7 100644 --- a/Mastodon/Scene/Profile/CachedProfileViewModel.swift +++ b/Mastodon/Scene/Profile/CachedProfileViewModel.swift @@ -12,6 +12,8 @@ final class CachedProfileViewModel: ProfileViewModel { init(context: AppContext, mastodonUser: MastodonUser) { super.init(context: context, optionalMastodonUser: mastodonUser) + + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [Profile] user[\(mastodonUser.id)] profile: \(mastodonUser.acctWithDomain)") } } diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewController+DataSourceProvider.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewController+DataSourceProvider.swift new file mode 100644 index 000000000..8fe8d1bd7 --- /dev/null +++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewController+DataSourceProvider.swift @@ -0,0 +1,34 @@ +// +// FavoriteViewController+DataSourceProvider.swift +// Mastodon +// +// Created by MainasuK on 2022-1-18. +// + +import UIKit + +extension FavoriteViewController: DataSourceProvider { + func item(from source: DataSourceItem.Source) async -> DataSourceItem? { + var _indexPath = source.indexPath + if _indexPath == nil, let cell = source.tableViewCell { + _indexPath = await self.indexPath(for: cell) + } + guard let indexPath = _indexPath else { return nil } + + guard let item = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else { + return nil + } + + switch item { + case .status(let record): + return .status(record: record) + default: + return nil + } + } + + @MainActor + private func indexPath(for cell: UITableViewCell) async -> IndexPath? { + return tableView.indexPath(for: cell) + } +} diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewController+Provider.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewController+Provider.swift deleted file mode 100644 index f4631b6e6..000000000 --- a/Mastodon/Scene/Profile/Favorite/FavoriteViewController+Provider.swift +++ /dev/null @@ -1,95 +0,0 @@ -// -// FavoriteViewController+StatusProvider.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-4-7. -// - -import os.log -import UIKit -import Combine -import CoreData -import CoreDataStack - -// MARK: - StatusProvider -extension FavoriteViewController: StatusProvider { - - func status() -> Future<Status?, Never> { - return Future { promise in promise(.success(nil)) } - } - - func status(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future<Status?, Never> { - return Future { promise in - 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 .status(let objectID, _): - let managedObjectContext = self.viewModel.statusFetchedResultsController.fetchedResultsController.managedObjectContext - managedObjectContext.perform { - let status = managedObjectContext.object(with: objectID) as? Status - promise(.success(status)) - } - default: - promise(.success(nil)) - } - } - } - - func status(for cell: UICollectionViewCell) -> Future<Status?, Never> { - return Future { promise in promise(.success(nil)) } - } - - var managedObjectContext: NSManagedObjectContext { - return viewModel.statusFetchedResultsController.fetchedResultsController.managedObjectContext - } - - var tableViewDiffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>? { - return viewModel.diffableDataSource - } - - func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item? { - guard let diffableDataSource = self.viewModel.diffableDataSource else { - assertionFailure() - return nil - } - - guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }), - let item = diffableDataSource.itemIdentifier(for: indexPath) else { - return nil - } - - return item - } - - func items(indexPaths: [IndexPath]) -> [Item] { - guard let diffableDataSource = self.viewModel.diffableDataSource else { - assertionFailure() - return [] - } - - var items: [Item] = [] - for indexPath in indexPaths { - guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { continue } - items.append(item) - } - return items - } - - func statusObjectItems(indexPaths: [IndexPath]) -> [StatusObjectItem] { - guard let diffableDataSource = self.viewModel.diffableDataSource else { return [] } - let items = indexPaths.compactMap { diffableDataSource.itemIdentifier(for: $0)?.statusObjectItem } - return items - } - -} - -extension FavoriteViewController: UserProvider {} diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift index c9890c248..2ac1e2065 100644 --- a/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift +++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift @@ -13,9 +13,13 @@ import UIKit import AVKit import Combine import GameplayKit +import MastodonAsset +import MastodonLocalization final class FavoriteViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { + let logger = Logger(subsystem: "FavoriteViewController", category: "ViewController") + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } @@ -37,7 +41,7 @@ final class FavoriteViewController: UIViewController, NeedsDependency, MediaPrev }() deinit { - os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) } } @@ -49,7 +53,7 @@ extension FavoriteViewController { view.backgroundColor = ThemeService.shared.currentTheme.value.secondarySystemBackgroundColor ThemeService.shared.currentTheme - .receive(on: RunLoop.main) + .receive(on: DispatchQueue.main) .sink { [weak self] theme in guard let self = self else { return } self.view.backgroundColor = theme.secondarySystemBackgroundColor @@ -69,69 +73,50 @@ extension FavoriteViewController { ]) tableView.delegate = self - tableView.prefetchDataSource = self viewModel.setupDiffableDataSource( - for: tableView, - dependency: self, + tableView: tableView, 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(FavoriteViewModel.State.Loading.self) + } + .store(in: &disposeBag) } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - aspectViewWillAppear(animated) + tableView.deselectRow(with: transitionCoordinator, animated: animated) } override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) - aspectViewDidDisappear(animated) +// aspectViewDidDisappear(animated) } } -// MARK: - StatusTableViewControllerAspect -extension FavoriteViewController: StatusTableViewControllerAspect { } - -// MARK: - TableViewCellHeightCacheableContainer -extension FavoriteViewController: TableViewCellHeightCacheableContainer { - var cellFrameCache: NSCache<NSNumber, NSValue> { - return viewModel.cellFrameCache - } -} - -// MARK: - UIScrollViewDelegate -extension FavoriteViewController { - func scrollViewDidScroll(_ scrollView: UIScrollView) { - aspectScrollViewDidScroll(scrollView) - } -} - // MARK: - UITableViewDelegate -extension FavoriteViewController: UITableViewDelegate { +extension FavoriteViewController: UITableViewDelegate, AutoGenerateTableViewDelegate { + // sourcery:inline:FavoriteViewController.AutoGenerateTableViewDelegate - func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { - aspectTableView(tableView, estimatedHeightForRowAt: indexPath) - } - - func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { - aspectTableView(tableView, willDisplay: cell, forRowAt: indexPath) - } - - func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { - aspectTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) - } - + // Generated using Sourcery + // DO NOT EDIT func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { aspectTableView(tableView, didSelectRowAt: 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) } @@ -139,48 +124,17 @@ extension FavoriteViewController: UITableViewDelegate { 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) } - -} - -// MARK: - UITableViewDataSourcePrefetching -extension FavoriteViewController: UITableViewDataSourcePrefetching { - func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { - aspectTableView(tableView, prefetchRowsAt: indexPaths) - } -} - -// MARK: - AVPlayerViewControllerDelegate -extension FavoriteViewController: AVPlayerViewControllerDelegate { - - func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { - aspectPlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator) - } - - func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { - aspectPlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator) - } - -} - -// MARK: - TimelinePostTableViewCellDelegate -extension FavoriteViewController: StatusTableViewCellDelegate { - weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self } - func parent() -> UIViewController { return self } -} - -// MARK: - LoadMoreConfigurableTableViewContainer -extension FavoriteViewController: LoadMoreConfigurableTableViewContainer { - typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell - typealias LoadingState = FavoriteViewModel.State.Loading - - var loadMoreConfigurableTableView: UITableView { return tableView } - var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.stateMachine } + + + // sourcery:end } +// MARK: - StatusTableViewCellDelegate +extension FavoriteViewController: StatusTableViewCellDelegate { } extension FavoriteViewController { override var keyCommands: [UIKeyCommand]? { diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+Diffable.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+Diffable.swift index 314721413..58109247e 100644 --- a/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+Diffable.swift +++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+Diffable.swift @@ -10,26 +10,56 @@ import UIKit extension FavoriteViewModel { func setupDiffableDataSource( - for tableView: UITableView, - dependency: NeedsDependency, + tableView: UITableView, statusTableViewCellDelegate: StatusTableViewCellDelegate - ) { - diffableDataSource = StatusSection.tableViewDiffableDataSource( - for: tableView, - timelineContext: .favorite, - dependency: dependency, - managedObjectContext: statusFetchedResultsController.fetchedResultsController.managedObjectContext, - statusTableViewCellDelegate: statusTableViewCellDelegate, - timelineMiddleLoaderTableViewCellDelegate: nil, - threadReplyLoaderTableViewCellDelegate: nil + ) { + diffableDataSource = StatusSection.diffableDataSource( + tableView: tableView, + context: context, + configuration: StatusSection.Configuration( + statusTableViewCellDelegate: statusTableViewCellDelegate, + timelineMiddleLoaderTableViewCellDelegate: nil, + filterContext: .none, + activeFilters: nil + ) ) - // set empty section to make update animation top-to-bottom style - var snapshot = NSDiffableDataSourceSnapshot<StatusSection, Item>() + var snapshot = NSDiffableDataSourceSnapshot<StatusSection, StatusItem>() snapshot.appendSections([.main]) diffableDataSource?.apply(snapshot) stateMachine.enter(State.Reloading.self) + + statusFetchedResultsController.$records + .receive(on: DispatchQueue.main) + .sink { [weak self] records in + guard let self = self else { return } + guard let diffableDataSource = self.diffableDataSource else { return } + + var snapshot = NSDiffableDataSourceSnapshot<StatusSection, StatusItem>() + snapshot.appendSections([.main]) + + let items = records.map { StatusItem.status(record: $0) } + snapshot.appendItems(items, toSection: .main) + + if let currentState = self.stateMachine.currentState { + switch currentState { + case is State.Reloading, + is State.Loading, + is State.Idle, + is State.Fail: + snapshot.appendItems([.bottomLoader], toSection: .main) + case is State.NoMore: + break + default: + assertionFailure() + break + } + } + + diffableDataSource.applySnapshot(snapshot, animated: false) + } + .store(in: &disposeBag) } } diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+State.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+State.swift index c4420e88b..6c539450c 100644 --- a/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+State.swift +++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+State.swift @@ -11,7 +11,16 @@ import GameplayKit import MastodonSDK extension FavoriteViewModel { - class State: GKState { + class State: GKState, NamingState { + + let logger = Logger(subsystem: "FavoriteViewModel.State", category: "StateMachine") + + let id = UUID() + + var name: String { + String(describing: Self.self) + } + weak var viewModel: FavoriteViewModel? init(viewModel: FavoriteViewModel) { @@ -19,7 +28,18 @@ extension FavoriteViewModel { } 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) + super.didEnter(from: previousState) + let previousState = previousState as? FavoriteViewModel.State + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [\(self.id.uuidString)] enter \(self.name), previous: \(previousState?.name ?? "<nil>")") + } + + @MainActor + func enter(state: State.Type) { + stateMachine?.enter(state) + } + + deinit { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [\(self.id.uuidString)] \(self.name)") } } } @@ -93,6 +113,7 @@ extension FavoriteViewModel.State { class Loading: FavoriteViewModel.State { + // prefer use `maxID` token in response header var maxID: String? override func isValidNextState(_ stateClass: AnyClass) -> Bool { @@ -112,56 +133,49 @@ extension FavoriteViewModel.State { super.didEnter(from: previousState) guard let viewModel = viewModel, let stateMachine = stateMachine else { return } - guard let activeMastodonAuthenticationBox = viewModel.activeMastodonAuthenticationBox.value else { + guard let authenticationBox = viewModel.activeMastodonAuthenticationBox.value else { stateMachine.enter(Fail.self) return } if previousState is Reloading { maxID = nil } - // prefer use `maxID` token in response header - // let maxID = viewModel.statusFetchedResultsController.statusIDs.value.last - viewModel.context.apiService.favoritedStatuses( - maxID: maxID, - mastodonAuthenticationBox: 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) + + Task { + do { + let response = try await viewModel.context.apiService.favoritedStatuses( + maxID: maxID, + authenticationBox: authenticationBox + ) + + var hasNewStatusesAppend = false + var statusIDs = viewModel.statusFetchedResultsController.statusIDs.value + for status in response.value { + guard !statusIDs.contains(status.id) else { continue } + statusIDs.append(status.id) + hasNewStatusesAppend = true + } + + self.maxID = response.link?.maxID + + let hasNextPage: Bool = { + guard let link = response.link else { return true } // assert has more when link invalid + return link.maxID != nil + }() - var hasNewStatusesAppend = false - var statusIDs = viewModel.statusFetchedResultsController.statusIDs.value - for status in response.value { - guard !statusIDs.contains(status.id) else { continue } - statusIDs.append(status.id) - hasNewStatusesAppend = true + if hasNewStatusesAppend && hasNextPage { + await enter(state: Idle.self) + } else { + await enter(state: NoMore.self) + } + viewModel.statusFetchedResultsController.statusIDs.value = statusIDs + } catch { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch user favorites fail: \(error.localizedDescription)") + await enter(state: Fail.self) } - - self.maxID = response.link?.maxID - - let hasNextPage: Bool = { - guard let link = response.link else { return true } // assert has more when link invalid - return link.maxID != nil - }() - - if hasNewStatusesAppend && hasNextPage { - stateMachine.enter(Idle.self) - } else { - stateMachine.enter(NoMore.self) - } - viewModel.statusFetchedResultsController.statusIDs.value = statusIDs - } - .store(in: &viewModel.disposeBag) - } + } // end Task + } // end func } class NoMore: FavoriteViewModel.State { diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewModel.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel.swift index 6b4c1b8cf..150c8f815 100644 --- a/Mastodon/Scene/Profile/Favorite/FavoriteViewModel.swift +++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel.swift @@ -19,10 +19,10 @@ final class FavoriteViewModel { let context: AppContext let activeMastodonAuthenticationBox: CurrentValueSubject<MastodonAuthenticationBox?, Never> let statusFetchedResultsController: StatusFetchedResultsController - let cellFrameCache = NSCache<NSNumber, NSValue>() - + let listBatchFetchViewModel = ListBatchFetchViewModel() + // output - var diffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>? + var diffableDataSource: UITableViewDiffableDataSource<StatusSection, StatusItem>? private(set) lazy var stateMachine: GKStateMachine = { let stateMachine = GKStateMachine(states: [ State.Initial(viewModel: self), @@ -36,14 +36,13 @@ final class FavoriteViewModel { return stateMachine }() - init(context: AppContext) { self.context = context self.activeMastodonAuthenticationBox = CurrentValueSubject(context.authenticationService.activeMastodonAuthenticationBox.value) self.statusFetchedResultsController = StatusFetchedResultsController( managedObjectContext: context.managedObjectContext, domain: nil, - additionalTweetPredicate: Status.notDeleted() + additionalTweetPredicate: nil ) context.authenticationService.activeMastodonAuthenticationBox @@ -54,48 +53,6 @@ final class FavoriteViewModel { .map { $0?.domain } .assign(to: \.value, on: statusFetchedResultsController.domain) .store(in: &disposeBag) - - statusFetchedResultsController.objectIDs - .receive(on: DispatchQueue.main) - .sink { [weak self] objectIDs in - guard let self = self else { return } - guard let diffableDataSource = self.diffableDataSource else { return } - - var items: [Item] = [] - var snapshot = NSDiffableDataSourceSnapshot<StatusSection, Item>() - snapshot.appendSections([.main]) - - defer { - // not animate when empty items fix loader first appear layout issue - diffableDataSource.apply(snapshot, animatingDifferences: !items.isEmpty) - } - - var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:] - let oldSnapshot = diffableDataSource.snapshot() - for item in oldSnapshot.itemIdentifiers { - guard case let .status(objectID, attribute) = item else { continue } - oldSnapshotAttributeDict[objectID] = attribute - } - - for objectID in objectIDs { - let attribute = oldSnapshotAttributeDict[objectID] ?? Item.StatusAttribute() - items.append(.status(objectID: objectID, attribute: attribute)) - } - snapshot.appendItems(items, toSection: .main) - - if let currentState = self.stateMachine.currentState { - switch currentState { - case is State.Reloading, is State.Loading, is State.Idle, is State.Fail: - snapshot.appendItems([.bottomLoader], toSection: .main) - case is State.NoMore: - break - // TODO: handle other states - default: - break - } - } - } - .store(in: &disposeBag) } } diff --git a/Mastodon/Scene/Profile/Follower/FollowerListViewController+DataSourceProvider.swift b/Mastodon/Scene/Profile/Follower/FollowerListViewController+DataSourceProvider.swift new file mode 100644 index 000000000..956cb0704 --- /dev/null +++ b/Mastodon/Scene/Profile/Follower/FollowerListViewController+DataSourceProvider.swift @@ -0,0 +1,34 @@ +// +// FollowerListViewController+DataSourceProvider.swift +// Mastodon +// +// Created by MainasuK on 2022-1-20. +// + +import UIKit + +extension FollowerListViewController: DataSourceProvider { + func item(from source: DataSourceItem.Source) async -> DataSourceItem? { + var _indexPath = source.indexPath + if _indexPath == nil, let cell = source.tableViewCell { + _indexPath = await self.indexPath(for: cell) + } + guard let indexPath = _indexPath else { return nil } + + guard let item = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else { + return nil + } + + switch item { + case .user(let record): + return .user(record: record) + default: + return nil + } + } + + @MainActor + private func indexPath(for cell: UITableViewCell) async -> IndexPath? { + return tableView.indexPath(for: cell) + } +} diff --git a/Mastodon/Scene/Profile/Follower/FollowerListViewController+Provider.swift b/Mastodon/Scene/Profile/Follower/FollowerListViewController+Provider.swift deleted file mode 100644 index 25e102846..000000000 --- a/Mastodon/Scene/Profile/Follower/FollowerListViewController+Provider.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// 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<MastodonUser?, Never> { - Future { promise in - promise(.success(nil)) - } - } - - func mastodonUser(for cell: UITableViewCell?) -> Future<MastodonUser?, Never> { - 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 index 97e62ea8d..68f1d0de1 100644 --- a/Mastodon/Scene/Profile/Follower/FollowerListViewController.swift +++ b/Mastodon/Scene/Profile/Follower/FollowerListViewController.swift @@ -12,11 +12,12 @@ import Combine final class FollowerListViewController: UIViewController, NeedsDependency { - var disposeBag = Set<AnyCancellable>() + let logger = Logger(subsystem: "FollowerListViewController", category: "ViewController") weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + var disposeBag = Set<AnyCancellable>() var viewModel: FollowerListViewModel! lazy var tableView: UITableView = { @@ -43,7 +44,7 @@ extension FollowerListViewController { view.backgroundColor = ThemeService.shared.currentTheme.value.secondarySystemBackgroundColor ThemeService.shared.currentTheme - .receive(on: RunLoop.main) + .receive(on: DispatchQueue.main) .sink { [weak self] theme in guard let self = self else { return } self.view.backgroundColor = theme.secondarySystemBackgroundColor @@ -61,10 +62,19 @@ extension FollowerListViewController { tableView.delegate = self viewModel.setupDiffableDataSource( - for: tableView, - dependency: self + tableView: tableView, + userTableViewCellDelegate: self ) - // TODO: add UserTableViewCellDelegate + + // 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(FollowerListViewModel.State.Loading.self) + } + .store(in: &disposeBag) // trigger user timeline loading Publishers.CombineLatest( @@ -79,29 +89,26 @@ extension FollowerListViewController { .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) + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + tableView.deselectRow(with: transitionCoordinator, animated: animated) } + } - // MARK: - UITableViewDelegate -extension FollowerListViewController: UITableViewDelegate { +extension FollowerListViewController: UITableViewDelegate, AutoGenerateTableViewDelegate { + // sourcery:inline:FollowerListViewController.AutoGenerateTableViewDelegate + + // Generated using Sourcery + // DO NOT EDIT func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - handleTableView(tableView, didSelectRowAt: indexPath) + aspectTableView(tableView, didSelectRowAt: indexPath) } + + // sourcery:end + } // MARK: - UserTableViewCellDelegate diff --git a/Mastodon/Scene/Profile/Follower/FollowerListViewModel+Diffable.swift b/Mastodon/Scene/Profile/Follower/FollowerListViewModel+Diffable.swift index fc9f31779..15cc1be13 100644 --- a/Mastodon/Scene/Profile/Follower/FollowerListViewModel+Diffable.swift +++ b/Mastodon/Scene/Profile/Follower/FollowerListViewModel+Diffable.swift @@ -6,16 +6,20 @@ // import UIKit +import MastodonAsset +import MastodonLocalization extension FollowerListViewModel { func setupDiffableDataSource( - for tableView: UITableView, - dependency: NeedsDependency + tableView: UITableView, + userTableViewCellDelegate: UserTableViewCellDelegate? ) { - diffableDataSource = UserSection.tableViewDiffableDataSource( - for: tableView, - dependency: dependency, - managedObjectContext: userFetchedResultsController.fetchedResultsController.managedObjectContext + diffableDataSource = UserSection.diffableDataSource( + tableView: tableView, + context: context, + configuration: UserSection.Configuration( + userTableViewCellDelegate: userTableViewCellDelegate + ) ) // workaround to append loader wrong animation issue @@ -30,17 +34,15 @@ extension FollowerListViewModel { diffableDataSource?.apply(snapshot, animatingDifferences: false) } - userFetchedResultsController.objectIDs + userFetchedResultsController.$records .receive(on: DispatchQueue.main) - .sink { [weak self] objectIDs in + .sink { [weak self] records in guard let self = self else { return } guard let diffableDataSource = self.diffableDataSource else { return } var snapshot = NSDiffableDataSourceSnapshot<UserSection, UserItem>() snapshot.appendSections([.main]) - let items: [UserItem] = objectIDs.map { - UserItem.follower(objectID: $0) - } + let items = records.map { UserItem.user(record: $0) } snapshot.appendItems(items, toSection: .main) if let currentState = self.stateMachine.currentState { @@ -59,7 +61,7 @@ extension FollowerListViewModel { } } - diffableDataSource.apply(snapshot) + diffableDataSource.apply(snapshot, animatingDifferences: false) } .store(in: &disposeBag) } diff --git a/Mastodon/Scene/Profile/Follower/FollowerListViewModel+State.swift b/Mastodon/Scene/Profile/Follower/FollowerListViewModel+State.swift index 43e532673..a2958de3c 100644 --- a/Mastodon/Scene/Profile/Follower/FollowerListViewModel+State.swift +++ b/Mastodon/Scene/Profile/Follower/FollowerListViewModel+State.swift @@ -11,7 +11,16 @@ import GameplayKit import MastodonSDK extension FollowerListViewModel { - class State: GKState { + class State: GKState, NamingState { + + let logger = Logger(subsystem: "FollowerListViewModel.State", category: "StateMachine") + + let id = UUID() + + var name: String { + String(describing: Self.self) + } + weak var viewModel: FollowerListViewModel? init(viewModel: FollowerListViewModel) { @@ -19,7 +28,18 @@ extension FollowerListViewModel { } 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) + super.didEnter(from: previousState) + let previousState = previousState as? FollowerListViewModel.State + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [\(self.id.uuidString)] enter \(self.name), previous: \(previousState?.name ?? "<nil>")") + } + + @MainActor + func enter(state: State.Type) { + stateMachine?.enter(state) + } + + deinit { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [\(self.id.uuidString)] \(self.name)") } } } @@ -52,7 +72,7 @@ extension FollowerListViewModel.State { guard let viewModel = viewModel, let stateMachine = stateMachine else { return } // reset - viewModel.userFetchedResultsController.userIDs.value = [] + viewModel.userFetchedResultsController.userIDs = [] stateMachine.enter(Loading.self) } @@ -123,47 +143,44 @@ extension FollowerListViewModel.State { return } - guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { + guard let authenticationBox = 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 + + Task { + do { + let response = try await viewModel.context.apiService.followers( + userID: userID, + maxID: maxID, + authenticationBox: authenticationBox + ) + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch \(response.value.count) followers") + + var hasNewAppend = false + var userIDs = viewModel.userFetchedResultsController.userIDs + 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 { + await enter(state: Idle.self) + } else { + await enter(state: NoMore.self) + } + + self.maxID = maxID + viewModel.userFetchedResultsController.userIDs = userIDs + + } catch { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch follower fail: \(error.localizedDescription)") + await enter(state: Fail.self) } - } 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 Task } // end func didEnter } diff --git a/Mastodon/Scene/Profile/Follower/FollowerListViewModel.swift b/Mastodon/Scene/Profile/Follower/FollowerListViewModel.swift index f62441cf1..80f26e608 100644 --- a/Mastodon/Scene/Profile/Follower/FollowerListViewModel.swift +++ b/Mastodon/Scene/Profile/Follower/FollowerListViewModel.swift @@ -22,7 +22,8 @@ final class FollowerListViewModel { let domain: CurrentValueSubject<String?, Never> let userID: CurrentValueSubject<String?, Never> let userFetchedResultsController: UserFetchedResultsController - + let listBatchFetchViewModel = ListBatchFetchViewModel() + // output var diffableDataSource: UITableViewDiffableDataSource<UserSection, UserItem>? private(set) lazy var stateMachine: GKStateMachine = { @@ -43,7 +44,7 @@ final class FollowerListViewModel { self.userFetchedResultsController = UserFetchedResultsController( managedObjectContext: context.managedObjectContext, domain: domain, - additionalTweetPredicate: nil + additionalPredicate: nil ) self.domain = CurrentValueSubject(domain) self.userID = CurrentValueSubject(userID) diff --git a/Mastodon/Scene/Profile/Following/FollowingListViewController+DataSourceProvider.swift b/Mastodon/Scene/Profile/Following/FollowingListViewController+DataSourceProvider.swift new file mode 100644 index 000000000..3ea2a74c1 --- /dev/null +++ b/Mastodon/Scene/Profile/Following/FollowingListViewController+DataSourceProvider.swift @@ -0,0 +1,34 @@ +// +// FollowingListViewController+DataSourceProvider.swift +// Mastodon +// +// Created by MainasuK on 2022-1-20. +// + +import UIKit + +extension FollowingListViewController: DataSourceProvider { + func item(from source: DataSourceItem.Source) async -> DataSourceItem? { + var _indexPath = source.indexPath + if _indexPath == nil, let cell = source.tableViewCell { + _indexPath = await self.indexPath(for: cell) + } + guard let indexPath = _indexPath else { return nil } + + guard let item = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else { + return nil + } + + switch item { + case .user(let record): + return .user(record: record) + default: + return nil + } + } + + @MainActor + private func indexPath(for cell: UITableViewCell) async -> IndexPath? { + return tableView.indexPath(for: cell) + } +} diff --git a/Mastodon/Scene/Profile/Following/FollowingListViewController+Provider.swift b/Mastodon/Scene/Profile/Following/FollowingListViewController+Provider.swift deleted file mode 100644 index aaeb52328..000000000 --- a/Mastodon/Scene/Profile/Following/FollowingListViewController+Provider.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// 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<MastodonUser?, Never> { - Future { promise in - promise(.success(nil)) - } - } - - func mastodonUser(for cell: UITableViewCell?) -> Future<MastodonUser?, Never> { - 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 index 35691b82d..7272a2db4 100644 --- a/Mastodon/Scene/Profile/Following/FollowingListViewController.swift +++ b/Mastodon/Scene/Profile/Following/FollowingListViewController.swift @@ -11,12 +11,13 @@ import GameplayKit import Combine final class FollowingListViewController: UIViewController, NeedsDependency { - - var disposeBag = Set<AnyCancellable>() + + let logger = Logger(subsystem: "FollowingListViewController", category: "ViewController") weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + var disposeBag = Set<AnyCancellable>() var viewModel: FollowingListViewModel! lazy var tableView: UITableView = { @@ -43,7 +44,7 @@ extension FollowingListViewController { view.backgroundColor = ThemeService.shared.currentTheme.value.secondarySystemBackgroundColor ThemeService.shared.currentTheme - .receive(on: RunLoop.main) + .receive(on: DispatchQueue.main) .sink { [weak self] theme in guard let self = self else { return } self.view.backgroundColor = theme.secondarySystemBackgroundColor @@ -61,10 +62,19 @@ extension FollowingListViewController { tableView.delegate = self viewModel.setupDiffableDataSource( - for: tableView, - dependency: self + tableView: tableView, + userTableViewCellDelegate: self ) - // TODO: add UserTableViewCellDelegate + + // 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(FollowingListViewModel.State.Loading.self) + } + .store(in: &disposeBag) // trigger user timeline loading Publishers.CombineLatest( @@ -81,27 +91,17 @@ extension FollowingListViewController { } -// 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 { +extension FollowingListViewController: UITableViewDelegate, AutoGenerateTableViewDelegate { + // sourcery:inline:FollowingListViewController.AutoGenerateTableViewDelegate + + // Generated using Sourcery + // DO NOT EDIT func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - handleTableView(tableView, didSelectRowAt: indexPath) + aspectTableView(tableView, didSelectRowAt: indexPath) } + + // sourcery:end } // MARK: - UserTableViewCellDelegate diff --git a/Mastodon/Scene/Profile/Following/FollowingListViewModel+Diffable.swift b/Mastodon/Scene/Profile/Following/FollowingListViewModel+Diffable.swift index dc6f1f6fd..116e7567c 100644 --- a/Mastodon/Scene/Profile/Following/FollowingListViewModel+Diffable.swift +++ b/Mastodon/Scene/Profile/Following/FollowingListViewModel+Diffable.swift @@ -6,16 +6,20 @@ // import UIKit +import MastodonAsset +import MastodonLocalization extension FollowingListViewModel { func setupDiffableDataSource( - for tableView: UITableView, - dependency: NeedsDependency + tableView: UITableView, + userTableViewCellDelegate: UserTableViewCellDelegate? ) { - diffableDataSource = UserSection.tableViewDiffableDataSource( - for: tableView, - dependency: dependency, - managedObjectContext: userFetchedResultsController.fetchedResultsController.managedObjectContext + diffableDataSource = UserSection.diffableDataSource( + tableView: tableView, + context: context, + configuration: UserSection.Configuration( + userTableViewCellDelegate: userTableViewCellDelegate + ) ) // workaround to append loader wrong animation issue @@ -30,17 +34,15 @@ extension FollowingListViewModel { diffableDataSource?.apply(snapshot, animatingDifferences: false) } - userFetchedResultsController.objectIDs + userFetchedResultsController.$records .receive(on: DispatchQueue.main) - .sink { [weak self] objectIDs in + .sink { [weak self] records in guard let self = self else { return } guard let diffableDataSource = self.diffableDataSource else { return } var snapshot = NSDiffableDataSourceSnapshot<UserSection, UserItem>() snapshot.appendSections([.main]) - let items: [UserItem] = objectIDs.map { - UserItem.following(objectID: $0) - } + let items = records.map { UserItem.user(record: $0) } snapshot.appendItems(items, toSection: .main) if let currentState = self.stateMachine.currentState { @@ -59,7 +61,7 @@ extension FollowingListViewModel { } } - diffableDataSource.apply(snapshot) + diffableDataSource.apply(snapshot, animatingDifferences: false) } .store(in: &disposeBag) } diff --git a/Mastodon/Scene/Profile/Following/FollowingListViewModel+State.swift b/Mastodon/Scene/Profile/Following/FollowingListViewModel+State.swift index 0ec3d6262..c01a9c8c6 100644 --- a/Mastodon/Scene/Profile/Following/FollowingListViewModel+State.swift +++ b/Mastodon/Scene/Profile/Following/FollowingListViewModel+State.swift @@ -11,7 +11,16 @@ import GameplayKit import MastodonSDK extension FollowingListViewModel { - class State: GKState { + class State: GKState, NamingState { + + let logger = Logger(subsystem: "FollowingListViewModel.State", category: "StateMachine") + + let id = UUID() + + var name: String { + String(describing: Self.self) + } + weak var viewModel: FollowingListViewModel? init(viewModel: FollowingListViewModel) { @@ -19,7 +28,18 @@ extension FollowingListViewModel { } 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) + super.didEnter(from: previousState) + let previousState = previousState as? FollowingListViewModel.State + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [\(self.id.uuidString)] enter \(self.name), previous: \(previousState?.name ?? "<nil>")") + } + + @MainActor + func enter(state: State.Type) { + stateMachine?.enter(state) + } + + deinit { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [\(self.id.uuidString)] \(self.name)") } } } @@ -52,7 +72,7 @@ extension FollowingListViewModel.State { guard let viewModel = viewModel, let stateMachine = stateMachine else { return } // reset - viewModel.userFetchedResultsController.userIDs.value = [] + viewModel.userFetchedResultsController.userIDs = [] stateMachine.enter(Loading.self) } @@ -123,30 +143,23 @@ extension FollowingListViewModel.State { return } - guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { + guard let authenticationBox = 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) + Task { + do { + let response = try await viewModel.context.apiService.following( + userID: userID, + maxID: maxID, + authenticationBox: authenticationBox + ) + + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch \(response.value.count)") var hasNewAppend = false - var userIDs = viewModel.userFetchedResultsController.userIDs.value + var userIDs = viewModel.userFetchedResultsController.userIDs for user in response.value { guard !userIDs.contains(user.id) else { continue } userIDs.append(user.id) @@ -156,14 +169,18 @@ extension FollowingListViewModel.State { let maxID = response.link?.maxID if hasNewAppend, maxID != nil { - stateMachine.enter(Idle.self) + await enter(state: Idle.self) } else { - stateMachine.enter(NoMore.self) + await enter(state: NoMore.self) } self.maxID = maxID - viewModel.userFetchedResultsController.userIDs.value = userIDs + viewModel.userFetchedResultsController.userIDs = userIDs + + } catch { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch following fail: \(error.localizedDescription)") + await enter(state: Fail.self) } - .store(in: &viewModel.disposeBag) + } // end Task } // end func didEnter } diff --git a/Mastodon/Scene/Profile/Following/FollowingListViewModel.swift b/Mastodon/Scene/Profile/Following/FollowingListViewModel.swift index 0677f6cb4..f1e07f9d8 100644 --- a/Mastodon/Scene/Profile/Following/FollowingListViewModel.swift +++ b/Mastodon/Scene/Profile/Following/FollowingListViewModel.swift @@ -22,6 +22,7 @@ final class FollowingListViewModel { let domain: CurrentValueSubject<String?, Never> let userID: CurrentValueSubject<String?, Never> let userFetchedResultsController: UserFetchedResultsController + let listBatchFetchViewModel = ListBatchFetchViewModel() // output var diffableDataSource: UITableViewDiffableDataSource<UserSection, UserItem>? @@ -43,7 +44,7 @@ final class FollowingListViewModel { self.userFetchedResultsController = UserFetchedResultsController( managedObjectContext: context.managedObjectContext, domain: domain, - additionalTweetPredicate: nil + additionalPredicate: nil ) self.domain = CurrentValueSubject(domain) self.userID = CurrentValueSubject(userID) diff --git a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift index 34716dde5..de6ad5415 100644 --- a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift +++ b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift @@ -13,18 +13,18 @@ import AlamofireImage import CropViewController import MastodonMeta import MetaTextKit +import MastodonAsset +import MastodonLocalization +import Tabman protocol ProfileHeaderViewControllerDelegate: AnyObject { func profileHeaderViewController(_ viewController: ProfileHeaderViewController, viewLayoutDidUpdate view: UIView) - func profileHeaderViewController(_ viewController: ProfileHeaderViewController, pageSegmentedControlValueChanged segmentedControl: UISegmentedControl, selectedSegmentIndex index: Int) - func profileHeaderViewController(_ viewController: ProfileHeaderViewController, profileFieldCollectionViewCell: ProfileFieldCollectionViewCell, metaLabel: MetaLabel, didSelectMeta meta: Meta) } final class ProfileHeaderViewController: UIViewController { - static let segmentedControlHeight: CGFloat = 32 - static let segmentedControlMarginHeight: CGFloat = 20 - static let headerMinHeight: CGFloat = segmentedControlHeight + 2 * segmentedControlMarginHeight + static let segmentedControlHeight: CGFloat = 50 + static let headerMinHeight: CGFloat = segmentedControlHeight var disposeBag = Set<AnyCancellable>() weak var delegate: ProfileHeaderViewControllerDelegate? @@ -43,12 +43,36 @@ final class ProfileHeaderViewController: UIViewController { }() let profileHeaderView = ProfileHeaderView() - let pageSegmentedControl: UISegmentedControl = { - let segmentedControl = UISegmentedControl(items: ["A", "B"]) - segmentedControl.selectedSegmentIndex = 0 - return segmentedControl + + let buttonBar: TMBar.ButtonBar = { + let buttonBar = TMBar.ButtonBar() + buttonBar.indicator.backgroundColor = Asset.Colors.Label.primary.color + buttonBar.backgroundView.style = .clear + buttonBar.layout.contentInset = .zero + return buttonBar }() - var pageSegmentedControlLeadingLayoutConstraint: NSLayoutConstraint! + + func customizeButtonBarAppearance() { + // The implmention use CATextlayer. Adapt for Dark Mode without dynamic colors + // Needs trigger update when `userInterfaceStyle` chagnes + let userInterfaceStyle = traitCollection.userInterfaceStyle + buttonBar.buttons.customize { button in + switch userInterfaceStyle { + case .dark: + // Asset.Colors.Label.primary.color + button.selectedTintColor = UIColor(red: 238.0/255.0, green: 238.0/255.0, blue: 238.0/255.0, alpha: 1.0) + // Asset.Colors.Label.secondary.color + button.tintColor = UIColor(red: 151.0/255.0, green: 157.0/255.0, blue: 173.0/255.0, alpha: 1.0) + default: + // Asset.Colors.Label.primary.color + button.selectedTintColor = UIColor(red: 40.0/255.0, green: 44.0/255.0, blue: 55.0/255.0, alpha: 1.0) + // Asset.Colors.Label.secondary.color + button.tintColor = UIColor(red: 60.0/255.0, green: 60.0/255.0, blue: 67.0/255.0, alpha: 0.6) + } + + button.backgroundColor = .clear + } + } private var isBannerPinned = false private var bottomShadowAlpha: CGFloat = 0.0 @@ -88,13 +112,15 @@ extension ProfileHeaderViewController { override func viewDidLoad() { super.viewDidLoad() + + customizeButtonBarAppearance() - view.backgroundColor = ThemeService.shared.currentTheme.value.systemGroupedBackgroundColor + view.backgroundColor = ThemeService.shared.currentTheme.value.systemBackgroundColor ThemeService.shared.currentTheme - .receive(on: RunLoop.main) + .receive(on: DispatchQueue.main) .sink { [weak self] theme in guard let self = self else { return } - self.view.backgroundColor = theme.systemGroupedBackgroundColor + self.view.backgroundColor = theme.systemBackgroundColor } .store(in: &disposeBag) @@ -106,30 +132,7 @@ extension ProfileHeaderViewController { profileHeaderView.trailingAnchor.constraint(equalTo: view.trailingAnchor), ]) profileHeaderView.preservesSuperviewLayoutMargins = true - - profileHeaderView.fieldCollectionView.delegate = self - viewModel.setupProfileFieldCollectionViewDiffableDataSource( - collectionView: profileHeaderView.fieldCollectionView, - profileFieldCollectionViewCellDelegate: self, - profileFieldAddEntryCollectionViewCellDelegate: self - ) - - let longPressReorderGesture = UILongPressGestureRecognizer(target: self, action: #selector(ProfileHeaderViewController.longPressReorderGestureHandler(_:))) - profileHeaderView.fieldCollectionView.addGestureRecognizer(longPressReorderGesture) - - pageSegmentedControl.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(pageSegmentedControl) - pageSegmentedControlLeadingLayoutConstraint = pageSegmentedControl.leadingAnchor.constraint(equalTo: view.leadingAnchor) - NSLayoutConstraint.activate([ - pageSegmentedControl.topAnchor.constraint(equalTo: profileHeaderView.bottomAnchor, constant: ProfileHeaderViewController.segmentedControlMarginHeight), - 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), - ]) - - pageSegmentedControl.addTarget(self, action: #selector(ProfileHeaderViewController.pageSegmentedControlValueChanged(_:)), for: .valueChanged) - + Publishers.CombineLatest( viewModel.viewDidAppear.eraseToAnyPublisher(), viewModel.isTitleViewContentOffsetSet.eraseToAnyPublisher() @@ -151,37 +154,31 @@ extension ProfileHeaderViewController { .store(in: &disposeBag) Publishers.CombineLatest4( - viewModel.isEditing.eraseToAnyPublisher(), - viewModel.displayProfileInfo.avatarImageResource.eraseToAnyPublisher(), - viewModel.editProfileInfo.avatarImageResource.eraseToAnyPublisher(), + viewModel.$isEditing.eraseToAnyPublisher(), + viewModel.displayProfileInfo.$avatarImageResource.eraseToAnyPublisher(), + viewModel.editProfileInfo.$avatarImageResource.eraseToAnyPublisher(), viewModel.viewDidAppear.eraseToAnyPublisher() ) .receive(on: DispatchQueue.main) - .sink { [weak self] isEditing, resource, editingResource, _ in + .sink { [weak self] isEditing, displayResource, editingResource, _ in guard let self = self else { return } - let url: URL? = { - guard case let .url(url) = resource else { return nil } - return url - - }() - let image: UIImage? = { - guard case let .image(image) = editingResource else { return nil } - return image - }() - self.profileHeaderView.configure( - with: AvatarConfigurableViewConfiguration( - avatarImageURL: image == nil ? url : nil, // set only when image empty - placeholderImage: image, - keepImageCorner: true // fit preview transitioning + + let url = displayResource.url + let image = editingResource.image + + self.profileHeaderView.avatarButton.avatarImageView.configure( + configuration: AvatarImageView.Configuration( + url: isEditing && image != nil ? nil : url, + placeholder: image ?? UIImage.placeholder(color: Asset.Theme.Mastodon.systemGroupedBackground.color) ) ) } .store(in: &disposeBag) Publishers.CombineLatest4( - viewModel.isEditing, - viewModel.displayProfileInfo.name.removeDuplicates(), - viewModel.editProfileInfo.name.removeDuplicates(), - viewModel.emojiMeta + viewModel.$isEditing, + viewModel.displayProfileInfo.$name.removeDuplicates(), + viewModel.editProfileInfo.$name.removeDuplicates(), + viewModel.$emojiMeta ) .receive(on: DispatchQueue.main) .sink { [weak self] isEditing, name, editingName, emojiMeta in @@ -198,13 +195,13 @@ extension ProfileHeaderViewController { .store(in: &disposeBag) let profileNote = Publishers.CombineLatest3( - viewModel.isEditing.removeDuplicates(), - viewModel.displayProfileInfo.note.removeDuplicates(), + viewModel.$isEditing.removeDuplicates(), + viewModel.displayProfileInfo.$note.removeDuplicates(), viewModel.editProfileInfoDidInitialized ) .map { isEditing, displayNote, _ -> String? in if isEditing { - return self.viewModel.editProfileInfo.note.value + return self.viewModel.editProfileInfo.note } else { return displayNote } @@ -212,9 +209,9 @@ extension ProfileHeaderViewController { .eraseToAnyPublisher() Publishers.CombineLatest3( - viewModel.isEditing.removeDuplicates(), + viewModel.$isEditing.removeDuplicates(), profileNote.removeDuplicates(), - viewModel.emojiMeta.removeDuplicates() + viewModel.$emojiMeta.removeDuplicates() ) .receive(on: DispatchQueue.main) .sink { [weak self] isEditing, note, emojiMeta in @@ -245,26 +242,10 @@ extension ProfileHeaderViewController { .sink { [weak self] notification in guard let self = self else { return } guard let textField = notification.object as? UITextField else { return } - self.viewModel.editProfileInfo.name.value = textField.text + self.viewModel.editProfileInfo.name = textField.text } .store(in: &disposeBag) - Publishers.CombineLatest3( - viewModel.isEditing, - viewModel.displayProfileInfo.fields, - viewModel.needsFiledCollectionViewHidden - ) - .receive(on: RunLoop.main) - .sink { [weak self] isEditing, fields, needsHidden in - guard let self = self else { return } - guard !needsHidden else { - self.profileHeaderView.fieldCollectionView.isHidden = true - return - } - self.profileHeaderView.fieldCollectionView.isHidden = isEditing ? false : fields.isEmpty - } - .store(in: &disposeBag) - profileHeaderView.editAvatarButton.menu = createAvatarContextMenu() profileHeaderView.editAvatarButton.showsMenuAsPrimaryAction = true } @@ -285,11 +266,10 @@ extension ProfileHeaderViewController { setupBottomShadow() } - override func viewLayoutMarginsDidChange() { - super.viewLayoutMarginsDidChange() - - let margin = view.frame.maxX - view.readableContentGuide.layoutFrame.maxX - pageSegmentedControlLeadingLayoutConstraint.constant = margin + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + customizeButtonBarAppearance() } } @@ -335,57 +315,6 @@ extension ProfileHeaderViewController { } } -extension ProfileHeaderViewController { - - @objc private func pageSegmentedControlValueChanged(_ sender: UISegmentedControl) { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: selectedSegmentIndex: %ld", ((#file as NSString).lastPathComponent), #line, #function, sender.selectedSegmentIndex) - delegate?.profileHeaderViewController(self, pageSegmentedControlValueChanged: sender, selectedSegmentIndex: sender.selectedSegmentIndex) - } - - // seealso: ProfileHeaderViewModel.setupProfileFieldCollectionViewDiffableDataSource(…) - @objc private func longPressReorderGestureHandler(_ sender: UILongPressGestureRecognizer) { - guard sender.view === profileHeaderView.fieldCollectionView else { - assertionFailure() - return - } - let collectionView = profileHeaderView.fieldCollectionView - switch(sender.state) { - case .began: - guard let selectedIndexPath = collectionView.indexPathForItem(at: sender.location(in: collectionView)), - let cell = collectionView.cellForItem(at: selectedIndexPath) as? ProfileFieldCollectionViewCell else { - break - } - // check if pressing reorder bar no not - let locationInCell = sender.location(in: cell.reorderBarImageView) - guard cell.reorderBarImageView.bounds.contains(locationInCell) else { - return - } - - collectionView.beginInteractiveMovementForItem(at: selectedIndexPath) - case .changed: - guard let selectedIndexPath = collectionView.indexPathForItem(at: sender.location(in: collectionView)), - let diffableDataSource = viewModel.fieldDiffableDataSource else { - break - } - guard let item = diffableDataSource.itemIdentifier(for: selectedIndexPath), - case .field = 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() - default: - collectionView.cancelInteractiveMovement() - } - } - -} - extension ProfileHeaderViewController { func updateHeaderContainerSafeAreaInset(_ inset: UIEdgeInsets) { @@ -454,26 +383,23 @@ extension ProfileHeaderViewController { if viewModel.viewDidAppear.value { viewModel.isTitleViewContentOffsetSet.value = true } - + // set avatar fade if progress > 0 { - setProfileBannerFade(alpha: 0) + setProfileAvatar(alpha: 0) } else if progress > -abs(throttle) { // y = -(1/0.8T)x let alpha = -1 / abs(0.8 * throttle) * progress - setProfileBannerFade(alpha: alpha) + setProfileAvatar(alpha: alpha) } else { - setProfileBannerFade(alpha: 1) + setProfileAvatar(alpha: 1) } } - private func setProfileBannerFade(alpha: CGFloat) { + private func setProfileAvatar(alpha: CGFloat) { profileHeaderView.avatarImageViewBackgroundView.alpha = alpha - profileHeaderView.avatarImageView.alpha = alpha + profileHeaderView.avatarButton.alpha = alpha profileHeaderView.editAvatarBackgroundView.alpha = alpha - profileHeaderView.nameTextFieldBackgroundView.alpha = alpha - profileHeaderView.displayNameStackView.alpha = alpha - profileHeaderView.usernameLabel.alpha = alpha } } @@ -485,8 +411,8 @@ extension ProfileHeaderViewController: MetaTextDelegate { switch metaText { case profileHeaderView.bioMetaText: - guard viewModel.isEditing.value else { break } - viewModel.editProfileInfo.note.value = metaText.backedString + guard viewModel.isEditing else { break } + viewModel.editProfileInfo.note = metaText.backedString let metaContent = PlaintextMetaContent(string: metaText.backedString) return metaContent default: @@ -558,35 +484,7 @@ extension ProfileHeaderViewController: UIDocumentPickerDelegate { // MARK: - CropViewControllerDelegate extension ProfileHeaderViewController: CropViewControllerDelegate { public func cropViewController(_ cropViewController: CropViewController, didCropToImage image: UIImage, withRect cropRect: CGRect, angle: Int) { - viewModel.editProfileInfo.avatarImageResource.value = .image(image) + viewModel.editProfileInfo.avatarImage = image cropViewController.dismiss(animated: true, completion: nil) } } - -// MARK: - UICollectionViewDelegate -extension ProfileHeaderViewController: UICollectionViewDelegate { - -} - -// MARK: - ProfileFieldCollectionViewCellDelegate -extension ProfileHeaderViewController: ProfileFieldCollectionViewCellDelegate { - - // should be remove style edit button - func profileFieldCollectionViewCell(_ cell: ProfileFieldCollectionViewCell, editButtonDidPressed button: UIButton) { - guard let diffableDataSource = viewModel.fieldDiffableDataSource else { return } - guard let indexPath = profileHeaderView.fieldCollectionView.indexPath(for: cell) else { return } - guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } - viewModel.removeFieldItem(item: item) - } - - func profileFieldCollectionViewCell(_ cell: ProfileFieldCollectionViewCell, metaLebel: MetaLabel, didSelectMeta meta: Meta) { - delegate?.profileHeaderViewController(self, profileFieldCollectionViewCell: cell, metaLabel: metaLebel, didSelectMeta: meta) - } -} - -// MARK: - ProfileFieldAddEntryCollectionViewCellDelegate -extension ProfileHeaderViewController: ProfileFieldAddEntryCollectionViewCellDelegate { - func ProfileFieldAddEntryCollectionViewCellDidPressed(_ cell: ProfileFieldAddEntryCollectionViewCell) { - viewModel.appendFieldItem() - } -} diff --git a/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel+Diffable.swift b/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel+Diffable.swift deleted file mode 100644 index b02eaa614..000000000 --- a/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel+Diffable.swift +++ /dev/null @@ -1,43 +0,0 @@ -// -// ProfileHeaderViewModel+Diffable.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-5-25. -// - -import UIKit - -extension ProfileHeaderViewModel { - func setupProfileFieldCollectionViewDiffableDataSource( - collectionView: UICollectionView, - profileFieldCollectionViewCellDelegate: ProfileFieldCollectionViewCellDelegate, - profileFieldAddEntryCollectionViewCellDelegate: ProfileFieldAddEntryCollectionViewCellDelegate - ) { - let diffableDataSource = ProfileFieldSection.collectionViewDiffableDataSource( - for: collectionView, - profileFieldCollectionViewCellDelegate: profileFieldCollectionViewCellDelegate, - profileFieldAddEntryCollectionViewCellDelegate: profileFieldAddEntryCollectionViewCellDelegate - ) - - diffableDataSource.reorderingHandlers.canReorderItem = { item in - switch item { - case .field: return true - default: return false - } - } - - diffableDataSource.reorderingHandlers.didReorder = { [weak self] transaction in - guard let self = self else { return } - - let items = transaction.finalSnapshot.itemIdentifiers - var fieldValues: [ProfileFieldItem.FieldValue] = [] - for item in items { - guard case let .field(field, _) = item else { continue } - fieldValues.append(field) - } - self.editProfileInfo.fields.value = fieldValues - } - - fieldDiffableDataSource = diffableDataSource - } -} diff --git a/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift b/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift index e8405b6ad..7f7b0dd00 100644 --- a/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift +++ b/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift @@ -14,132 +14,76 @@ import MastodonMeta final class ProfileHeaderViewModel { + static let avatarImageMaxSizeInPixel = CGSize(width: 400, height: 400) static let maxProfileFieldCount = 4 var disposeBag = Set<AnyCancellable>() // input let context: AppContext - let isEditing = CurrentValueSubject<Bool, Never>(false) + @Published var isEditing = false + @Published var accountForEdit: Mastodon.Entity.Account? + @Published var emojiMeta: MastodonContent.Emojis = [:] + let viewDidAppear = CurrentValueSubject<Bool, Never>(false) let needsSetupBottomShadow = CurrentValueSubject<Bool, Never>(true) let needsFiledCollectionViewHidden = CurrentValueSubject<Bool, Never>(false) let isTitleViewContentOffsetSet = CurrentValueSubject<Bool, Never>(false) - let emojiMeta = CurrentValueSubject<MastodonContent.Emojis, Never>([:]) - let accountForEdit = CurrentValueSubject<Mastodon.Entity.Account?, Never>(nil) // output + let isTitleViewDisplaying = CurrentValueSubject<Bool, Never>(false) let displayProfileInfo = ProfileInfo() let editProfileInfo = ProfileInfo() let editProfileInfoDidInitialized = CurrentValueSubject<Void, Never>(Void()) // needs trigger initial event - let isTitleViewDisplaying = CurrentValueSubject<Bool, Never>(false) - var fieldDiffableDataSource: UICollectionViewDiffableDataSource<ProfileFieldSection, ProfileFieldItem>! - + init(context: AppContext) { self.context = context - + Publishers.CombineLatest( - isEditing.removeDuplicates(), // only trigger when value toggle - accountForEdit + $isEditing.removeDuplicates(), // only trigger when value toggle + $accountForEdit ) .receive(on: DispatchQueue.main) .sink { [weak self] isEditing, account in guard let self = self else { return } guard isEditing else { return } // setup editing value when toggle to editing - self.editProfileInfo.name.value = self.displayProfileInfo.name.value // set to name - self.editProfileInfo.avatarImageResource.value = .image(nil) // set to empty - self.editProfileInfo.note.value = ProfileHeaderViewModel.normalize(note: self.displayProfileInfo.note.value) - self.editProfileInfo.fields.value = account?.source?.fields?.compactMap { field in - ProfileFieldItem.FieldValue(name: field.name, value: field.value) - } ?? [] + self.editProfileInfo.name = self.displayProfileInfo.name // set to name + self.editProfileInfo.avatarImage = nil // set to empty + self.editProfileInfo.note = ProfileHeaderViewModel.normalize(note: self.displayProfileInfo.note) self.editProfileInfoDidInitialized.send() } .store(in: &disposeBag) - - Publishers.CombineLatest4( - isEditing.removeDuplicates(), - displayProfileInfo.fields.removeDuplicates(), - editProfileInfo.fields.removeDuplicates(), - emojiMeta.removeDuplicates() - ) - .receive(on: RunLoop.main) - .sink { [weak self] isEditing, displayFields, editingFields, emojiMeta in - guard let self = self else { return } - guard let diffableDataSource = self.fieldDiffableDataSource else { return } - - var snapshot = NSDiffableDataSourceSnapshot<ProfileFieldSection, ProfileFieldItem>() - snapshot.appendSections([.main]) - - let oldSnapshot = diffableDataSource.snapshot() - let oldFieldAttributeDict: [UUID: ProfileFieldItem.FieldItemAttribute] = { - var dict: [UUID: ProfileFieldItem.FieldItemAttribute] = [:] - for item in oldSnapshot.itemIdentifiers { - switch item { - case .field(let field, let attribute): - dict[field.id] = attribute - default: - continue - } - } - return dict - }() - let fields: [ProfileFieldItem.FieldValue] = isEditing ? editingFields : displayFields - var items = fields.map { field -> ProfileFieldItem in - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: process field item ID: %s", ((#file as NSString).lastPathComponent), #line, #function, field.id.uuidString) - - let attribute = oldFieldAttributeDict[field.id] ?? ProfileFieldItem.FieldItemAttribute() - attribute.isEditing = isEditing - attribute.emojiMeta.value = emojiMeta - attribute.isLast = false - return ProfileFieldItem.field(field: field, attribute: attribute) - } - - if isEditing, fields.count < ProfileHeaderViewModel.maxProfileFieldCount { - items.append(.addEntry(attribute: ProfileFieldItem.AddEntryItemAttribute())) - } - - if let last = items.last?.listSeparatorLineConfigurable { - last.isLast = true - } - - snapshot.appendItems(items, toSection: .main) - - diffableDataSource.apply(snapshot, animatingDifferences: false, completion: nil) - } - .store(in: &disposeBag) } } extension ProfileHeaderViewModel { - struct ProfileInfo { - let name = CurrentValueSubject<String?, Never>(nil) - let avatarImageResource = CurrentValueSubject<ImageResource?, Never>(nil) - let note = CurrentValueSubject<String?, Never>(nil) - let fields = CurrentValueSubject<[ProfileFieldItem.FieldValue], Never>([]) + class ProfileInfo { + // input + @Published var name: String? + @Published var avatarImageURL: URL? + @Published var avatarImage: UIImage? + @Published var note: String? - enum ImageResource { - case url(URL?) - case image(UIImage?) + // output + @Published var avatarImageResource = ImageResource(url: nil, image: nil) + + struct ImageResource { + let url: URL? + let image: UIImage? + } + + init() { + Publishers.CombineLatest( + $avatarImageURL, + $avatarImage + ) + .map { url, image in + ImageResource(url: url, image: image) + } + .assign(to: &$avatarImageResource) } - } -} - -extension ProfileHeaderViewModel { - func appendFieldItem() { - var fields = editProfileInfo.fields.value - guard fields.count < ProfileHeaderViewModel.maxProfileFieldCount else { return } - fields.append(ProfileFieldItem.FieldValue(name: "", value: "")) - editProfileInfo.fields.value = fields - } - - func removeFieldItem(item: ProfileFieldItem) { - var fields = editProfileInfo.fields.value - guard case let .field(field, _) = item else { return } - guard let removeIndex = fields.firstIndex(of: field) else { return } - fields.remove(at: removeIndex) - editProfileInfo.fields.value = fields } } @@ -153,69 +97,19 @@ extension ProfileHeaderViewModel { let html = try? HTML(html: note, encoding: .utf8) return html?.text } - - // check if profile change or not - func isProfileInfoEdited() -> Bool { - guard isEditing.value else { return false } - - guard editProfileInfo.name.value == displayProfileInfo.name.value else { return true } - guard case let .image(image) = editProfileInfo.avatarImageResource.value, image == nil else { return true } - guard editProfileInfo.note.value == ProfileHeaderViewModel.normalize(note: displayProfileInfo.note.value) else { return true } - let isFieldsEqual: Bool = { - let originalFields = self.accountForEdit.value?.source?.fields?.compactMap { field in - ProfileFieldItem.FieldValue(name: field.name, value: field.value) - } ?? [] - let editFields = editProfileInfo.fields.value - guard editFields.count == originalFields.count else { return false } - for (editField, originalField) in zip(editFields, originalFields) { - guard editField.name.value == originalField.name.value, - editField.value.value == originalField.value.value else { - return false - } - } - return true - }() - guard isFieldsEqual else { return true } + +} + + +// MARK: - ProfileViewModelEditable +extension ProfileHeaderViewModel: ProfileViewModelEditable { + func isEdited() -> Bool { + guard isEditing else { return false } + guard editProfileInfo.name == displayProfileInfo.name else { return true } + guard editProfileInfo.avatarImage == nil else { return true } + guard editProfileInfo.note == ProfileHeaderViewModel.normalize(note: displayProfileInfo.note) else { return true } + return false } - - func updateProfileInfo() -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, Error> { - guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { - return Fail(error: APIService.APIError.implicit(.badRequest)).eraseToAnyPublisher() - } - let domain = activeMastodonAuthenticationBox.domain - let authorization = activeMastodonAuthenticationBox.userAuthorization - - let image: UIImage? = { - guard case let .image(_image) = editProfileInfo.avatarImageResource.value else { return nil } - guard let image = _image else { return nil } - guard image.size.width <= MastodonRegisterViewController.avatarImageMaxSizeInPixel.width else { - return image.af.imageScaled(to: MastodonRegisterViewController.avatarImageMaxSizeInPixel) - } - return image - }() - - let fieldsAttributes = editProfileInfo.fields.value.map { fieldValue in - Mastodon.Entity.Field(name: fieldValue.name.value, value: fieldValue.value.value) - } - - let query = Mastodon.API.Account.UpdateCredentialQuery( - discoverable: nil, - bot: nil, - displayName: editProfileInfo.name.value, - note: editProfileInfo.note.value, - avatar: image.flatMap { Mastodon.Query.MediaAttachment.png($0.pngData()) }, - header: nil, - locked: nil, - source: nil, - fieldsAttributes: fieldsAttributes - ) - return context.apiService.accountUpdateCredentials( - domain: domain, - query: query, - authorization: authorization - ) - } - } diff --git a/Mastodon/Scene/Profile/Header/View/ProfileFieldAddEntryCollectionViewCell.swift b/Mastodon/Scene/Profile/Header/View/ProfileFieldAddEntryCollectionViewCell.swift deleted file mode 100644 index cafe0eda9..000000000 --- a/Mastodon/Scene/Profile/Header/View/ProfileFieldAddEntryCollectionViewCell.swift +++ /dev/null @@ -1,177 +0,0 @@ -// -// ProfileFieldAddEntryCollectionViewCell.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-5-26. -// - -import os.log -import UIKit -import Combine - -protocol ProfileFieldAddEntryCollectionViewCellDelegate: AnyObject { - func ProfileFieldAddEntryCollectionViewCellDidPressed(_ cell: ProfileFieldAddEntryCollectionViewCell) -} - -final class ProfileFieldAddEntryCollectionViewCell: UICollectionViewCell { - - var disposeBag = Set<AnyCancellable>() - - weak var delegate: ProfileFieldAddEntryCollectionViewCellDelegate? - - let singleTagGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer - - - static let symbolConfiguration = ProfileFieldCollectionViewCell.symbolConfiguration - static let insertButtonImage = UIImage(systemName: "plus.circle.fill", withConfiguration: symbolConfiguration) - - let containerStackView = UIStackView() - - let fieldView = ProfileFieldView() - - let editButton: UIButton = { - let button = HitTestExpandedButton(type: .custom) - button.setImage(ProfileFieldAddEntryCollectionViewCell.insertButtonImage, for: .normal) - button.contentMode = .center - button.tintColor = .systemGreen - return button - }() - - var separatorLineToMarginLeadingLayoutConstraint: NSLayoutConstraint! - var separatorLineToEdgeTrailingLayoutConstraint: NSLayoutConstraint! - var separatorLineToMarginTrailingLayoutConstraint: NSLayoutConstraint! - let bottomSeparatorLine = UIView.separatorLine - - 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 ProfileFieldAddEntryCollectionViewCell { - - private func _init() { - containerStackView.axis = .horizontal - containerStackView.spacing = 8 - - 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), - containerStackView.heightAnchor.constraint(equalToConstant: 44).priority(.defaultHigh), - ]) - containerStackView.isLayoutMarginsRelativeArrangement = true - - containerStackView.addArrangedSubview(editButton) - containerStackView.addArrangedSubview(fieldView) - - editButton.setContentCompressionResistancePriority(.required - 1, for: .horizontal) - editButton.setContentHuggingPriority(.required - 1, for: .horizontal) - - bottomSeparatorLine.translatesAutoresizingMaskIntoConstraints = false - separatorLineToMarginLeadingLayoutConstraint = bottomSeparatorLine.leadingAnchor.constraint(equalTo: contentView.leadingAnchor) - separatorLineToEdgeTrailingLayoutConstraint = bottomSeparatorLine.trailingAnchor.constraint(equalTo: contentView.trailingAnchor) - separatorLineToMarginTrailingLayoutConstraint = bottomSeparatorLine.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor) - - addSubview(bottomSeparatorLine) - NSLayoutConstraint.activate([ - separatorLineToMarginLeadingLayoutConstraint, - bottomSeparatorLine.bottomAnchor.constraint(equalTo: bottomAnchor), - bottomSeparatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: self)).priority(.defaultHigh), - ]) - - fieldView.titleMetaLabel.isHidden = false - fieldView.titleMetaLabel.configure(content: PlaintextMetaContent(string: L10n.Scene.Profile.Fields.addRow)) - fieldView.titleTextField.isHidden = true - - fieldView.valueMetaLabel.isHidden = false - fieldView.valueMetaLabel.configure(content: PlaintextMetaContent(string: " ")) - fieldView.valueTextField.isHidden = true - - addGestureRecognizer(singleTagGestureRecognizer) - singleTagGestureRecognizer.addTarget(self, action: #selector(ProfileFieldAddEntryCollectionViewCell.singleTapGestureRecognizerHandler(_:))) - - editButton.addTarget(self, action: #selector(ProfileFieldAddEntryCollectionViewCell.addButtonDidPressed(_:)), for: .touchUpInside) - - resetSeparatorLineLayout() - } - - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - - resetSeparatorLineLayout() - } - -} - -extension ProfileFieldAddEntryCollectionViewCell { - - @objc private func singleTapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - delegate?.ProfileFieldAddEntryCollectionViewCellDidPressed(self) - } - - @objc private func addButtonDidPressed(_ sender: UIButton) { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - delegate?.ProfileFieldAddEntryCollectionViewCellDidPressed(self) - } - -} - -extension ProfileFieldAddEntryCollectionViewCell { - private func resetSeparatorLineLayout() { - separatorLineToEdgeTrailingLayoutConstraint.isActive = false - separatorLineToMarginTrailingLayoutConstraint.isActive = false - - if traitCollection.userInterfaceIdiom == .phone { - // to edge - NSLayoutConstraint.activate([ - separatorLineToEdgeTrailingLayoutConstraint, - ]) - } else { - if traitCollection.horizontalSizeClass == .compact { - // to edge - NSLayoutConstraint.activate([ - separatorLineToEdgeTrailingLayoutConstraint, - ]) - } else { - // to margin - NSLayoutConstraint.activate([ - separatorLineToMarginTrailingLayoutConstraint, - ]) - } - } - } -} - -#if canImport(SwiftUI) && DEBUG -import SwiftUI - -struct ProfileFieldAddEntryCollectionViewCell_Previews: PreviewProvider { - - static var previews: some View { - UIViewPreview(width: 375) { - ProfileFieldAddEntryCollectionViewCell() - } - .previewLayout(.fixed(width: 375, height: 44)) - } - -} - -#endif - diff --git a/Mastodon/Scene/Profile/Header/View/ProfileFieldCollectionViewCell.swift b/Mastodon/Scene/Profile/Header/View/ProfileFieldCollectionViewCell.swift deleted file mode 100644 index 9106b0e44..000000000 --- a/Mastodon/Scene/Profile/Header/View/ProfileFieldCollectionViewCell.swift +++ /dev/null @@ -1,180 +0,0 @@ -// -// ProfileFieldCollectionViewCell.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-5-25. -// - -import os.log -import UIKit -import Combine -import MetaTextKit - -protocol ProfileFieldCollectionViewCellDelegate: AnyObject { - func profileFieldCollectionViewCell(_ cell: ProfileFieldCollectionViewCell, editButtonDidPressed button: UIButton) - func profileFieldCollectionViewCell(_ cell: ProfileFieldCollectionViewCell, metaLebel: MetaLabel, didSelectMeta meta: Meta) -} - -final class ProfileFieldCollectionViewCell: UICollectionViewCell { - - var disposeBag = Set<AnyCancellable>() - - weak var delegate: ProfileFieldCollectionViewCellDelegate? - - static let symbolConfiguration = UIImage.SymbolConfiguration(pointSize: 22, weight: .semibold, scale: .medium) - static let removeButtonItem = UIImage(systemName: "minus.circle.fill", withConfiguration: symbolConfiguration) - - let containerStackView = UIStackView() - - let fieldView = ProfileFieldView() - - let editButton: UIButton = { - let button = HitTestExpandedButton(type: .custom) - button.setImage(ProfileFieldCollectionViewCell.removeButtonItem, for: .normal) - button.contentMode = .center - button.tintColor = .systemRed - return button - }() - - let reorderBarImageView: UIImageView = { - let imageView = UIImageView() - imageView.contentMode = .scaleAspectFit - imageView.image = UIImage(systemName: "line.horizontal.3")?.withConfiguration(UIImage.SymbolConfiguration(pointSize: 20, weight: .regular)).withRenderingMode(.alwaysTemplate) - imageView.tintColor = Asset.Colors.Label.secondary.color - return imageView - }() - - var separatorLineToMarginLeadingLayoutConstraint: NSLayoutConstraint! - var separatorLineToEdgeTrailingLayoutConstraint: NSLayoutConstraint! - var separatorLineToMarginTrailingLayoutConstraint: NSLayoutConstraint! - let bottomSeparatorLine = UIView.separatorLine - - 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 ProfileFieldCollectionViewCell { - - private func _init() { - containerStackView.axis = .horizontal - containerStackView.spacing = 8 - - 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), - containerStackView.heightAnchor.constraint(equalToConstant: 44).priority(.defaultHigh), - ]) - containerStackView.isLayoutMarginsRelativeArrangement = true - - containerStackView.addArrangedSubview(editButton) - containerStackView.addArrangedSubview(fieldView) - containerStackView.addArrangedSubview(reorderBarImageView) - - editButton.setContentCompressionResistancePriority(.required - 1, for: .horizontal) - editButton.setContentHuggingPriority(.required - 1, for: .horizontal) - reorderBarImageView.setContentHuggingPriority(.required - 1, for: .horizontal) - reorderBarImageView.setContentCompressionResistancePriority(.required - 1, for: .horizontal) - - bottomSeparatorLine.translatesAutoresizingMaskIntoConstraints = false - separatorLineToMarginLeadingLayoutConstraint = bottomSeparatorLine.leadingAnchor.constraint(equalTo: contentView.leadingAnchor) - separatorLineToEdgeTrailingLayoutConstraint = bottomSeparatorLine.trailingAnchor.constraint(equalTo: contentView.trailingAnchor) - separatorLineToMarginTrailingLayoutConstraint = bottomSeparatorLine.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor) - - addSubview(bottomSeparatorLine) - NSLayoutConstraint.activate([ - separatorLineToMarginLeadingLayoutConstraint, - bottomSeparatorLine.bottomAnchor.constraint(equalTo: bottomAnchor), - bottomSeparatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: self)).priority(.defaultHigh), - ]) - - editButton.addTarget(self, action: #selector(ProfileFieldCollectionViewCell.editButtonDidPressed(_:)), for: .touchUpInside) - - fieldView.valueMetaLabel.linkDelegate = self - - resetSeparatorLineLayout() - } - - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - - resetSeparatorLineLayout() - } - -} - -extension ProfileFieldCollectionViewCell { - private func resetSeparatorLineLayout() { - separatorLineToEdgeTrailingLayoutConstraint.isActive = false - separatorLineToMarginTrailingLayoutConstraint.isActive = false - - if traitCollection.userInterfaceIdiom == .phone { - // to edge - NSLayoutConstraint.activate([ - separatorLineToEdgeTrailingLayoutConstraint, - ]) - } else { - if traitCollection.horizontalSizeClass == .compact { - // to edge - NSLayoutConstraint.activate([ - separatorLineToEdgeTrailingLayoutConstraint, - ]) - } else { - // to margin - NSLayoutConstraint.activate([ - separatorLineToMarginTrailingLayoutConstraint, - ]) - } - } - } -} - -extension ProfileFieldCollectionViewCell { - @objc private func editButtonDidPressed(_ sender: UIButton) { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - delegate?.profileFieldCollectionViewCell(self, editButtonDidPressed: sender) - } -} - -// MARK: - MetaLabelDelegate -extension ProfileFieldCollectionViewCell: MetaLabelDelegate { - func metaLabel(_ metaLabel: MetaLabel, didSelectMeta meta: Meta) { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - delegate?.profileFieldCollectionViewCell(self, metaLebel: metaLabel, didSelectMeta: meta) - } -} - - -#if canImport(SwiftUI) && DEBUG -import SwiftUI - -struct ProfileFieldCollectionViewCell_Previews: PreviewProvider { - - static var previews: some View { - UIViewPreview(width: 375) { - ProfileFieldCollectionViewCell() - } - .previewLayout(.fixed(width: 375, height: 44)) - } - -} - -#endif - diff --git a/Mastodon/Scene/Profile/Header/View/ProfileFieldCollectionViewHeaderFooterView.swift b/Mastodon/Scene/Profile/Header/View/ProfileFieldCollectionViewHeaderFooterView.swift index 83fec9bcf..8a0d3c6d6 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileFieldCollectionViewHeaderFooterView.swift +++ b/Mastodon/Scene/Profile/Header/View/ProfileFieldCollectionViewHeaderFooterView.swift @@ -12,8 +12,6 @@ final class ProfileFieldCollectionViewHeaderFooterView: UICollectionReusableView static let headerReuseIdentifer = "ProfileFieldCollectionViewHeaderFooterView.Header" static let footerReuseIdentifer = "ProfileFieldCollectionViewHeaderFooterView.Footer" - let separatorLine = UIView.separatorLine - override init(frame: CGRect) { super.init(frame: frame) _init() @@ -28,15 +26,6 @@ final class ProfileFieldCollectionViewHeaderFooterView: UICollectionReusableView extension ProfileFieldCollectionViewHeaderFooterView { private func _init() { - separatorLine.translatesAutoresizingMaskIntoConstraints = false - addSubview(separatorLine) - NSLayoutConstraint.activate([ - separatorLine.topAnchor.constraint(equalTo: topAnchor), - // workaround SDK supplementariesFollowContentInsets not works issue - separatorLine.leadingAnchor.constraint(equalTo: leadingAnchor, constant: -9999), - separatorLine.trailingAnchor.constraint(equalTo: trailingAnchor, constant: 9999), - separatorLine.bottomAnchor.constraint(equalTo: bottomAnchor), - separatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: self)).priority(.defaultHigh), - ]) + } } diff --git a/Mastodon/Scene/Profile/Header/View/ProfileFieldView.swift b/Mastodon/Scene/Profile/Header/View/ProfileFieldView.swift deleted file mode 100644 index ee17d7e4d..000000000 --- a/Mastodon/Scene/Profile/Header/View/ProfileFieldView.swift +++ /dev/null @@ -1,141 +0,0 @@ -// -// ProfileFieldView.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-3-30. -// - -import UIKit -import Combine -import MetaTextKit - -final class ProfileFieldView: UIView { - - var disposeBag = Set<AnyCancellable>() - - // output - let name = PassthroughSubject<String, Never>() - let value = PassthroughSubject<String, Never>() - - // for custom emoji display - let titleMetaLabel = MetaLabel(style: .profileFieldName) - - // for editing - let titleTextField: UITextField = { - let textField = UITextField() - textField.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold), maximumPointSize: 20) - textField.textColor = Asset.Colors.Label.primary.color - textField.placeholder = L10n.Scene.Profile.Fields.Placeholder.label - return textField - }() - - // for custom emoji display - let valueMetaLabel = MetaLabel(style: .profileFieldValue) - - // for editing - let valueTextField: UITextField = { - let textField = UITextField() - textField.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .regular), maximumPointSize: 20) - textField.textColor = Asset.Colors.Label.primary.color - textField.placeholder = L10n.Scene.Profile.Fields.Placeholder.content - textField.textAlignment = .right - return textField - }() - - override init(frame: CGRect) { - super.init(frame: frame) - _init() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - _init() - } - -} - -extension ProfileFieldView { - private func _init() { - - let containerStackView = UIStackView() - containerStackView.axis = .horizontal - containerStackView.alignment = .center - - // note: - // do not use readable layout guide to workaround SDK issue - // otherwise, the `ProfileFieldCollectionViewCell` cannot display edit button and reorder icon - containerStackView.translatesAutoresizingMaskIntoConstraints = false - addSubview(containerStackView) - NSLayoutConstraint.activate([ - containerStackView.topAnchor.constraint(equalTo: topAnchor), - containerStackView.leadingAnchor.constraint(equalTo: leadingAnchor), - containerStackView.trailingAnchor.constraint(equalTo: trailingAnchor), - containerStackView.bottomAnchor.constraint(equalTo: bottomAnchor), - ]) - titleMetaLabel.translatesAutoresizingMaskIntoConstraints = false - containerStackView.addArrangedSubview(titleMetaLabel) - NSLayoutConstraint.activate([ - titleMetaLabel.widthAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh), - ]) - titleTextField.setContentHuggingPriority(.defaultLow - 1, for: .horizontal) - titleTextField.translatesAutoresizingMaskIntoConstraints = false - containerStackView.addArrangedSubview(titleTextField) - NSLayoutConstraint.activate([ - titleTextField.widthAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh), - ]) - titleTextField.setContentHuggingPriority(.defaultLow - 1, for: .horizontal) - - valueMetaLabel.translatesAutoresizingMaskIntoConstraints = false - containerStackView.addArrangedSubview(valueMetaLabel) - NSLayoutConstraint.activate([ - valueMetaLabel.widthAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh), - ]) - valueMetaLabel.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) - valueTextField.translatesAutoresizingMaskIntoConstraints = false - containerStackView.addArrangedSubview(valueTextField) - NSLayoutConstraint.activate([ - valueTextField.widthAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh), - ]) - - titleTextField.isHidden = true - valueTextField.isHidden = true - - NotificationCenter.default - .publisher(for: UITextField.textDidChangeNotification, object: titleTextField) - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - guard let self = self else { return } - self.name.send(self.titleTextField.text ?? "") - } - .store(in: &disposeBag) - - NotificationCenter.default - .publisher(for: UITextField.textDidChangeNotification, object: valueTextField) - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - guard let self = self else { return } - self.value.send(self.valueTextField.text ?? "") - } - .store(in: &disposeBag) - } -} - -#if canImport(SwiftUI) && DEBUG -import SwiftUI - -struct ProfileFieldView_Previews: PreviewProvider { - - static var previews: some View { - UIViewPreview(width: 375) { - let filedView = ProfileFieldView() - let content = PlaintextMetaContent(string: "https://mastodon.online") - filedView.valueMetaLabel.configure(content: content) - return filedView - } - .previewLayout(.fixed(width: 375, height: 100)) - } - -} - -#endif - diff --git a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift index 016b31a1e..78430cb36 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift +++ b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift @@ -10,9 +10,12 @@ import UIKit import Combine import FLAnimatedImage import MetaTextKit +import MastodonAsset +import MastodonLocalization +import MastodonUI protocol ProfileHeaderViewDelegate: AnyObject { - func profileHeaderView(_ profileHeaderView: ProfileHeaderView, avatarImageViewDidPressed imageView: UIImageView) + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, avatarButtonDidPressed button: AvatarButton) func profileHeaderView(_ profileHeaderView: ProfileHeaderView, bannerImageViewDidPressed imageView: UIImageView) func profileHeaderView(_ profileHeaderView: ProfileHeaderView, relationshipButtonDidPressed button: ProfileRelationshipActionButton) func profileHeaderView(_ profileHeaderView: ProfileHeaderView, metaTextView: MetaTextView, metaDidPressed meta: Meta) @@ -22,8 +25,8 @@ protocol ProfileHeaderViewDelegate: AnyObject { final class ProfileHeaderView: UIView { - static let avatarImageViewSize = CGSize(width: 56, height: 56) - static let avatarImageViewCornerRadius: CGFloat = 6 + static let avatarImageViewSize = CGSize(width: 98, height: 98) + static let avatarImageViewCornerRadius: CGFloat = 25 static let avatarImageViewBorderColor = UIColor.white static let avatarImageViewBorderWidth: CGFloat = 2 static let friendshipActionButtonSize = CGSize(width: 108, height: 34) @@ -69,13 +72,11 @@ final class ProfileHeaderView: UIView { return view }() - let avatarImageView: FLAnimatedImageView = { - let imageView = FLAnimatedImageView() - let placeholderImage = UIImage - .placeholder(size: ProfileHeaderView.avatarImageViewSize, color: Asset.Theme.Mastodon.systemGroupedBackground.color) - .af.imageRounded(withCornerRadius: ProfileHeaderView.avatarImageViewCornerRadius, divideRadiusByImageScale: false) - imageView.image = placeholderImage - return imageView + let avatarButton: AvatarButton = { + let button = AvatarButton() + button.avatarImageView.configure(cornerConfiguration: .init(corner: .fixed(radius: 0))) + button.accessibilityLabel = "Avatar image" // FIXME: i18n + return button }() func setupAvatarOverlayViews() { @@ -123,38 +124,38 @@ final class ProfileHeaderView: UIView { metaText.textView.isSelectable = false metaText.textView.isScrollEnabled = false metaText.textView.layer.masksToBounds = false - metaText.textView.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 20, weight: .semibold), maximumPointSize: 28) + metaText.textView.font = UIFontMetrics(forTextStyle: .title2).scaledFont(for: .systemFont(ofSize: 22, weight: .bold)) metaText.textView.textColor = .white metaText.textView.textContainer.lineFragmentPadding = 0 metaText.textAttributes = [ - .font: UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 20, weight: .semibold), maximumPointSize: 28), - .foregroundColor: UIColor.white + .font: UIFontMetrics(forTextStyle: .title2).scaledFont(for: .systemFont(ofSize: 22, weight: .bold)), + .foregroundColor: Asset.Colors.Label.primary.color ] return metaText }() let nameTextField: UITextField = { let textField = UITextField() - textField.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 20, weight: .semibold), maximumPointSize: 28) - textField.textColor = .white + textField.font = UIFontMetrics(forTextStyle: .title2).scaledFont(for: .systemFont(ofSize: 22, weight: .bold)) + textField.textColor = Asset.Colors.Label.secondary.color textField.text = "Alice" textField.autocorrectionType = .no textField.autocapitalizationType = .none - textField.applyShadow(color: UIColor.black.withAlphaComponent(0.2), alpha: 0.5, x: 0, y: 2, blur: 2, spread: 0) return textField }() let usernameLabel: UILabel = { let label = UILabel() - label.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(for: .systemFont(ofSize: 15, weight: .regular), maximumPointSize: 20) + label.font = UIFontMetrics(forTextStyle: .callout).scaledFont(for: .systemFont(ofSize: 16, weight: .regular)) label.adjustsFontSizeToFitWidth = true label.minimumScaleFactor = 0.5 - label.textColor = Asset.Scene.Profile.Banner.usernameGray.color + label.textColor = Asset.Colors.Label.secondary.color label.text = "@alice" - label.applyShadow(color: UIColor.black.withAlphaComponent(0.2), alpha: 0.5, x: 0, y: 2, blur: 2, spread: 0) return label }() let statusDashboardView = ProfileStatusDashboardView() + + let relationshipActionButtonShadowContainer = ShadowBackgroundContainer() let relationshipActionButton: ProfileRelationshipActionButton = { let button = ProfileRelationshipActionButton() button.titleLabel?.font = .systemFont(ofSize: 17, weight: .semibold) @@ -198,37 +199,6 @@ final class ProfileHeaderView: UIView { return metaText }() - static func createFieldCollectionViewLayout() -> UICollectionViewLayout { - let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44)) - let item = NSCollectionLayoutItem(layoutSize: itemSize) - let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44)) - let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item]) - let section = NSCollectionLayoutSection(group: group) - section.contentInsetsReference = .readableContent - - let headerFooterSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(1)) - let header = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerFooterSize, elementKind: UICollectionView.elementKindSectionHeader, alignment: .top) - let footer = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerFooterSize, elementKind: UICollectionView.elementKindSectionFooter, alignment: .bottom) - section.boundarySupplementaryItems = [header, footer] - // note: toggle this not take effect - // section.supplementariesFollowContentInsets = false - - return UICollectionViewCompositionalLayout(section: section) - } - - let fieldCollectionView: UICollectionView = { - let collectionViewLayout = ProfileHeaderView.createFieldCollectionViewLayout() - let collectionView = UICollectionView(frame: CGRect(x: 0, y: 0, width: 100, height: 100), collectionViewLayout: collectionViewLayout) - collectionView.register(ProfileFieldCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ProfileFieldCollectionViewCell.self)) - collectionView.register(ProfileFieldAddEntryCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ProfileFieldAddEntryCollectionViewCell.self)) - collectionView.register(ProfileFieldCollectionViewHeaderFooterView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: ProfileFieldCollectionViewHeaderFooterView.headerReuseIdentifer) - collectionView.register(ProfileFieldCollectionViewHeaderFooterView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter, withReuseIdentifier: ProfileFieldCollectionViewHeaderFooterView.footerReuseIdentifer) - collectionView.isScrollEnabled = false - return collectionView - }() - var fieldCollectionViewHeightLayoutConstraint: NSLayoutConstraint! - var fieldCollectionViewHeightObservation: NSKeyValueObservation? - override init(frame: CGRect) { super.init(frame: frame) _init() @@ -239,22 +209,16 @@ final class ProfileHeaderView: UIView { _init() } - deinit { - fieldCollectionViewHeightObservation = nil - } - } extension ProfileHeaderView { private func _init() { - backgroundColor = ThemeService.shared.currentTheme.value.systemGroupedBackgroundColor - fieldCollectionView.backgroundColor = ThemeService.shared.currentTheme.value.profileFieldCollectionViewBackgroundColor + backgroundColor = ThemeService.shared.currentTheme.value.systemBackgroundColor ThemeService.shared.currentTheme - .receive(on: RunLoop.main) + .receive(on: DispatchQueue.main) .sink { [weak self] theme in guard let self = self else { return } - self.backgroundColor = theme.systemGroupedBackgroundColor - self.fieldCollectionView.backgroundColor = theme.profileFieldCollectionViewBackgroundColor + self.backgroundColor = theme.systemBackgroundColor } .store(in: &disposeBag) @@ -284,21 +248,21 @@ extension ProfileHeaderView { // avatar avatarImageViewBackgroundView.translatesAutoresizingMaskIntoConstraints = false - bannerContainerView.addSubview(avatarImageViewBackgroundView) + addSubview(avatarImageViewBackgroundView) NSLayoutConstraint.activate([ - avatarImageViewBackgroundView.leadingAnchor.constraint(equalTo: bannerContainerView.readableContentGuide.leadingAnchor), - bannerContainerView.bottomAnchor.constraint(equalTo: avatarImageViewBackgroundView.bottomAnchor, constant: 20), + avatarImageViewBackgroundView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), + // align to dashboardContainer bottom ]) - avatarImageView.translatesAutoresizingMaskIntoConstraints = false - avatarImageViewBackgroundView.addSubview(avatarImageView) + avatarButton.translatesAutoresizingMaskIntoConstraints = false + avatarImageViewBackgroundView.addSubview(avatarButton) NSLayoutConstraint.activate([ - avatarImageView.topAnchor.constraint(equalTo: avatarImageViewBackgroundView.topAnchor, constant: 0.5 * ProfileHeaderView.avatarImageViewBorderWidth), - avatarImageView.leadingAnchor.constraint(equalTo: avatarImageViewBackgroundView.leadingAnchor, constant: 0.5 * ProfileHeaderView.avatarImageViewBorderWidth), - avatarImageViewBackgroundView.trailingAnchor.constraint(equalTo: avatarImageView.trailingAnchor, constant: 0.5 * ProfileHeaderView.avatarImageViewBorderWidth), - avatarImageViewBackgroundView.bottomAnchor.constraint(equalTo: avatarImageView.bottomAnchor, constant: 0.5 * ProfileHeaderView.avatarImageViewBorderWidth), - avatarImageView.widthAnchor.constraint(equalToConstant: ProfileHeaderView.avatarImageViewSize.width).priority(.required - 1), - avatarImageView.heightAnchor.constraint(equalToConstant: ProfileHeaderView.avatarImageViewSize.height).priority(.required - 1), + avatarButton.topAnchor.constraint(equalTo: avatarImageViewBackgroundView.topAnchor, constant: 0.5 * ProfileHeaderView.avatarImageViewBorderWidth), + avatarButton.leadingAnchor.constraint(equalTo: avatarImageViewBackgroundView.leadingAnchor, constant: 0.5 * ProfileHeaderView.avatarImageViewBorderWidth), + avatarImageViewBackgroundView.trailingAnchor.constraint(equalTo: avatarButton.trailingAnchor, constant: 0.5 * ProfileHeaderView.avatarImageViewBorderWidth), + avatarImageViewBackgroundView.bottomAnchor.constraint(equalTo: avatarButton.bottomAnchor, constant: 0.5 * ProfileHeaderView.avatarImageViewBorderWidth), + avatarButton.widthAnchor.constraint(equalToConstant: ProfileHeaderView.avatarImageViewSize.width).priority(.required - 1), + avatarButton.heightAnchor.constraint(equalToConstant: ProfileHeaderView.avatarImageViewSize.height).priority(.required - 1), ]) avatarImageViewOverlayVisualEffectView.translatesAutoresizingMaskIntoConstraints = false @@ -311,12 +275,12 @@ extension ProfileHeaderView { ]) editAvatarBackgroundView.translatesAutoresizingMaskIntoConstraints = false - avatarImageView.addSubview(editAvatarBackgroundView) + avatarButton.addSubview(editAvatarBackgroundView) NSLayoutConstraint.activate([ - editAvatarBackgroundView.topAnchor.constraint(equalTo: avatarImageView.topAnchor), - editAvatarBackgroundView.leadingAnchor.constraint(equalTo: avatarImageView.leadingAnchor), - editAvatarBackgroundView.trailingAnchor.constraint(equalTo: avatarImageView.trailingAnchor), - editAvatarBackgroundView.bottomAnchor.constraint(equalTo: avatarImageView.bottomAnchor), + editAvatarBackgroundView.topAnchor.constraint(equalTo: avatarButton.topAnchor), + editAvatarBackgroundView.leadingAnchor.constraint(equalTo: avatarButton.leadingAnchor), + editAvatarBackgroundView.trailingAnchor.constraint(equalTo: avatarButton.trailingAnchor), + editAvatarBackgroundView.bottomAnchor.constraint(equalTo: avatarButton.bottomAnchor), ]) editAvatarButton.translatesAutoresizingMaskIntoConstraints = false @@ -328,20 +292,50 @@ extension ProfileHeaderView { editAvatarButton.bottomAnchor.constraint(equalTo: editAvatarBackgroundView.bottomAnchor), ]) editAvatarBackgroundView.isUserInteractionEnabled = true - avatarImageView.isUserInteractionEnabled = true - - // name container: [display name container | username] + avatarButton.isUserInteractionEnabled = true + + // container: V - [ dashboard container | author container | bio ] + let container = UIStackView() + container.axis = .vertical + container.spacing = 8 + container.preservesSuperviewLayoutMargins = true + container.isLayoutMarginsRelativeArrangement = true + container.layoutMargins.top = 12 + + container.translatesAutoresizingMaskIntoConstraints = false + addSubview(container) + NSLayoutConstraint.activate([ + container.topAnchor.constraint(equalTo: bannerContainerView.bottomAnchor), + container.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), + layoutMarginsGuide.trailingAnchor.constraint(equalTo: container.trailingAnchor), + container.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + // dashboardContainer: H - [ padding | statusDashboardView ] + let dashboardContainer = UIStackView() + dashboardContainer.axis = .horizontal + container.addArrangedSubview(dashboardContainer) + + let dashboardPaddingView = UIView() + dashboardContainer.addArrangedSubview(dashboardPaddingView) + dashboardContainer.addArrangedSubview(statusDashboardView) + + NSLayoutConstraint.activate([ + avatarImageViewBackgroundView.bottomAnchor.constraint(equalTo: dashboardContainer.bottomAnchor), + ]) + + // authorContainer: H - [ nameContainer | padding | relationshipActionButtonShadowContainer ] + let authorContainer = UIStackView() + authorContainer.axis = .horizontal + authorContainer.alignment = .top + authorContainer.spacing = 10 + container.addArrangedSubview(authorContainer) + + // name container: V - [ display name container | username ] let nameContainerStackView = UIStackView() nameContainerStackView.preservesSuperviewLayoutMargins = true nameContainerStackView.axis = .vertical - nameContainerStackView.spacing = 7 nameContainerStackView.translatesAutoresizingMaskIntoConstraints = false - addSubview(nameContainerStackView) - NSLayoutConstraint.activate([ - nameContainerStackView.leadingAnchor.constraint(equalTo: avatarImageView.trailingAnchor, constant: 12), - nameContainerStackView.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor), - nameContainerStackView.centerYAnchor.constraint(equalTo: avatarImageView.centerYAnchor), - ]) displayNameStackView.axis = .horizontal nameTextField.translatesAutoresizingMaskIntoConstraints = false @@ -365,101 +359,79 @@ extension ProfileHeaderView { nameMetaText.textView.translatesAutoresizingMaskIntoConstraints = false displayNameStackView.addSubview(nameMetaText.textView) NSLayoutConstraint.activate([ - nameMetaText.textView.centerYAnchor.constraint(equalTo: nameTextField.centerYAnchor), - nameMetaText.textView.leadingAnchor.constraint(equalTo: nameTextField.leadingAnchor), - nameMetaText.textView.trailingAnchor.constraint(equalTo: nameTextField.trailingAnchor), + nameMetaText.textView.topAnchor.constraint(equalTo: nameTextFieldBackgroundView.topAnchor), + nameMetaText.textView.leadingAnchor.constraint(equalTo: nameTextFieldBackgroundView.leadingAnchor, constant: 5), + nameTextFieldBackgroundView.trailingAnchor.constraint(equalTo: nameMetaText.textView.trailingAnchor, constant: 5), + nameMetaText.textView.bottomAnchor.constraint(equalTo: nameTextFieldBackgroundView.bottomAnchor), ]) nameContainerStackView.addArrangedSubview(displayNameStackView) nameContainerStackView.addArrangedSubview(usernameLabel) - // meta container: [dashboard container | bio container | field container] - let metaContainerStackView = UIStackView() - metaContainerStackView.spacing = 16 - metaContainerStackView.axis = .vertical - metaContainerStackView.preservesSuperviewLayoutMargins = true - metaContainerStackView.translatesAutoresizingMaskIntoConstraints = false - addSubview(metaContainerStackView) - NSLayoutConstraint.activate([ - metaContainerStackView.topAnchor.constraint(equalTo: bannerContainerView.bottomAnchor, constant: 13), - metaContainerStackView.leadingAnchor.constraint(equalTo: leadingAnchor), - metaContainerStackView.trailingAnchor.constraint(equalTo: trailingAnchor), - metaContainerStackView.bottomAnchor.constraint(equalTo: bottomAnchor), - ]) - - // dashboard container: [dashboard | friendship action button] - let dashboardContainerView = UIView() - dashboardContainerView.preservesSuperviewLayoutMargins = true - metaContainerStackView.addArrangedSubview(dashboardContainerView) - - statusDashboardView.translatesAutoresizingMaskIntoConstraints = false - dashboardContainerView.addSubview(statusDashboardView) - NSLayoutConstraint.activate([ - statusDashboardView.topAnchor.constraint(equalTo: dashboardContainerView.topAnchor), - statusDashboardView.leadingAnchor.constraint(equalTo: dashboardContainerView.readableContentGuide.leadingAnchor), - statusDashboardView.bottomAnchor.constraint(equalTo: dashboardContainerView.bottomAnchor), - ]) + authorContainer.addArrangedSubview(nameContainerStackView) + authorContainer.addArrangedSubview(UIView()) + authorContainer.addArrangedSubview(relationshipActionButtonShadowContainer) relationshipActionButton.translatesAutoresizingMaskIntoConstraints = false - dashboardContainerView.addSubview(relationshipActionButton) + relationshipActionButtonShadowContainer.addSubview(relationshipActionButton) NSLayoutConstraint.activate([ - relationshipActionButton.topAnchor.constraint(equalTo: dashboardContainerView.topAnchor), - relationshipActionButton.leadingAnchor.constraint(greaterThanOrEqualTo: statusDashboardView.trailingAnchor, constant: 8), - relationshipActionButton.trailingAnchor.constraint(equalTo: dashboardContainerView.readableContentGuide.trailingAnchor), + relationshipActionButton.topAnchor.constraint(equalTo: relationshipActionButtonShadowContainer.topAnchor), + relationshipActionButton.leadingAnchor.constraint(equalTo: relationshipActionButtonShadowContainer.leadingAnchor), + relationshipActionButton.trailingAnchor.constraint(equalTo: relationshipActionButtonShadowContainer.trailingAnchor), + relationshipActionButton.bottomAnchor.constraint(equalTo: relationshipActionButtonShadowContainer.bottomAnchor), relationshipActionButton.widthAnchor.constraint(greaterThanOrEqualToConstant: ProfileHeaderView.friendshipActionButtonSize.width).priority(.required - 1), relationshipActionButton.heightAnchor.constraint(equalToConstant: ProfileHeaderView.friendshipActionButtonSize.height).priority(.defaultHigh), ]) - relationshipActionButton.setContentHuggingPriority(.required - 10, for: .horizontal) - - bioContainerView.preservesSuperviewLayoutMargins = true - metaContainerStackView.addArrangedSubview(bioContainerView) - - bioMetaText.textView.translatesAutoresizingMaskIntoConstraints = false - bioContainerView.addSubview(bioMetaText.textView) - NSLayoutConstraint.activate([ - bioMetaText.textView.topAnchor.constraint(equalTo: bioContainerView.topAnchor), - bioMetaText.textView.leadingAnchor.constraint(equalTo: bioContainerView.readableContentGuide.leadingAnchor), - bioMetaText.textView.trailingAnchor.constraint(equalTo: bioContainerView.readableContentGuide.trailingAnchor), - bioMetaText.textView.bottomAnchor.constraint(equalTo: bioContainerView.bottomAnchor), - ]) - - fieldCollectionView.translatesAutoresizingMaskIntoConstraints = false - metaContainerStackView.addArrangedSubview(fieldCollectionView) - fieldCollectionViewHeightLayoutConstraint = fieldCollectionView.heightAnchor.constraint(equalToConstant: 44).priority(.defaultHigh) - NSLayoutConstraint.activate([ - fieldCollectionViewHeightLayoutConstraint, - ]) - fieldCollectionViewHeightObservation = fieldCollectionView.observe(\.contentSize, options: .new, changeHandler: { [weak self] tableView, _ in - guard let self = self else { return } - guard self.fieldCollectionView.contentSize.height != .zero else { - self.fieldCollectionViewHeightLayoutConstraint.constant = 44 - return - } - self.fieldCollectionViewHeightLayoutConstraint.constant = self.fieldCollectionView.contentSize.height - }) + // bio + container.addArrangedSubview(bioMetaText.textView) + bringSubviewToFront(bannerContainerView) - bringSubviewToFront(nameContainerStackView) + bringSubviewToFront(avatarImageViewBackgroundView) statusDashboardView.delegate = self bioMetaText.textView.delegate = self bioMetaText.textView.linkDelegate = self - let avatarImageViewSingleTapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer - avatarImageView.addGestureRecognizer(avatarImageViewSingleTapGestureRecognizer) - avatarImageViewSingleTapGestureRecognizer.addTarget(self, action: #selector(ProfileHeaderView.avatarImageViewDidPressed(_:))) - let bannerImageViewSingleTapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer bannerImageView.addGestureRecognizer(bannerImageViewSingleTapGestureRecognizer) bannerImageViewSingleTapGestureRecognizer.addTarget(self, action: #selector(ProfileHeaderView.bannerImageViewDidPressed(_:))) + avatarButton.addTarget(self, action: #selector(ProfileHeaderView.avatarButtonDidPressed(_:)), for: .touchUpInside) relationshipActionButton.addTarget(self, action: #selector(ProfileHeaderView.relationshipActionButtonDidPressed(_:)), for: .touchUpInside) configure(state: .normal) + + updateLayoutMargins() + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + updateLayoutMargins() } } +extension ProfileHeaderView { + private func updateLayoutMargins() { + let margin: CGFloat = { + switch traitCollection.userInterfaceIdiom { + case .phone: + return ProfileViewController.containerViewMarginForCompactHorizontalSizeClass + default: + return traitCollection.horizontalSizeClass == .regular ? + ProfileViewController.containerViewMarginForRegularHorizontalSizeClass : + ProfileViewController.containerViewMarginForCompactHorizontalSizeClass + } + }() + + layoutMargins.left = margin + layoutMargins.right = margin + } + +} + extension ProfileHeaderView { enum State { case normal @@ -514,9 +486,10 @@ extension ProfileHeaderView { delegate?.profileHeaderView(self, relationshipButtonDidPressed: relationshipActionButton) } - @objc private func avatarImageViewDidPressed(_ sender: UITapGestureRecognizer) { + @objc private func avatarButtonDidPressed(_ sender: UIButton) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - delegate?.profileHeaderView(self, avatarImageViewDidPressed: avatarImageView) + assert(sender === avatarButton) + delegate?.profileHeaderView(self, avatarButtonDidPressed: avatarButton) } @objc private func bannerImageViewDidPressed(_ sender: UITapGestureRecognizer) { @@ -553,14 +526,6 @@ extension ProfileHeaderView: ProfileStatusDashboardViewDelegate { } } -// MARK: - AvatarConfigurableView -extension ProfileHeaderView: AvatarConfigurableView { - static var configurableAvatarImageSize: CGSize { avatarImageViewSize } - static var configurableAvatarImageCornerRadius: CGFloat { avatarImageViewCornerRadius } - var configurableAvatarImageView: FLAnimatedImageView? { return avatarImageView } -} - - #if DEBUG import SwiftUI diff --git a/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift b/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift index fffb061b4..87c189a45 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift +++ b/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift @@ -7,12 +7,13 @@ import UIKit import MastodonUI +import MastodonAsset final class ProfileRelationshipActionButton: RoundedEdgesButton { let activityIndicatorView: UIActivityIndicatorView = { let activityIndicatorView = UIActivityIndicatorView(style: .medium) - activityIndicatorView.color = .white + activityIndicatorView.color = Asset.Colors.Label.primaryReverse.color return activityIndicatorView }() @@ -30,6 +31,7 @@ final class ProfileRelationshipActionButton: RoundedEdgesButton { extension ProfileRelationshipActionButton { private func _init() { + cornerRadius = 10 titleLabel?.font = .systemFont(ofSize: 17, weight: .semibold) activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false @@ -41,17 +43,22 @@ extension ProfileRelationshipActionButton { activityIndicatorView.hidesWhenStopped = true activityIndicatorView.stopAnimating() + + configureAppearance() + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + configureAppearance() } } extension ProfileRelationshipActionButton { func configure(actionOptionSet: ProfileViewModel.RelationshipActionOptionSet) { setTitle(actionOptionSet.title, for: .normal) - setTitleColor(.white, for: .normal) - setTitleColor(UIColor.white.withAlphaComponent(0.5), for: .highlighted) - setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor), for: .normal) - setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor.withAlphaComponent(0.5)), for: .highlighted) - setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor.withAlphaComponent(0.5)), for: .disabled) + + configureAppearance() titleEdgeInsets = UIEdgeInsets(top: 0, left: 4, bottom: 0, right: 4) @@ -66,5 +73,23 @@ extension ProfileRelationshipActionButton { isEnabled = true } } + + private func configureAppearance() { + setTitleColor(Asset.Colors.Label.primaryReverse.color, for: .normal) + setTitleColor(Asset.Colors.Label.primaryReverse.color.withAlphaComponent(0.5), for: .highlighted) + switch traitCollection.userInterfaceStyle { + case .dark: + setBackgroundImage(.placeholder(color: Asset.Scene.Profile.RelationshipButton.backgroundDark.color), for: .normal) + setBackgroundImage(.placeholder(color: Asset.Scene.Profile.RelationshipButton.backgroundHighlightedDark.color), for: .highlighted) + setBackgroundImage(.placeholder(color: Asset.Scene.Profile.RelationshipButton.backgroundHighlightedDark.color), for: .disabled) + default: + setBackgroundImage(.placeholder(color: Asset.Scene.Profile.RelationshipButton.backgroundLight.color), for: .normal) + setBackgroundImage(.placeholder(color: Asset.Scene.Profile.RelationshipButton.backgroundHighlightedLight.color), for: .highlighted) + setBackgroundImage(.placeholder(color: Asset.Scene.Profile.RelationshipButton.backgroundHighlightedLight.color), for: .disabled) + } +// setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor), for: .normal) +// setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor.withAlphaComponent(0.5)), for: .highlighted) +// setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor.withAlphaComponent(0.5)), for: .disabled) + } } diff --git a/Mastodon/Scene/Profile/Header/View/ProfileStatusDashboardMeterView.swift b/Mastodon/Scene/Profile/Header/View/ProfileStatusDashboardMeterView.swift index 53cd21f60..9176d7a3c 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileStatusDashboardMeterView.swift +++ b/Mastodon/Scene/Profile/Header/View/ProfileStatusDashboardMeterView.swift @@ -6,6 +6,8 @@ // import UIKit +import MastodonAsset +import MastodonLocalization final class ProfileStatusDashboardMeterView: UIView { diff --git a/Mastodon/Scene/Profile/Header/View/ProfileStatusDashboardView.swift b/Mastodon/Scene/Profile/Header/View/ProfileStatusDashboardView.swift index c21703c08..9448f1964 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileStatusDashboardView.swift +++ b/Mastodon/Scene/Profile/Header/View/ProfileStatusDashboardView.swift @@ -7,6 +7,8 @@ import os.log import UIKit +import MastodonAsset +import MastodonLocalization protocol ProfileStatusDashboardViewDelegate: AnyObject { func profileStatusDashboardView(_ dashboardView: ProfileStatusDashboardView, dashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView, meter: ProfileStatusDashboardView.Meter) @@ -73,6 +75,9 @@ extension ProfileStatusDashboardView { tapGestureRecognizer.addTarget(self, action: #selector(ProfileStatusDashboardView.tapGestureRecognizerHandler(_:))) meterView.addGestureRecognizer(tapGestureRecognizer) } + + followingDashboardMeterView.accessibilityHint = "Double tap to open the list" // TODO: i18n + followersDashboardMeterView.accessibilityHint = "Double tap to open the list" } } diff --git a/Mastodon/Scene/Profile/MeProfileViewModel.swift b/Mastodon/Scene/Profile/MeProfileViewModel.swift index d1c0cb49d..cee6d5e47 100644 --- a/Mastodon/Scene/Profile/MeProfileViewModel.swift +++ b/Mastodon/Scene/Profile/MeProfileViewModel.swift @@ -20,12 +20,12 @@ final class MeProfileViewModel: ProfileViewModel { optionalMastodonUser: context.authenticationService.activeMastodonAuthentication.value?.user ) - self.currentMastodonUser - .sink { [weak self] currentMastodonUser in - os_log("%{public}s[%{public}ld], %{public}s: current active mastodon user: %s", ((#file as NSString).lastPathComponent), #line, #function, currentMastodonUser?.username ?? "<nil>") + $me + .sink { [weak self] me in + os_log("%{public}s[%{public}ld], %{public}s: current active mastodon user: %s", ((#file as NSString).lastPathComponent), #line, #function, me?.username ?? "<nil>") guard let self = self else { return } - self.mastodonUser.value = currentMastodonUser + self.user = me } .store(in: &disposeBag) } diff --git a/Mastodon/Scene/Profile/ProfileViewController+UserProvider.swift b/Mastodon/Scene/Profile/ProfileViewController+UserProvider.swift deleted file mode 100644 index 6bfa132b8..000000000 --- a/Mastodon/Scene/Profile/ProfileViewController+UserProvider.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// ProfileViewController+UserProvider.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-4-1. -// - -import Foundation -import Combine -import CoreDataStack -import UIKit - -extension ProfileViewController: UserProvider { - func mastodonUser(for cell: UITableViewCell?) -> Future<MastodonUser?, Never> { - return Future { promise in - promise(.success(nil)) - } - } - - - func mastodonUser() -> Future<MastodonUser?, Never> { - return Future { promise in - promise(.success(self.viewModel.mastodonUser.value)) - } - } - -} diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index 5ff71ba99..a890505ef 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -10,9 +10,23 @@ import UIKit import Combine import MastodonMeta import MetaTextKit +import MastodonAsset +import MastodonLocalization +import MastodonUI +import Tabman +import CoreDataStack + +protocol ProfileViewModelEditable { + func isEdited() -> Bool +} final class ProfileViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { + public static let containerViewMarginForRegularHorizontalSizeClass: CGFloat = 64 + public static let containerViewMarginForCompactHorizontalSizeClass: CGFloat = 16 + + let logger = Logger(subsystem: "ProfileViewController", category: "ViewController") + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } @@ -145,7 +159,7 @@ extension ProfileViewController { view.backgroundColor = ThemeService.shared.currentTheme.value.secondarySystemBackgroundColor ThemeService.shared.currentTheme - .receive(on: RunLoop.main) + .receive(on: DispatchQueue.main) .sink { [weak self] theme in guard let self = self else { return } self.view.backgroundColor = theme.secondarySystemBackgroundColor @@ -221,12 +235,12 @@ extension ProfileViewController { return } - if !isReplyBarButtonItemHidden { - items.append(self.replyBarButtonItem) - } if !isMoreMenuBarButtonItemHidden { items.append(self.moreMenuBarButtonItem) } + if !isReplyBarButtonItemHidden { + items.append(self.replyBarButtonItem) + } } .store(in: &disposeBag) @@ -242,11 +256,14 @@ extension ProfileViewController { let mediaUserTimelineViewModel = UserTimelineViewModel(context: context, domain: viewModel.domain.value, userID: viewModel.userID.value, queryFilter: UserTimelineViewModel.QueryFilter(onlyMedia: true)) bind(userTimelineViewModel: mediaUserTimelineViewModel) + let profileAboutViewModel = ProfileAboutViewModel(context: context) + profileSegmentedViewController.pagingViewController.viewModel = { let profilePagingViewModel = ProfilePagingViewModel( postsUserTimelineViewModel: postsUserTimelineViewModel, repliesUserTimelineViewModel: repliesUserTimelineViewModel, - mediaUserTimelineViewModel: mediaUserTimelineViewModel + mediaUserTimelineViewModel: mediaUserTimelineViewModel, + profileAboutViewModel: profileAboutViewModel ) profilePagingViewModel.viewControllers.forEach { viewController in if let viewController = viewController as? NeedsDependency { @@ -257,12 +274,22 @@ extension ProfileViewController { return profilePagingViewModel }() - profileHeaderViewController.pageSegmentedControl.removeAllSegments() - profileSegmentedViewController.pagingViewController.viewModel.barItems.forEach { item in - let index = profileHeaderViewController.pageSegmentedControl.numberOfSegments - profileHeaderViewController.pageSegmentedControl.insertSegment(withTitle: item.title, at: index, animated: false) - } - profileHeaderViewController.pageSegmentedControl.selectedSegmentIndex = 0 + profileSegmentedViewController.pagingViewController.addBar( + profileHeaderViewController.buttonBar, + dataSource: profileSegmentedViewController.pagingViewController.viewModel, + at: .custom(view: profileHeaderViewController.view, layout: { buttonBar in + buttonBar.translatesAutoresizingMaskIntoConstraints = false + self.profileHeaderViewController.view.addSubview(buttonBar) + NSLayoutConstraint.activate([ + buttonBar.topAnchor.constraint(equalTo: self.profileHeaderViewController.profileHeaderView.bottomAnchor), + buttonBar.leadingAnchor.constraint(equalTo: self.profileHeaderViewController.view.leadingAnchor), + buttonBar.trailingAnchor.constraint(equalTo: self.profileHeaderViewController.view.trailingAnchor), + buttonBar.bottomAnchor.constraint(equalTo: self.profileHeaderViewController.view.bottomAnchor), + buttonBar.heightAnchor.constraint(equalToConstant: ProfileHeaderViewController.segmentedControlHeight).priority(.required - 1), + ]) + }) + ) + updateBarButtonInsets() overlayScrollView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(overlayScrollView) @@ -312,9 +339,153 @@ extension ProfileViewController { overlayScrollView.layer.zPosition = .greatestFiniteMagnitude // make vision top-most overlayScrollView.delegate = self profileHeaderViewController.delegate = self + profileSegmentedViewController.pagingViewController.viewModel.profileAboutViewController.delegate = self profileSegmentedViewController.pagingViewController.pagingDelegate = self // bind view model + bindProfile( + headerViewModel: profileHeaderViewController.viewModel, + aboutViewModel: profileAboutViewModel + ) + + bindTitleView() + bindHeader() + bindProfileRelationship() + bindProfileDashboard() + + viewModel.needsPagingEnabled + .receive(on: DispatchQueue.main) + .sink { [weak self] needsPaingEnabled in + guard let self = self else { return } + self.profileSegmentedViewController.pagingViewController.isScrollEnabled = needsPaingEnabled + } + .store(in: &disposeBag) + + profileHeaderViewController.profileHeaderView.delegate = self + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + // set back button tint color in SceneCoordinator.present(scene:from:transition:) + + // force layout to make banner image tweak take effect + view.layoutIfNeeded() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + viewModel.viewDidAppear.send() + + // set overlay scroll view initial content size + guard let currentViewController = profileSegmentedViewController.pagingViewController.currentViewController as? ScrollViewContainer, + let scrollView = currentViewController.scrollView + else { return } + + currentPostTimelineTableViewContentSizeObservation = observeTableViewContentSize(scrollView: scrollView) + scrollView.panGestureRecognizer.require(toFail: overlayScrollView.panGestureRecognizer) + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + currentPostTimelineTableViewContentSizeObservation = nil + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + updateBarButtonInsets() + } + +} + +extension ProfileViewController { + private func updateBarButtonInsets() { + let margin: CGFloat = { + switch traitCollection.userInterfaceIdiom { + case .phone: + return ProfileViewController.containerViewMarginForCompactHorizontalSizeClass + default: + return traitCollection.horizontalSizeClass == .regular ? + ProfileViewController.containerViewMarginForRegularHorizontalSizeClass : + ProfileViewController.containerViewMarginForCompactHorizontalSizeClass + } + }() + + profileHeaderViewController.buttonBar.layout.contentInset.left = margin + profileHeaderViewController.buttonBar.layout.contentInset.right = margin + } + +} + +extension ProfileViewController { + + private func bind(userTimelineViewModel: UserTimelineViewModel) { + viewModel.domain.assign(to: \.domain, on: userTimelineViewModel).store(in: &disposeBag) + viewModel.userID.assign(to: \.userID, on: userTimelineViewModel).store(in: &disposeBag) + viewModel.isBlocking.assign(to: \.value, on: userTimelineViewModel.isBlocking).store(in: &disposeBag) + viewModel.isBlockedBy.assign(to: \.value, on: userTimelineViewModel.isBlockedBy).store(in: &disposeBag) + viewModel.suspended.assign(to: \.value, on: userTimelineViewModel.isSuspended).store(in: &disposeBag) + viewModel.name.assign(to: \.value, on: userTimelineViewModel.userDisplayName).store(in: &disposeBag) + } + + private func bindProfile( + headerViewModel: ProfileHeaderViewModel, + aboutViewModel: ProfileAboutViewModel + ) { + // header + viewModel.avatarImageURL + .receive(on: DispatchQueue.main) + .assign(to: \.avatarImageURL, on: headerViewModel.displayProfileInfo) + .store(in: &disposeBag) + viewModel.name + .map { $0 ?? "" } + .receive(on: DispatchQueue.main) + .assign(to: \.name, on: headerViewModel.displayProfileInfo) + .store(in: &disposeBag) + viewModel.bioDescription + .receive(on: DispatchQueue.main) + .assign(to: \.note, on: headerViewModel.displayProfileInfo) + .store(in: &disposeBag) + + // about + Publishers.CombineLatest( + viewModel.fields.removeDuplicates(), + viewModel.emojiMeta.removeDuplicates() + ) + .map { fields, emojiMeta -> [ProfileFieldItem.FieldValue] in + fields.map { ProfileFieldItem.FieldValue(name: $0.name, value: $0.value, emojiMeta: emojiMeta) } + } + .receive(on: DispatchQueue.main) + .assign(to: \.fields, on: aboutViewModel.displayProfileInfo) + .store(in: &disposeBag) + + // common + viewModel.accountForEdit + .assign(to: \.accountForEdit, on: headerViewModel) + .store(in: &disposeBag) + viewModel.accountForEdit + .assign(to: \.accountForEdit, on: aboutViewModel) + .store(in: &disposeBag) + viewModel.emojiMeta + .receive(on: DispatchQueue.main) + .assign(to: \.emojiMeta, on: headerViewModel) + .store(in: &disposeBag) + viewModel.emojiMeta + .receive(on: DispatchQueue.main) + .assign(to: \.emojiMeta, on: aboutViewModel) + .store(in: &disposeBag) + viewModel.isEditing + .assign(to: \.isEditing, on: headerViewModel) + .store(in: &disposeBag) + viewModel.isEditing + .assign(to: \.isEditing, on: aboutViewModel) + .store(in: &disposeBag) + } + + private func bindTitleView() { Publishers.CombineLatest3( viewModel.name, viewModel.emojiMeta, @@ -346,7 +517,10 @@ extension ProfileViewController { self.navigationItem.title = name } .store(in: &disposeBag) - + } + + private func bindHeader() { + // heaer UI Publishers.CombineLatest( viewModel.bannerImageURL.eraseToAnyPublisher(), viewModel.viewDidAppear.eraseToAnyPublisher() @@ -377,72 +551,96 @@ extension ProfileViewController { ) } .store(in: &disposeBag) - viewModel.avatarImageURL - .receive(on: DispatchQueue.main) - .map { url in ProfileHeaderViewModel.ProfileInfo.ImageResource.url(url) } - .assign(to: \.value, on: profileHeaderViewController.viewModel.displayProfileInfo.avatarImageResource) - .store(in: &disposeBag) - viewModel.name - .map { $0 ?? "" } - .receive(on: DispatchQueue.main) - .assign(to: \.value, on: profileHeaderViewController.viewModel.displayProfileInfo.name) - .store(in: &disposeBag) - viewModel.fields - .removeDuplicates() - .map { fields -> [ProfileFieldItem.FieldValue] in - fields.map { ProfileFieldItem.FieldValue(name: $0.name, value: $0.value) } - } - .receive(on: DispatchQueue.main) - .assign(to: \.value, on: profileHeaderViewController.viewModel.displayProfileInfo.fields) - .store(in: &disposeBag) - viewModel.accountForEdit - .assign(to: \.value, on: profileHeaderViewController.viewModel.accountForEdit) - .store(in: &disposeBag) - viewModel.emojiMeta - .receive(on: DispatchQueue.main) - .assign(to: \.value, on: profileHeaderViewController.viewModel.emojiMeta) - .store(in: &disposeBag) + viewModel.username .map { username in username.flatMap { "@" + $0 } ?? " " } .receive(on: DispatchQueue.main) .assign(to: \.text, on: profileHeaderViewController.profileHeaderView.usernameLabel) .store(in: &disposeBag) + + viewModel.isEditing + .receive(on: DispatchQueue.main) + .sink { [weak self] isEditing in + guard let self = self else { return } + // set first responder for key command + if !isEditing { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + self.profileSegmentedViewController.pagingViewController.becomeFirstResponder() + } + } + + // dismiss keyboard if needs + if !isEditing { self.view.endEditing(true) } + + self.profileHeaderViewController.buttonBar.isUserInteractionEnabled = !isEditing + if isEditing { + // scroll to About page + self.profileSegmentedViewController.pagingViewController.scrollToPage( + .last, + animated: true, + completion: nil + ) + self.profileSegmentedViewController.pagingViewController.isScrollEnabled = false + } else { + self.profileSegmentedViewController.pagingViewController.isScrollEnabled = true + } + + let animator = UIViewPropertyAnimator(duration: 0.33, curve: .easeInOut) + animator.addAnimations { + self.profileHeaderViewController.profileHeaderView.statusDashboardView.alpha = isEditing ? 0.2 : 1.0 + } + animator.startAnimation() + } + .store(in: &disposeBag) + + viewModel.needsImageOverlayBlurred + .receive(on: DispatchQueue.main) + .sink { [weak self] needsImageOverlayBlurred in + guard let self = self else { return } + UIView.animate(withDuration: 0.33) { + let bannerEffect: UIVisualEffect? = needsImageOverlayBlurred ? ProfileHeaderView.bannerImageViewOverlayBlurEffect : nil + self.profileHeaderViewController.profileHeaderView.bannerImageViewOverlayVisualEffectView.effect = bannerEffect + let avatarEffect: UIVisualEffect? = needsImageOverlayBlurred ? ProfileHeaderView.avatarImageViewOverlayBlurEffect : nil + self.profileHeaderViewController.profileHeaderView.avatarImageViewOverlayVisualEffectView.effect = avatarEffect + } + } + .store(in: &disposeBag) + } + + private func bindProfileRelationship() { Publishers.CombineLatest( - viewModel.relationshipActionOptionSet, - viewModel.context.blockDomainService.blockedDomains + viewModel.$user, + viewModel.relationshipActionOptionSet ) - .receive(on: DispatchQueue.main) - .sink { [weak self] relationshipActionOptionSet,domains in + .asyncMap { [weak self] user, relationshipSet -> UIMenu? in + guard let self = self else { return nil } + guard let user = user else { + return nil + } + let name = user.displayNameWithFallback + let record = ManagedObjectRecord<MastodonUser>(objectID: user.objectID) + let menu = MastodonMenu.setupMenu( + actions: [ + .muteUser(.init(name: name, isMuting: self.viewModel.isMuting.value)), + .blockUser(.init(name: name, isBlocking: self.viewModel.isBlocking.value)), + .reportUser(.init(name: name)), + .shareUser(.init(name: name)), + ], + delegate: self + ) + return menu + } + .sink { [weak self] completion in guard let self = self else { return } - guard let mastodonUser = self.viewModel.mastodonUser.value else { + switch completion { + case .failure(let error): self.moreMenuBarButtonItem.menu = nil - return + case .finished: + break } - guard let currentMastodonUser = self.viewModel.currentMastodonUser.value else { - self.moreMenuBarButtonItem.menu = nil - return - } - guard let currentDomain = self.viewModel.domain.value else { return } - let isMuting = relationshipActionOptionSet.contains(.muting) - let isBlocking = relationshipActionOptionSet.contains(.blocking) - let isDomainBlocking = domains.contains(mastodonUser.domainFromAcct) - let needsShareAction = self.viewModel.isMeBarButtonItemsHidden.value - let isInSameDomain = mastodonUser.domainFromAcct == currentDomain - let isMyself = currentMastodonUser.id == mastodonUser.id - - self.moreMenuBarButtonItem.menu = UserProviderFacade.createProfileActionMenu( - for: mastodonUser, - isMyself: isMyself, - isMuting: isMuting, - isBlocking: isBlocking, - isInSameDomain: isInSameDomain, - isDomainBlocking: isDomainBlocking, - provider: self, - cell: nil, - sourceView: nil, - barButtonItem: self.moreMenuBarButtonItem, - shareUser: needsShareAction ? mastodonUser : nil, - shareStatus: nil) + } receiveValue: { [weak self] menu in + guard let self = self else { return } + self.moreMenuBarButtonItem.menu = menu } .store(in: &disposeBag) @@ -450,9 +648,10 @@ extension ProfileViewController { .receive(on: DispatchQueue.main) .sink { [weak self] isHidden in guard let self = self else { return } - self.profileHeaderViewController.profileHeaderView.relationshipActionButton.isHidden = isHidden + self.profileHeaderViewController.profileHeaderView.relationshipActionButtonShadowContainer.isHidden = isHidden } .store(in: &disposeBag) + Publishers.CombineLatest3( viewModel.relationshipActionOptionSet.eraseToAnyPublisher(), viewModel.isEditing.eraseToAnyPublisher(), @@ -471,31 +670,7 @@ extension ProfileViewController { } } .store(in: &disposeBag) - viewModel.isEditing - .handleEvents(receiveOutput: { [weak self] isEditing in - guard let self = self else { return } - // set first responder for key command - if !isEditing { - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - self.profileSegmentedViewController.pagingViewController.becomeFirstResponder() - } - } - - // dismiss keyboard if needs - if !isEditing { self.view.endEditing(true) } - - self.profileHeaderViewController.pageSegmentedControl.isEnabled = !isEditing - self.profileSegmentedViewController.view.isUserInteractionEnabled = !isEditing - - let animator = UIViewPropertyAnimator(duration: 0.33, curve: .easeInOut) - animator.addAnimations { - self.profileSegmentedViewController.view.alpha = isEditing ? 0.2 : 1.0 - self.profileHeaderViewController.profileHeaderView.statusDashboardView.alpha = isEditing ? 0.2 : 1.0 - } - animator.startAnimation() - }) - .assign(to: \.value, on: profileHeaderViewController.viewModel.isEditing) - .store(in: &disposeBag) + Publishers.CombineLatest3( viewModel.isBlocking.eraseToAnyPublisher(), viewModel.isBlockedBy.eraseToAnyPublisher(), @@ -508,14 +683,13 @@ extension ProfileViewController { self.profileHeaderViewController.viewModel.needsSetupBottomShadow.value = !isNeedSetHidden self.profileHeaderViewController.profileHeaderView.bioContainerView.isHidden = isNeedSetHidden self.profileHeaderViewController.viewModel.needsFiledCollectionViewHidden.value = isNeedSetHidden - self.profileHeaderViewController.pageSegmentedControl.isEnabled = !isNeedSetHidden + self.profileHeaderViewController.buttonBar.isUserInteractionEnabled = !isNeedSetHidden self.viewModel.needsPagePinToTop.value = isNeedSetHidden } .store(in: &disposeBag) - viewModel.bioDescription - .receive(on: DispatchQueue.main) - .assign(to: \.value, on: profileHeaderViewController.viewModel.displayProfileInfo.note) - .store(in: &disposeBag) + } // end func bindProfileRelationship + + private func bindProfileDashboard() { viewModel.statusesCount .receive(on: DispatchQueue.main) .sink { [weak self] count in @@ -546,68 +720,25 @@ extension ProfileViewController { self.profileHeaderViewController.profileHeaderView.statusDashboardView.followersDashboardMeterView.accessibilityLabel = L10n.Plural.Count.follower(count ?? 0) } .store(in: &disposeBag) - viewModel.needsPagingEnabled - .receive(on: RunLoop.main) - .sink { [weak self] needsPaingEnabled in - guard let self = self else { return } - self.profileSegmentedViewController.pagingViewController.isScrollEnabled = needsPaingEnabled - } - .store(in: &disposeBag) - viewModel.needsImageOverlayBlurred - .receive(on: RunLoop.main) - .sink { [weak self] needsImageOverlayBlurred in - guard let self = self else { return } - UIView.animate(withDuration: 0.33) { - let bannerEffect: UIVisualEffect? = needsImageOverlayBlurred ? ProfileHeaderView.bannerImageViewOverlayBlurEffect : nil - self.profileHeaderViewController.profileHeaderView.bannerImageViewOverlayVisualEffectView.effect = bannerEffect - let avatarEffect: UIVisualEffect? = needsImageOverlayBlurred ? ProfileHeaderView.avatarImageViewOverlayBlurEffect : nil - self.profileHeaderViewController.profileHeaderView.avatarImageViewOverlayVisualEffectView.effect = avatarEffect - } - } - .store(in: &disposeBag) - - profileHeaderViewController.profileHeaderView.delegate = self } - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - - // set back button tint color in SceneCoordinator.present(scene:from:transition:) - - // force layout to make banner image tweak take effect - view.layoutIfNeeded() + private func handleMetaPress(_ meta: Meta) { + switch meta { + case .url(_, _, let url, _): + guard let url = URL(string: url) else { return } + coordinator.present(scene: .safari(url: url), from: nil, transition: .safariPresent(animated: true, completion: nil)) + case .mention(_, _, let userInfo): + guard let href = userInfo?["href"] as? String, + let url = URL(string: href) else { return } + coordinator.present(scene: .safari(url: url), from: nil, transition: .safariPresent(animated: true, completion: nil)) + case .hashtag(_, let hashtag, _): + let hashtagTimelineViewModel = HashtagTimelineViewModel(context: context, hashtag: hashtag) + coordinator.present(scene: .hashtagTimeline(viewModel: hashtagTimelineViewModel), from: nil, transition: .show) + case .email, .emoji: + break + } } - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - viewModel.viewDidAppear.send() - - // set overlay scroll view initial content size - guard let currentViewController = profileSegmentedViewController.pagingViewController.currentViewController as? ScrollViewContainer else { return } - currentPostTimelineTableViewContentSizeObservation = observeTableViewContentSize(scrollView: currentViewController.scrollView) - currentViewController.scrollView.panGestureRecognizer.require(toFail: overlayScrollView.panGestureRecognizer) - } - - override func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - - currentPostTimelineTableViewContentSizeObservation = nil - } - -} - -extension ProfileViewController { - - private func bind(userTimelineViewModel: UserTimelineViewModel) { - viewModel.domain.assign(to: \.value, on: userTimelineViewModel.domain).store(in: &disposeBag) - viewModel.userID.assign(to: \.value, on: userTimelineViewModel.userID).store(in: &disposeBag) - viewModel.isBlocking.assign(to: \.value, on: userTimelineViewModel.isBlocking).store(in: &disposeBag) - viewModel.isBlockedBy.assign(to: \.value, on: userTimelineViewModel.isBlockedBy).store(in: &disposeBag) - viewModel.suspended.assign(to: \.value, on: userTimelineViewModel.isSuspended).store(in: &disposeBag) - viewModel.name.assign(to: \.value, on: userTimelineViewModel.userDisplayName).store(in: &disposeBag) - } - } extension ProfileViewController { @@ -626,17 +757,24 @@ extension ProfileViewController { @objc private func shareBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - guard let mastodonUser = viewModel.mastodonUser.value else { return } - let activityViewController = UserProviderFacade.createActivityViewControllerForMastodonUser(mastodonUser: mastodonUser, dependency: self) - coordinator.present( - scene: .activityViewController( - activityViewController: activityViewController, - sourceView: nil, - barButtonItem: sender - ), - from: self, - transition: .activityViewControllerPresent(animated: true, completion: nil) - ) + guard let user = viewModel.user else { return } + let record: ManagedObjectRecord<MastodonUser> = .init(objectID: user.objectID) + Task { + let _activityViewController = try await DataSourceFacade.createActivityViewController( + dependency: self, + user: record + ) + guard let activityViewController = _activityViewController else { return } + self.coordinator.present( + scene: .activityViewController( + activityViewController: activityViewController, + sourceView: nil, + barButtonItem: sender + ), + from: self, + transition: .activityViewControllerPresent(animated: true, completion: nil) + ) + } // end Task } @objc private func favoriteBarButtonItemPressed(_ sender: UIBarButtonItem) { @@ -647,10 +785,12 @@ extension ProfileViewController { @objc private func replyBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - guard let mastodonUser = viewModel.mastodonUser.value else { return } + guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } + guard let mastodonUser = viewModel.user else { return } let composeViewModel = ComposeViewModel( context: context, - composeKind: .mention(mastodonUserObjectID: mastodonUser.objectID) + composeKind: .mention(user: .init(objectID: mastodonUser.objectID)), + authenticationBox: authenticationBox ) coordinator.present(scene: .compose(viewModel: composeViewModel), from: self, transition: .modal(animated: true, completion: nil)) } @@ -679,7 +819,7 @@ extension ProfileViewController: UIScrollViewDelegate { if scrollView.contentOffset.y < topMaxContentOffsetY { self.containerScrollView.contentOffset.y = scrollView.contentOffset.y for postTimelineView in profileSegmentedViewController.pagingViewController.viewModel.viewControllers { - postTimelineView.scrollView.contentOffset.y = 0 + postTimelineView.scrollView?.contentOffset.y = 0 } contentOffsets.removeAll() } else { @@ -689,14 +829,14 @@ extension ProfileViewController: UIScrollViewDelegate { } else { if let customScrollViewContainerController = profileSegmentedViewController.pagingViewController.currentViewController as? ScrollViewContainer { let contentOffsetY = scrollView.contentOffset.y - containerScrollView.contentOffset.y - customScrollViewContainerController.scrollView.contentOffset.y = contentOffsetY + customScrollViewContainerController.scrollView?.contentOffset.y = contentOffsetY } } } // elastically banner image - let headerScrollProgress = containerScrollView.contentOffset.y / topMaxContentOffsetY + let headerScrollProgress = (containerScrollView.contentOffset.y - containerScrollView.safeAreaInsets.top) / topMaxContentOffsetY let throttle = ProfileHeaderViewController.headerMinHeight / topMaxContentOffsetY profileHeaderViewController.updateHeaderScrollProgress(headerScrollProgress, throttle: throttle) } @@ -715,35 +855,6 @@ extension ProfileViewController: ProfileHeaderViewControllerDelegate { updateOverlayScrollViewContentSize(scrollView: scrollView) } - func profileHeaderViewController(_ viewController: ProfileHeaderViewController, pageSegmentedControlValueChanged segmentedControl: UISegmentedControl, selectedSegmentIndex index: Int) { - profileSegmentedViewController.pagingViewController.scrollToPage( - .at(index: index), - animated: true - ) - } - - func profileHeaderViewController(_ viewController: ProfileHeaderViewController, profileFieldCollectionViewCell: ProfileFieldCollectionViewCell, metaLabel: MetaLabel, didSelectMeta meta: Meta) { - switch meta { - case .url(_, _, let url, _): - guard let url = URL(string: url) else { return } - coordinator.present(scene: .safari(url: url), from: nil, transition: .safariPresent(animated: true, completion: nil)) - case .hashtag(_, let hashtag, _): - let hashtagTimelineViewModel = HashtagTimelineViewModel(context: context, hashtag: hashtag) - coordinator.present(scene: .hashtagTimeline(viewModel: hashtagTimelineViewModel), from: nil, transition: .show) - case .mention(_, _, let userInfo): - guard let href = userInfo?["href"] as? String else { - // currently we cannot present profile scene without userID - return - } - guard let url = URL(string: href) else { return } - coordinator.present(scene: .safari(url: url), from: nil, transition: .safariPresent(animated: true, completion: nil)) - case .email: - break - case .emoji: - break - } - } - } // MARK: - ProfilePagingViewControllerDelegate @@ -752,90 +863,61 @@ extension ProfileViewController: ProfilePagingViewControllerDelegate { func profilePagingViewController(_ viewController: ProfilePagingViewController, didScrollToPostCustomScrollViewContainerController postTimelineViewController: ScrollViewContainer, atIndex index: Int) { os_log("%{public}s[%{public}ld], %{public}s: select at index: %ld", ((#file as NSString).lastPathComponent), #line, #function, index) - // update segemented control - if index < profileHeaderViewController.pageSegmentedControl.numberOfSegments { - profileHeaderViewController.pageSegmentedControl.selectedSegmentIndex = index - } +// // update segemented control +// if index < profileHeaderViewController.pageSegmentedControl.numberOfSegments { +// profileHeaderViewController.pageSegmentedControl.selectedSegmentIndex = index +// } // save content offset overlayScrollView.contentOffset.y = contentOffsets[index] ?? containerScrollView.contentOffset.y // setup observer and gesture fallback - currentPostTimelineTableViewContentSizeObservation = observeTableViewContentSize(scrollView: postTimelineViewController.scrollView) - postTimelineViewController.scrollView.panGestureRecognizer.require(toFail: overlayScrollView.panGestureRecognizer) + if let scrollView = postTimelineViewController.scrollView { + currentPostTimelineTableViewContentSizeObservation = observeTableViewContentSize(scrollView: scrollView) + scrollView.panGestureRecognizer.require(toFail: overlayScrollView.panGestureRecognizer) + } } } // 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 } + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, avatarButtonDidPressed button: AvatarButton) { + guard let user = viewModel.user else { return } + let record: ManagedObjectRecord<MastodonUser> = .init(objectID: user.objectID) - let meta = MediaPreviewViewModel.ProfileAvatarImagePreviewMeta( - accountObjectID: mastodonUser.objectID, - preloadThumbnailImage: avatar - ) - let pushTransitionItem = MediaPreviewTransitionItem( - source: .profileAvatar(profileHeaderView), - previewableViewController: self - ) - pushTransitionItem.aspectRatio = CGSize(width: 100, height: 100) - pushTransitionItem.sourceImageView = imageView - pushTransitionItem.sourceImageViewCornerRadius = ProfileHeaderView.avatarImageViewCornerRadius - pushTransitionItem.initialFrame = { - let initialFrame = imageView.superview!.convert(imageView.frame, to: nil) - assert(initialFrame != .zero) - return initialFrame - }() - pushTransitionItem.image = avatar - - let mediaPreviewViewModel = MediaPreviewViewModel( - context: context, - meta: meta, - pushTransitionItem: pushTransitionItem - ) - DispatchQueue.main.async { - self.coordinator.present(scene: .mediaPreview(viewModel: mediaPreviewViewModel), from: self, transition: .custom(transitioningDelegate: self.mediaPreviewTransitionController)) - } + Task { + try await DataSourceFacade.coordinateToMediaPreviewScene( + dependency: self, + user: record, + previewContext: DataSourceFacade.ImagePreviewContext( + imageView: button.avatarImageView, + containerView: .profileAvatar(profileHeaderView) + ) + ) + } // end Task } func profileHeaderView(_ profileHeaderView: ProfileHeaderView, bannerImageViewDidPressed imageView: UIImageView) { - // not preview header banner when editing - guard !viewModel.isEditing.value else { return } + guard let user = viewModel.user else { return } + let record: ManagedObjectRecord<MastodonUser> = .init(objectID: user.objectID) - guard let mastodonUser = viewModel.mastodonUser.value else { return } - guard let header = imageView.image else { return } - - let meta = MediaPreviewViewModel.ProfileBannerImagePreviewMeta( - accountObjectID: mastodonUser.objectID, - preloadThumbnailImage: header - ) - let pushTransitionItem = MediaPreviewTransitionItem( - source: .profileBanner(profileHeaderView), - previewableViewController: self - ) - pushTransitionItem.aspectRatio = header.size - pushTransitionItem.sourceImageView = imageView - pushTransitionItem.initialFrame = { - let initialFrame = imageView.superview!.convert(imageView.frame, to: nil) - assert(initialFrame != .zero) - return initialFrame - }() - pushTransitionItem.image = header - - let mediaPreviewViewModel = MediaPreviewViewModel( - context: context, - meta: meta, - pushTransitionItem: pushTransitionItem - ) - DispatchQueue.main.async { - self.coordinator.present(scene: .mediaPreview(viewModel: mediaPreviewViewModel), from: self, transition: .custom(transitioningDelegate: self.mediaPreviewTransitionController)) - } + Task { + try await DataSourceFacade.coordinateToMediaPreviewScene( + dependency: self, + user: record, + previewContext: DataSourceFacade.ImagePreviewContext( + imageView: imageView, + containerView: .profileBanner(profileHeaderView) + ) + ) + } // end Task } - func profileHeaderView(_ profileHeaderView: ProfileHeaderView, relationshipButtonDidPressed button: ProfileRelationshipActionButton) { + func profileHeaderView( + _ profileHeaderView: ProfileHeaderView, + relationshipButtonDidPressed button: ProfileRelationshipActionButton + ) { let relationshipActionSet = viewModel.relationshipActionOptionSet.value // handle edit logic for editable profile @@ -843,34 +925,37 @@ extension ProfileViewController: ProfileHeaderViewDelegate { if relationshipActionSet.contains(.edit) { // do nothing when updating guard !viewModel.isUpdating.value else { return } - - if profileHeaderViewController.viewModel.isProfileInfoEdited() { + + guard let profileHeaderViewModel = profileHeaderViewController.viewModel else { return } + guard let profileAboutViewModel = profileSegmentedViewController.pagingViewController.viewModel.profileAboutViewController.viewModel else { return } + + let isEdited = profileHeaderViewModel.isEdited() + || profileAboutViewModel.isEdited() + + if isEdited { // update profile if changed viewModel.isUpdating.value = true - profileHeaderViewController.viewModel.updateProfileInfo() - .receive(on: DispatchQueue.main) - .sink { [weak self] completion in - guard let self = self else { return } - defer { - // finish updating - self.viewModel.isUpdating.value = false - } - switch completion { - case .failure(let error): - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: update profile info fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) - case .finished: - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: update profile info success", ((#file as NSString).lastPathComponent), #line, #function) - } - } receiveValue: { [weak self] _ in - guard let self = self else { return } + Task { + do { + _ = try await viewModel.updateProfileInfo( + headerProfileInfo: profileHeaderViewModel.editProfileInfo, + aboutProfileInfo: profileAboutViewModel.editProfileInfo + ) + self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): update profile info success") self.viewModel.isEditing.value = false + + } catch { + self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): update profile info fail: \(error.localizedDescription)") } - .store(in: &disposeBag) + + // finish updating + self.viewModel.isUpdating.value = false + } } else { // set `updating` then toggle `edit` state viewModel.isUpdating.value = true viewModel.fetchEditProfileInfo() - .receive(on: RunLoop.main) + .receive(on: DispatchQueue.main) .sink { [weak self] completion in guard let self = self else { return } defer { @@ -905,52 +990,61 @@ extension ProfileViewController: ProfileHeaderViewDelegate { case .none: break case .follow, .request, .pending, .following: - UserProviderFacade.toggleUserFollowRelationship(provider: self) - .sink { _ in - // TODO: handle error - } receiveValue: { _ in - // do nothing - } - .store(in: &disposeBag) + guard let user = viewModel.user else { return } + let reocrd = ManagedObjectRecord<MastodonUser>(objectID: user.objectID) + guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } + Task { + try await DataSourceFacade.responseToUserFollowAction( + dependency: self, + user: reocrd, + authenticationBox: authenticationBox + ) + } case .muting: - guard let mastodonUser = viewModel.mastodonUser.value else { return } - let name = mastodonUser.displayNameWithFallback + guard let authenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { return } + guard let user = viewModel.user else { return } + let name = user.displayNameWithFallback + let alertController = UIAlertController( title: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.title, message: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.message(name), preferredStyle: .alert ) + let record = ManagedObjectRecord<MastodonUser>(objectID: user.objectID) let unmuteAction = UIAlertAction(title: L10n.Common.Controls.Friendship.unmute, style: .default) { [weak self] _ in guard let self = self else { return } - UserProviderFacade.toggleUserMuteRelationship(provider: self, cell: nil) - .sink { _ in - // do nothing - } receiveValue: { _ in - // do nothing - } - .store(in: &self.context.disposeBag) + Task { + try await DataSourceFacade.responseToUserMuteAction( + dependency: self, + user: record, + authenticationBox: authenticationBox + ) + } } alertController.addAction(unmuteAction) let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel, handler: nil) alertController.addAction(cancelAction) present(alertController, animated: true, completion: nil) case .blocking: - guard let mastodonUser = viewModel.mastodonUser.value else { return } - let name = mastodonUser.displayNameWithFallback + guard let authenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { return } + guard let user = viewModel.user else { return } + let name = user.displayNameWithFallback + let alertController = UIAlertController( - title: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.title, - message: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.message(name), + title: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnblockUser.title, + message: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnblockUser.message(name), preferredStyle: .alert ) + let record = ManagedObjectRecord<MastodonUser>(objectID: user.objectID) let unblockAction = UIAlertAction(title: L10n.Common.Controls.Friendship.unblock, style: .default) { [weak self] _ in guard let self = self else { return } - UserProviderFacade.toggleUserBlockRelationship(provider: self, cell: nil) - .sink { _ in - // do nothing - } receiveValue: { _ in - // do nothing - } - .store(in: &self.context.disposeBag) + Task { + try await DataSourceFacade.responseToUserBlockAction( + dependency: self, + user: record, + authenticationBox: authenticationBox + ) + } } alertController.addAction(unblockAction) let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel, handler: nil) @@ -965,20 +1059,7 @@ extension ProfileViewController: ProfileHeaderViewDelegate { } func profileHeaderView(_ profileHeaderView: ProfileHeaderView, metaTextView: MetaTextView, metaDidPressed meta: Meta) { - switch meta { - case .url(_, _, let url, _): - guard let url = URL(string: url) else { return } - coordinator.present(scene: .safari(url: url), from: nil, transition: .safariPresent(animated: true, completion: nil)) - case .mention(_, _, let userInfo): - guard let href = userInfo?["href"] as? String, - let url = URL(string: href) else { return } - coordinator.present(scene: .safari(url: url), from: nil, transition: .safariPresent(animated: true, completion: nil)) - case .hashtag(_, let hashtag, _): - let hashtagTimelineViewModel = HashtagTimelineViewModel(context: context, hashtag: hashtag) - coordinator.present(scene: .hashtagTimeline(viewModel: hashtagTimelineViewModel), from: nil, transition: .show) - case .email, .emoji: - break - } + handleMetaPress(meta) } func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView dashboardView: ProfileStatusDashboardView, dashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView, meter: ProfileStatusDashboardView.Meter) { @@ -1019,30 +1100,63 @@ extension ProfileViewController: ProfileHeaderViewDelegate { } +// MARK: - ProfileAboutViewControllerDelegate +extension ProfileViewController: ProfileAboutViewControllerDelegate { + func profileAboutViewController(_ viewController: ProfileAboutViewController, profileFieldCollectionViewCell: ProfileFieldCollectionViewCell, metaLabel: MetaLabel, didSelectMeta meta: Meta) { + handleMetaPress(meta) + } +} + +// MARK: - MastodonMenuDelegate +extension ProfileViewController: MastodonMenuDelegate { + func menuAction(_ action: MastodonMenu.Action) { + guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } + guard let user = viewModel.user else { return } + + let userRecord: ManagedObjectRecord<MastodonUser> = .init(objectID: user.objectID) + + Task { + try await DataSourceFacade.responseToMenuAction( + dependency: self, + action: action, + menuContext: DataSourceFacade.MenuContext( + author: userRecord, + status: nil, + button: nil, + barButtonItem: self.moreMenuBarButtonItem + ), + authenticationBox: authenticationBox + ) + } // end Task + } +} + // MARK: - ScrollViewContainer extension ProfileViewController: ScrollViewContainer { - var scrollView: UIScrollView { return overlayScrollView } + var scrollView: UIScrollView? { + return overlayScrollView + } } -extension ProfileViewController { - - override var keyCommands: [UIKeyCommand]? { - if !viewModel.isEditing.value { - return segmentedControlNavigateKeyCommands - } - - return nil - } - -} +//extension ProfileViewController { +// +// override var keyCommands: [UIKeyCommand]? { +// if !viewModel.isEditing.value { +// return segmentedControlNavigateKeyCommands +// } +// +// return nil +// } +// +//} // MARK: - SegmentedControlNavigateable -extension ProfileViewController: SegmentedControlNavigateable { - var navigateableSegmentedControl: UISegmentedControl { - profileHeaderViewController.pageSegmentedControl - } - - @objc func segmentedControlNavigateKeyCommandHandlerRelay(_ sender: UIKeyCommand) { - segmentedControlNavigateKeyCommandHandler(sender) - } -} +//extension ProfileViewController: SegmentedControlNavigateable { +// var navigateableSegmentedControl: UISegmentedControl { +// profileHeaderViewController.pageSegmentedControl +// } +// +// @objc func segmentedControlNavigateKeyCommandHandlerRelay(_ sender: UIKeyCommand) { +// segmentedControlNavigateKeyCommandHandler(sender) +// } +//} diff --git a/Mastodon/Scene/Profile/ProfileViewModel.swift b/Mastodon/Scene/Profile/ProfileViewModel.swift index 5efbaa684..403437daf 100644 --- a/Mastodon/Scene/Profile/ProfileViewModel.swift +++ b/Mastodon/Scene/Profile/ProfileViewModel.swift @@ -11,10 +11,14 @@ import Combine import CoreDataStack import MastodonSDK import MastodonMeta +import MastodonAsset +import MastodonLocalization // please override this base class class ProfileViewModel: NSObject { + let logger = Logger(subsystem: "ProfileViewModel", category: "ViewModel") + typealias UserID = String var disposeBag = Set<AnyCancellable>() @@ -24,8 +28,8 @@ class ProfileViewModel: NSObject { // input let context: AppContext - let mastodonUser: CurrentValueSubject<MastodonUser?, Never> - let currentMastodonUser = CurrentValueSubject<MastodonUser?, Never>(nil) + @Published var me: MastodonUser? + @Published var user: MastodonUser? let viewDidAppear = PassthroughSubject<Void, Never>() // output @@ -40,7 +44,7 @@ class ProfileViewModel: NSObject { let statusesCount: CurrentValueSubject<Int?, Never> let followingCount: CurrentValueSubject<Int?, Never> let followersCount: CurrentValueSubject<Int?, Never> - let fields: CurrentValueSubject<[Mastodon.Entity.Field], Never> + let fields: CurrentValueSubject<[MastodonField], Never> let emojiMeta: CurrentValueSubject<MastodonContent.Emojis, Never> // fulfill this before editing @@ -69,7 +73,7 @@ class ProfileViewModel: NSObject { init(context: AppContext, optionalMastodonUser mastodonUser: MastodonUser?) { self.context = context - self.mastodonUser = CurrentValueSubject(mastodonUser) + self.user = mastodonUser self.domain = CurrentValueSubject(context.authenticationService.activeMastodonAuthenticationBox.value?.domain) self.userID = CurrentValueSubject(mastodonUser?.id) self.bannerImageURL = CurrentValueSubject(mastodonUser?.headerImageURL()) @@ -78,13 +82,13 @@ class ProfileViewModel: NSObject { self.username = CurrentValueSubject(mastodonUser?.acctWithDomain) self.bioDescription = CurrentValueSubject(mastodonUser?.note) self.url = CurrentValueSubject(mastodonUser?.url) - self.statusesCount = CurrentValueSubject(mastodonUser.flatMap { Int(truncating: $0.statusesCount) }) - self.followingCount = CurrentValueSubject(mastodonUser.flatMap { Int(truncating: $0.followingCount) }) - self.followersCount = CurrentValueSubject(mastodonUser.flatMap { Int(truncating: $0.followersCount) }) + self.statusesCount = CurrentValueSubject(mastodonUser.flatMap { Int($0.statusesCount) }) + self.followingCount = CurrentValueSubject(mastodonUser.flatMap { Int($0.followingCount) }) + self.followersCount = CurrentValueSubject(mastodonUser.flatMap { Int($0.followersCount) }) self.protected = CurrentValueSubject(mastodonUser?.locked) self.suspended = CurrentValueSubject(mastodonUser?.suspended ?? false) self.fields = CurrentValueSubject(mastodonUser?.fields ?? []) - self.emojiMeta = CurrentValueSubject(mastodonUser?.emojiMeta ?? [:]) + self.emojiMeta = CurrentValueSubject(mastodonUser?.emojis.asDictionary ?? [:]) super.init() relationshipActionOptionSet @@ -94,65 +98,59 @@ class ProfileViewModel: NSObject { .store(in: &disposeBag) // bind active authentication - context.authenticationService.activeMastodonAuthentication - .sink { [weak self] activeMastodonAuthentication in + context.authenticationService.activeMastodonAuthenticationBox + .sink { [weak self] authenticationBox in guard let self = self else { return } - guard let activeMastodonAuthentication = activeMastodonAuthentication else { + guard let authenticationBox = authenticationBox else { self.domain.value = nil - self.currentMastodonUser.value = nil + self.me = nil return } - self.domain.value = activeMastodonAuthentication.domain - self.currentMastodonUser.value = activeMastodonAuthentication.user + self.domain.value = authenticationBox.domain + self.me = authenticationBox.authenticationRecord.object(in: context.managedObjectContext)?.user } .store(in: &disposeBag) // query relationship - let mastodonUserID = self.mastodonUser.map { $0?.id } + let userRecord = $user.map { user -> ManagedObjectRecord<MastodonUser>? in + user.flatMap { ManagedObjectRecord<MastodonUser>(objectID: $0.objectID) } + } let pendingRetryPublisher = CurrentValueSubject<TimeInterval, Never>(1) - + + // observe friendship Publishers.CombineLatest3( - mastodonUserID.removeDuplicates().eraseToAnyPublisher(), - context.authenticationService.activeMastodonAuthenticationBox.eraseToAnyPublisher(), - pendingRetryPublisher.eraseToAnyPublisher() + userRecord, + context.authenticationService.activeMastodonAuthenticationBox, + pendingRetryPublisher ) - .compactMap { mastodonUserID, activeMastodonAuthenticationBox, _ -> (String, MastodonAuthenticationBox)? in - guard let mastodonUserID = mastodonUserID, let activeMastodonAuthenticationBox = activeMastodonAuthenticationBox else { return nil } - guard mastodonUserID != activeMastodonAuthenticationBox.userID else { return nil } - return (mastodonUserID, activeMastodonAuthenticationBox) - } - .setFailureType(to: Error.self) // allow failure - .flatMap { mastodonUserID, activeMastodonAuthenticationBox -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Relationship]>, Error> in - let domain = activeMastodonAuthenticationBox.domain - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] fetch for user %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUserID) - - return self.context.apiService.relationship(domain: domain, accountIDs: [mastodonUserID], authorizationBox: activeMastodonAuthenticationBox) - //.retry(3) - .eraseToAnyPublisher() - } - .sink { completion in - switch completion { - case .failure(let error): - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) - case .finished: - break - } - } receiveValue: { response in - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update success", ((#file as NSString).lastPathComponent), #line, #function) - - // there are seconds delay after request follow before requested -> following. Query again when needs - guard let relationship = response.value.first else { return } - if relationship.requested == true { - let delay = pendingRetryPublisher.value - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in - guard let _ = self else { return } - pendingRetryPublisher.value = min(2 * delay, 60) - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] fetch again due to pending", ((#file as NSString).lastPathComponent), #line, #function) + .sink { [weak self] userRecord, authenticationBox, _ in + guard let self = self else { return } + guard let userRecord = userRecord, + let authenticationBox = authenticationBox + else { return } + Task { + do { + let response = try await self.updateRelationship( + record: userRecord, + authenticationBox: authenticationBox + ) + // there are seconds delay after request follow before requested -> following. Query again when needs + guard let relationship = response.value.first else { return } + if relationship.requested == true { + let delay = pendingRetryPublisher.value + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + guard let _ = self else { return } + pendingRetryPublisher.value = min(2 * delay, 60) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] fetch again due to pending", ((#file as NSString).lastPathComponent), #line, #function) + } + } + } catch { + self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [Relationship] update user relationship failure: \(error.localizedDescription)") } } } .store(in: &disposeBag) - + let isBlockingOrBlocked = Publishers.CombineLatest( isBlocking, isBlockedBy @@ -178,18 +176,18 @@ class ProfileViewModel: NSObject { extension ProfileViewModel { private func setup() { Publishers.CombineLatest( - mastodonUser.eraseToAnyPublisher(), - currentMastodonUser.eraseToAnyPublisher() + $user, + $me ) .receive(on: DispatchQueue.main) - .sink { [weak self] mastodonUser, currentMastodonUser in + .sink { [weak self] user, me in guard let self = self else { return } // Update view model attribute - self.update(mastodonUser: mastodonUser) - self.update(mastodonUser: mastodonUser, currentMastodonUser: currentMastodonUser) + self.update(mastodonUser: user) + self.update(mastodonUser: user, currentMastodonUser: me) // Setup observer for user - if let mastodonUser = mastodonUser { + if let mastodonUser = user { // setup observer self.mastodonUserObserver = ManagedObjectObserver.observe(object: mastodonUser) .sink { completion in @@ -205,7 +203,7 @@ extension ProfileViewModel { switch changeType { case .update: self.update(mastodonUser: mastodonUser) - self.update(mastodonUser: mastodonUser, currentMastodonUser: currentMastodonUser) + self.update(mastodonUser: mastodonUser, currentMastodonUser: me) case .delete: // TODO: break @@ -217,7 +215,7 @@ extension ProfileViewModel { } // Setup observer for user - if let currentMastodonUser = currentMastodonUser { + if let currentMastodonUser = me { // setup observer self.currentMastodonUserObserver = ManagedObjectObserver.observe(object: currentMastodonUser) .sink { completion in @@ -232,7 +230,7 @@ extension ProfileViewModel { guard let changeType = change.changeType else { return } switch changeType { case .update: - self.update(mastodonUser: mastodonUser, currentMastodonUser: currentMastodonUser) + self.update(mastodonUser: user, currentMastodonUser: currentMastodonUser) case .delete: // TODO: break @@ -253,13 +251,13 @@ extension ProfileViewModel { self.username.value = mastodonUser?.acctWithDomain self.bioDescription.value = mastodonUser?.note self.url.value = mastodonUser?.url - self.statusesCount.value = mastodonUser.flatMap { Int(truncating: $0.statusesCount) } - self.followingCount.value = mastodonUser.flatMap { Int(truncating: $0.followingCount) } - self.followersCount.value = mastodonUser.flatMap { Int(truncating: $0.followersCount) } + self.statusesCount.value = mastodonUser.flatMap { Int($0.statusesCount) } + self.followingCount.value = mastodonUser.flatMap { Int($0.followingCount) } + self.followersCount.value = mastodonUser.flatMap { Int($0.followersCount) } self.protected.value = mastodonUser?.locked self.suspended.value = mastodonUser?.suspended ?? false self.fields.value = mastodonUser?.fields ?? [] - self.emojiMeta.value = mastodonUser?.emojiMeta ?? [:] + self.emojiMeta.value = mastodonUser?.emojis.asDictionary ?? [:] } private func update(mastodonUser: MastodonUser?, currentMastodonUser: MastodonUser?) { @@ -297,37 +295,37 @@ extension ProfileViewModel { relationshipActionSet.insert(.suspended) } - let isFollowing = mastodonUser.followingBy.flatMap { $0.contains(currentMastodonUser) } ?? false + let isFollowing = mastodonUser.followingBy.contains(currentMastodonUser) if isFollowing { relationshipActionSet.insert(.following) } os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isFollowing: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isFollowing.description) - let isPending = mastodonUser.followRequestedBy.flatMap { $0.contains(currentMastodonUser) } ?? false + let isPending = mastodonUser.followRequestedBy.contains(currentMastodonUser) if isPending { relationshipActionSet.insert(.pending) } os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isPending: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isPending.description) - let isFollowedBy = currentMastodonUser.followingBy.flatMap { $0.contains(mastodonUser) } ?? false + let isFollowedBy = currentMastodonUser.followingBy.contains(mastodonUser) self.isFollowedBy.value = isFollowedBy os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isFollowedBy: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isFollowedBy.description) - let isMuting = mastodonUser.mutingBy.flatMap { $0.contains(currentMastodonUser) } ?? false + let isMuting = mastodonUser.mutingBy.contains(currentMastodonUser) if isMuting { relationshipActionSet.insert(.muting) } self.isMuting.value = isMuting os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isMuting: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isMuting.description) - let isBlocking = mastodonUser.blockingBy.flatMap { $0.contains(currentMastodonUser) } ?? false + let isBlocking = mastodonUser.blockingBy.contains(currentMastodonUser) if isBlocking { relationshipActionSet.insert(.blocking) } self.isBlocking.value = isBlocking os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isBlocking: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isBlocking.description) - let isBlockedBy = currentMastodonUser.blockingBy.flatMap { $0.contains(mastodonUser) } ?? false + let isBlockedBy = currentMastodonUser.blockingBy.contains(mastodonUser) if isBlockedBy { relationshipActionSet.insert(.blocked) } @@ -349,14 +347,27 @@ extension ProfileViewModel { // fetch profile info before edit func fetchEditProfileInfo() -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, Error> { - guard let currentMastodonUser = currentMastodonUser.value, - let mastodonAuthentication = currentMastodonUser.mastodonAuthentication else { + guard let me = me, + let mastodonAuthentication = me.mastodonAuthentication + else { return Fail(error: APIService.APIError.implicit(.authenticationMissing)).eraseToAnyPublisher() } let authorization = Mastodon.API.OAuth.Authorization(accessToken: mastodonAuthentication.userAccessToken) - return context.apiService.accountVerifyCredentials(domain: currentMastodonUser.domain, authorization: authorization) -// .erro + return context.apiService.accountVerifyCredentials(domain: me.domain, authorization: authorization) + } + + private func updateRelationship( + record: ManagedObjectRecord<MastodonUser>, + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Relationship]> { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [Relationship] update user relationship...") + let response = try await context.apiService.relationship( + records: [record], + authenticationBox: authenticationBox + ) + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [Relationship] did update MastodonUser relationship") + return response } } @@ -431,6 +442,7 @@ extension ProfileViewModel { } } + @available(*, deprecated, message: "") var backgroundColor: UIColor { guard let highPriorityAction = self.highPriorityAction(except: []) else { assertionFailure() @@ -454,3 +466,46 @@ extension ProfileViewModel { } } + +extension ProfileViewModel { + func updateProfileInfo( + headerProfileInfo: ProfileHeaderViewModel.ProfileInfo, + aboutProfileInfo: ProfileAboutViewModel.ProfileInfo + ) async throws -> Mastodon.Response.Content<Mastodon.Entity.Account> { + guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { + throw APIService.APIError.implicit(.badRequest) + } + + let domain = authenticationBox.domain + let authorization = authenticationBox.userAuthorization + + let _image: UIImage? = { + guard let image = headerProfileInfo.avatarImage else { return nil } + guard image.size.width <= ProfileHeaderViewModel.avatarImageMaxSizeInPixel.width else { + return image.af.imageScaled(to: ProfileHeaderViewModel.avatarImageMaxSizeInPixel) + } + return image + }() + + let fieldsAttributes = aboutProfileInfo.fields.map { field in + Mastodon.Entity.Field(name: field.name.value, value: field.value.value) + } + + let query = Mastodon.API.Account.UpdateCredentialQuery( + discoverable: nil, + bot: nil, + displayName: headerProfileInfo.name, + note: headerProfileInfo.note, + avatar: _image.flatMap { Mastodon.Query.MediaAttachment.png($0.pngData()) }, + header: nil, + locked: nil, + source: nil, + fieldsAttributes: fieldsAttributes + ) + return try await context.apiService.accountUpdateCredentials( + domain: domain, + query: query, + authorization: authorization + ) + } +} diff --git a/Mastodon/Scene/Profile/RemoteProfileViewModel.swift b/Mastodon/Scene/Profile/RemoteProfileViewModel.swift index ef04d5811..bb565c3e0 100644 --- a/Mastodon/Scene/Profile/RemoteProfileViewModel.swift +++ b/Mastodon/Scene/Profile/RemoteProfileViewModel.swift @@ -21,80 +21,78 @@ final class RemoteProfileViewModel: ProfileViewModel { } let domain = activeMastodonAuthenticationBox.domain let authorization = activeMastodonAuthenticationBox.userAuthorization - context.apiService.accountInfo( - domain: domain, - userID: userID, - authorization: authorization - ) - .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 user %s fetch failed: %s", ((#file as NSString).lastPathComponent), #line, #function, userID, error.localizedDescription) - case .finished: - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: remote user %s fetched", ((#file as NSString).lastPathComponent), #line, #function, userID) + Just(userID) + .asyncMap { userID in + try await context.apiService.accountInfo( + domain: domain, + userID: userID, + authorization: authorization + ) } - } 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 + .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 user %s fetch failed: %s", ((#file as NSString).lastPathComponent), #line, #function, userID, error.localizedDescription) + case .finished: + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: remote user %s fetched", ((#file as NSString).lastPathComponent), #line, #function, userID) + } + } 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.user = mastodonUser } - self.mastodonUser.value = mastodonUser - } - .store(in: &disposeBag) + .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 { + guard let authenticationBox = 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<Mastodon.Response.Content<Mastodon.Entity.Account>, Error>? in - let userID = response.value.account.id - // TODO: use .account directly - return context.apiService.accountInfo( - domain: domain, - userID: userID, - authorization: authorization + Task { @MainActor in + let response = try await context.apiService.notification( + notificationID: notificationID, + authenticationBox: authenticationBox ) - } - .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) + let userID = response.value.account.id + + let _user: MastodonUser? = try await context.managedObjectContext.perform { + let request = MastodonUser.sortedFetchRequest + request.predicate = MastodonUser.predicate(domain: authenticationBox.domain, id: userID) + request.fetchLimit = 1 + return context.managedObjectContext.safeFetch(request).first } - } 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 + + if let user = _user { + self.user = user + } else { + _ = try await context.apiService.accountInfo( + domain: authenticationBox.domain, + userID: userID, + authorization: authenticationBox.userAuthorization + ) + + let _user: MastodonUser? = try await context.managedObjectContext.perform { + let request = MastodonUser.sortedFetchRequest + request.predicate = MastodonUser.predicate(domain: authenticationBox.domain, id: userID) + request.fetchLimit = 1 + return context.managedObjectContext.safeFetch(request).first + } + + self.user = _user } - self.mastodonUser.value = mastodonUser - } - .store(in: &disposeBag) + } // end Task } } diff --git a/Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewModel.swift b/Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewModel.swift index 252d5e14f..e5220ef79 100644 --- a/Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewModel.swift +++ b/Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewModel.swift @@ -9,21 +9,26 @@ import os.log import UIKit import Pageboy import Tabman +import MastodonAsset +import MastodonLocalization final class ProfilePagingViewModel: NSObject { let postUserTimelineViewController = UserTimelineViewController() let repliesUserTimelineViewController = UserTimelineViewController() let mediaUserTimelineViewController = UserTimelineViewController() + let profileAboutViewController = ProfileAboutViewController() init( postsUserTimelineViewModel: UserTimelineViewModel, repliesUserTimelineViewModel: UserTimelineViewModel, - mediaUserTimelineViewModel: UserTimelineViewModel + mediaUserTimelineViewModel: UserTimelineViewModel, + profileAboutViewModel: ProfileAboutViewModel ) { postUserTimelineViewController.viewModel = postsUserTimelineViewModel repliesUserTimelineViewController.viewModel = repliesUserTimelineViewModel mediaUserTimelineViewController.viewModel = mediaUserTimelineViewModel + profileAboutViewController.viewModel = profileAboutViewModel super.init() } @@ -32,14 +37,16 @@ final class ProfilePagingViewModel: NSObject { postUserTimelineViewController, repliesUserTimelineViewController, mediaUserTimelineViewController, + profileAboutViewController, ] } let barItems: [TMBarItemable] = { let items = [ TMBarItem(title: L10n.Scene.Profile.SegmentedControl.posts), - TMBarItem(title: L10n.Scene.Profile.SegmentedControl.replies), + TMBarItem(title: L10n.Scene.Profile.SegmentedControl.postsAndReplies), // TODO: i18n TMBarItem(title: L10n.Scene.Profile.SegmentedControl.media), + TMBarItem(title: L10n.Scene.Profile.SegmentedControl.about), ] return items }() @@ -66,3 +73,10 @@ extension ProfilePagingViewModel: PageboyViewControllerDataSource { } } + +// MARK: - TMBarDataSource +extension ProfilePagingViewModel: TMBarDataSource { + func barItem(for bar: TMBar, at index: Int) -> TMBarItemable { + return barItems[index] + } +} diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController+DataSourceProvider.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController+DataSourceProvider.swift new file mode 100644 index 000000000..2b18fad56 --- /dev/null +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController+DataSourceProvider.swift @@ -0,0 +1,34 @@ +// +// UserTimelineViewController+DataSourceProvider.swift +// Mastodon +// +// Created by MainasuK on 2022-1-18. +// + +import UIKit + +extension UserTimelineViewController: DataSourceProvider { + func item(from source: DataSourceItem.Source) async -> DataSourceItem? { + var _indexPath = source.indexPath + if _indexPath == nil, let cell = source.tableViewCell { + _indexPath = await self.indexPath(for: cell) + } + guard let indexPath = _indexPath else { return nil } + + guard let item = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else { + return nil + } + + switch item { + case .status(let record): + return .status(record: record) + default: + return nil + } + } + + @MainActor + private func indexPath(for cell: UITableViewCell) async -> IndexPath? { + return tableView.indexPath(for: cell) + } +} diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController+Provider.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController+Provider.swift deleted file mode 100644 index 8c46f0ad6..000000000 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController+Provider.swift +++ /dev/null @@ -1,95 +0,0 @@ -// -// UserTimelineViewController+Provider.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-3-30. -// - -import os.log -import UIKit -import Combine -import CoreData -import CoreDataStack - -// MARK: - StatusProvider -extension UserTimelineViewController: StatusProvider { - - func status() -> Future<Status?, Never> { - return Future { promise in promise(.success(nil)) } - } - - func status(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future<Status?, Never> { - return Future { promise in - 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 .status(let objectID, _): - let managedObjectContext = self.viewModel.statusFetchedResultsController.fetchedResultsController.managedObjectContext - managedObjectContext.perform { - let status = managedObjectContext.object(with: objectID) as? Status - promise(.success(status)) - } - default: - promise(.success(nil)) - } - } - } - - func status(for cell: UICollectionViewCell) -> Future<Status?, Never> { - return Future { promise in promise(.success(nil)) } - } - - var managedObjectContext: NSManagedObjectContext { - return viewModel.statusFetchedResultsController.fetchedResultsController.managedObjectContext - } - - var tableViewDiffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>? { - return viewModel.diffableDataSource - } - - func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item? { - guard let diffableDataSource = self.viewModel.diffableDataSource else { - assertionFailure() - return nil - } - - guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }), - let item = diffableDataSource.itemIdentifier(for: indexPath) else { - return nil - } - - return item - } - - func items(indexPaths: [IndexPath]) -> [Item] { - guard let diffableDataSource = self.viewModel.diffableDataSource else { - assertionFailure() - return [] - } - - var items: [Item] = [] - for indexPath in indexPaths { - guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { continue } - items.append(item) - } - return items - } - - func statusObjectItems(indexPaths: [IndexPath]) -> [StatusObjectItem] { - guard let diffableDataSource = self.viewModel.diffableDataSource else { return [] } - let items = indexPaths.compactMap { diffableDataSource.itemIdentifier(for: $0)?.statusObjectItem } - return items - } - -} - -extension UserTimelineViewController: UserProvider {} diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift index 4bee3b8af..12925ca41 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift @@ -13,6 +13,8 @@ import CoreDataStack import GameplayKit final class UserTimelineViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { + + let logger = Logger(subsystem: "UserTimelineViewController", category: "ViewController") weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } @@ -24,16 +26,14 @@ final class UserTimelineViewController: UIViewController, NeedsDependency, Media lazy var tableView: UITableView = { let tableView = UITableView() - tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.self)) - tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) - tableView.register(TimelineHeaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineHeaderTableViewCell.self)) tableView.rowHeight = UITableView.automaticDimension + tableView.estimatedRowHeight = 100 tableView.separatorStyle = .none tableView.backgroundColor = .clear return tableView }() - - var overrideNavigationScrollPosition: UITableView.ScrollPosition? = nil + + let cellFrameCache = NSCache<NSNumber, NSValue>() deinit { os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) @@ -48,7 +48,7 @@ extension UserTimelineViewController { view.backgroundColor = ThemeService.shared.currentTheme.value.secondarySystemBackgroundColor ThemeService.shared.currentTheme - .receive(on: RunLoop.main) + .receive(on: DispatchQueue.main) .sink { [weak self] theme in guard let self = self else { return } self.view.backgroundColor = theme.secondarySystemBackgroundColor @@ -65,10 +65,8 @@ extension UserTimelineViewController { ]) tableView.delegate = self - tableView.prefetchDataSource = self viewModel.setupDiffableDataSource( - for: tableView, - dependency: self, + tableView: tableView, statusTableViewCellDelegate: self ) @@ -78,78 +76,44 @@ extension UserTimelineViewController { .receive(on: DispatchQueue.main) .sink { [weak self] _ in guard let self = self else { return } + guard self.view.window != nil else { return } self.viewModel.stateMachine.enter(UserTimelineViewModel.State.Loading.self) } .store(in: &disposeBag) - - // 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(UserTimelineViewModel.State.Reloading.self) - } - .store(in: &disposeBag) - } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - aspectViewWillAppear(animated) - } - - override func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - - aspectViewDidDisappear(animated) + tableView.deselectRow(with: transitionCoordinator, animated: animated) } } -// MARK: - StatusTableViewControllerAspect -extension UserTimelineViewController: StatusTableViewControllerAspect { } - -// MARK: - UIScrollViewDelegate -//extension UserTimelineViewController { -// func scrollViewDidScroll(_ scrollView: UIScrollView) { -// aspectScrollViewDidScroll(scrollView) -// } -//} - -// MARK: - TableViewCellHeightCacheableContainer -extension UserTimelineViewController: TableViewCellHeightCacheableContainer { - var cellFrameCache: NSCache<NSNumber, NSValue> { - return viewModel.cellFrameCache +// MARK: - CellFrameCacheContainer +extension UserTimelineViewController: CellFrameCacheContainer { + func keyForCache(tableView: UITableView, indexPath: IndexPath) -> NSNumber? { + guard let diffableDataSource = viewModel.diffableDataSource else { return nil } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return nil } + let key = NSNumber(value: item.hashValue) + return key } } // MARK: - UITableViewDelegate -extension UserTimelineViewController: UITableViewDelegate { - - func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { - aspectTableView(tableView, estimatedHeightForRowAt: indexPath) - } - - func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { - aspectTableView(tableView, willDisplay: cell, forRowAt: indexPath) - } - - func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { - aspectTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) - } - +extension UserTimelineViewController: UITableViewDelegate, AutoGenerateTableViewDelegate { + // sourcery:inline:UserTimelineViewController.AutoGenerateTableViewDelegate + + // Generated using Sourcery + // DO NOT EDIT func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { aspectTableView(tableView, didSelectRowAt: 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) } @@ -157,52 +121,33 @@ extension UserTimelineViewController: UITableViewDelegate { 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) } - -} -// MARK: - UITableViewDataSourcePrefetching -extension UserTimelineViewController: UITableViewDataSourcePrefetching { - func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { - aspectTableView(tableView, prefetchRowsAt: indexPaths) + // sourcery:end + + func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { + guard let frame = retrieveCellFrame(tableView: tableView, indexPath: indexPath) else { + return 200 + } + return ceil(frame.height) } -} -// MARK: - AVPlayerViewControllerDelegate -extension UserTimelineViewController: AVPlayerViewControllerDelegate { - - func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { - aspectPlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator) + func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { + cacheCellFrame(tableView: tableView, didEndDisplaying: cell, forRowAt: indexPath) } - func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { - aspectPlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator) - } - -} - -// MARK: - TimelinePostTableViewCellDelegate -extension UserTimelineViewController: StatusTableViewCellDelegate { - weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self } - func parent() -> UIViewController { return self } } // MARK: - CustomScrollViewContainerController extension UserTimelineViewController: ScrollViewContainer { - var scrollView: UIScrollView { return tableView } + var scrollView: UIScrollView? { return tableView } } -// MARK: - LoadMoreConfigurableTableViewContainer -//extension UserTimelineViewController: LoadMoreConfigurableTableViewContainer { -// typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell -// typealias LoadingState = UserTimelineViewModel.State.Loading -// -// var loadMoreConfigurableTableView: UITable``````View { return tableView } -// var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.stateMachine } -//} +// MARK: - StatusTableViewCellDelegate +extension UserTimelineViewController: StatusTableViewCellDelegate { } 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 0d6d47823..a0a1f52cd 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift @@ -6,32 +6,87 @@ // import UIKit +import Combine extension UserTimelineViewModel { func setupDiffableDataSource( - for tableView: UITableView, - dependency: NeedsDependency, + tableView: UITableView, statusTableViewCellDelegate: StatusTableViewCellDelegate ) { - diffableDataSource = StatusSection.tableViewDiffableDataSource( - for: tableView, - timelineContext: .account, - dependency: dependency, - managedObjectContext: statusFetchedResultsController.fetchedResultsController.managedObjectContext, - statusTableViewCellDelegate: statusTableViewCellDelegate, - timelineMiddleLoaderTableViewCellDelegate: nil, - threadReplyLoaderTableViewCellDelegate: nil + diffableDataSource = StatusSection.diffableDataSource( + tableView: tableView, + context: context, + configuration: StatusSection.Configuration( + statusTableViewCellDelegate: statusTableViewCellDelegate, + timelineMiddleLoaderTableViewCellDelegate: nil, + filterContext: .none, + activeFilters: nil + ) ) - + // set empty section to make update animation top-to-bottom style - var snapshot = NSDiffableDataSourceSnapshot<StatusSection, Item>() + var snapshot = NSDiffableDataSourceSnapshot<StatusSection, StatusItem>() snapshot.appendSections([.main]) diffableDataSource?.apply(snapshot) + + // trigger user timeline loading + Publishers.CombineLatest( + $domain.removeDuplicates(), + $userID.removeDuplicates() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + self.stateMachine.enter(UserTimelineViewModel.State.Reloading.self) + } + .store(in: &disposeBag) + + let needsTimelineHidden = Publishers.CombineLatest3( + isBlocking, + isBlockedBy, + isSuspended + ).map { $0 || $1 || $2 } + + Publishers.CombineLatest( + statusFetchedResultsController.$records, + needsTimelineHidden.removeDuplicates() + ) + .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) + .sink { [weak self] records, needsTimelineHidden in + guard let self = self else { return } + guard let diffableDataSource = self.diffableDataSource else { return } + + var snapshot = NSDiffableDataSourceSnapshot<StatusSection, StatusItem>() + snapshot.appendSections([.main]) + + guard !needsTimelineHidden else { + diffableDataSource.apply(snapshot) + return + } - // workaround to append loader wrong animation issue - snapshot.appendItems([.bottomLoader], toSection: .main) - diffableDataSource?.apply(snapshot) + let items = records.map { StatusItem.status(record: $0) } + snapshot.appendItems(items, toSection: .main) + + if let currentState = self.stateMachine.currentState { + switch currentState { + case is State.Initial, + is State.Reloading, + is State.Loading, + is State.Idle, + is State.Fail: + snapshot.appendItems([.bottomLoader], toSection: .main) + case is State.NoMore: + break + default: + assertionFailure() + break + } + } + + diffableDataSource.applySnapshot(snapshot, animated: false) + } + .store(in: &disposeBag) } } diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift index 2566006e0..06f657bad 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift @@ -11,7 +11,16 @@ import GameplayKit import MastodonSDK extension UserTimelineViewModel { - class State: GKState { + class State: GKState, NamingState { + + let logger = Logger(subsystem: "UserTimelineViewModel.State", category: "StateMachine") + + let id = UUID() + + var name: String { + String(describing: Self.self) + } + weak var viewModel: UserTimelineViewModel? init(viewModel: UserTimelineViewModel) { @@ -19,7 +28,18 @@ extension UserTimelineViewModel { } 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) + super.didEnter(from: previousState) + let previousState = previousState as? UserTimelineViewModel.State + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [\(self.id.uuidString)] enter \(self.name), previous: \(previousState?.name ?? "<nil>")") + } + + @MainActor + func enter(state: State.Type) { + stateMachine?.enter(state) + } + + deinit { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [\(self.id.uuidString)] \(self.name)") } } } @@ -30,7 +50,7 @@ extension UserTimelineViewModel.State { guard let viewModel = viewModel else { return false } switch stateClass { case is Reloading.Type: - return viewModel.userID.value != nil + return viewModel.userID != nil default: return false } @@ -112,57 +132,51 @@ extension UserTimelineViewModel.State { let maxID = viewModel.statusFetchedResultsController.statusIDs.value.last - guard let userID = viewModel.userID.value, !userID.isEmpty else { + guard let userID = viewModel.userID, !userID.isEmpty else { stateMachine.enter(Fail.self) return } - guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { + guard let authenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { stateMachine.enter(Fail.self) return } - let domain = activeMastodonAuthenticationBox.domain - let queryFilter = viewModel.queryFilter.value - - viewModel.context.apiService.userTimeline( - domain: domain, - accountID: userID, - maxID: maxID, - sinceID: nil, - excludeReplies: queryFilter.excludeReplies, - excludeReblogs: queryFilter.excludeReblogs, - onlyMedia: queryFilter.onlyMedia, - 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 + let queryFilter = viewModel.queryFilter + + Task { + + do { + let response = try await viewModel.context.apiService.userTimeline( + accountID: userID, + maxID: maxID, + sinceID: nil, + excludeReplies: queryFilter.excludeReplies, + excludeReblogs: queryFilter.excludeReblogs, + onlyMedia: queryFilter.onlyMedia, + authenticationBox: authenticationBox + ) + + var hasNewStatusesAppend = false + var statusIDs = viewModel.statusFetchedResultsController.statusIDs.value + for status in response.value { + guard !statusIDs.contains(status.id) else { continue } + statusIDs.append(status.id) + hasNewStatusesAppend = true + } + + if hasNewStatusesAppend { + await enter(state: Idle.self) + } else { + await enter(state: NoMore.self) + } + viewModel.statusFetchedResultsController.statusIDs.value = statusIDs + + } catch { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch user timeline fail: \(error.localizedDescription)") + await enter(state: Fail.self) } - } receiveValue: { response in - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - - var hasNewStatusesAppend = false - var statusIDs = viewModel.statusFetchedResultsController.statusIDs.value - for status in response.value { - guard !statusIDs.contains(status.id) else { continue } - statusIDs.append(status.id) - hasNewStatusesAppend = true - } - - if hasNewStatusesAppend { - stateMachine.enter(Idle.self) - } else { - stateMachine.enter(NoMore.self) - } - viewModel.statusFetchedResultsController.statusIDs.value = statusIDs - } - .store(in: &viewModel.disposeBag) - } + } // end Task + } // end func } class NoMore: UserTimelineViewModel.State { diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift index 5bf520d6d..9701ba480 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift @@ -19,12 +19,11 @@ final class UserTimelineViewModel { // input let context: AppContext - let domain: CurrentValueSubject<String?, Never> - let userID: CurrentValueSubject<String?, Never> - let queryFilter: CurrentValueSubject<QueryFilter, Never> + @Published var domain: String? + @Published var userID: String? + @Published var queryFilter: QueryFilter let statusFetchedResultsController: StatusFetchedResultsController let listBatchFetchViewModel = ListBatchFetchViewModel() - var cellFrameCache = NSCache<NSNumber, NSValue>() let isBlocking = CurrentValueSubject<Bool, Never>(false) let isBlockedBy = CurrentValueSubject<Bool, Never>(false) @@ -33,7 +32,7 @@ final class UserTimelineViewModel { var dataSourceDidUpdate = PassthroughSubject<Void, Never>() // output - var diffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>? + var diffableDataSource: UITableViewDiffableDataSource<StatusSection, StatusItem>? private(set) lazy var stateMachine: GKStateMachine = { let stateMachine = GKStateMachine(states: [ State.Initial(viewModel: self), @@ -47,99 +46,28 @@ final class UserTimelineViewModel { return stateMachine }() - init(context: AppContext, domain: String?, userID: String?, queryFilter: QueryFilter) { + init( + context: AppContext, + domain: String?, + userID: String?, + queryFilter: QueryFilter + ) { self.context = context self.statusFetchedResultsController = StatusFetchedResultsController( managedObjectContext: context.managedObjectContext, domain: domain, additionalTweetPredicate: Status.notDeleted() ) - self.domain = CurrentValueSubject(domain) - self.userID = CurrentValueSubject(userID) - self.queryFilter = CurrentValueSubject(queryFilter) + self.domain = domain + self.userID = userID + self.queryFilter = queryFilter // super.init() - self.domain + $domain .assign(to: \.value, on: statusFetchedResultsController.domain) .store(in: &disposeBag) - Publishers.CombineLatest4( - statusFetchedResultsController.objectIDs.removeDuplicates(), - isBlocking, - isBlockedBy, - isSuspended - ) - .receive(on: DispatchQueue.main) - .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) - .sink { [weak self] objectIDs, isBlocking, isBlockedBy, isSuspended in - guard let self = self else { return } - guard let diffableDataSource = self.diffableDataSource else { return } - - var items: [Item] = [] - var snapshot = NSDiffableDataSourceSnapshot<StatusSection, Item>() - snapshot.appendSections([.main]) - - var animatingDifferences = true - defer { - // not animate when empty items fix loader first appear layout issue - diffableDataSource.apply(snapshot, animatingDifferences: animatingDifferences) { [weak self] in - guard let self = self else { return } - self.dataSourceDidUpdate.send() - } - } - - let name = self.userDisplayName.value - guard !isBlocking else { - snapshot.appendItems( - [Item.emptyStateHeader(attribute: Item.EmptyStateHeaderAttribute(reason: .blocking(name: name)))], - toSection: .main - ) - return - } - - guard !isBlockedBy else { - snapshot.appendItems( - [Item.emptyStateHeader(attribute: Item.EmptyStateHeaderAttribute(reason: .blocked(name: name)))], - toSection: .main - ) - return - } - - guard !isSuspended else { - snapshot.appendItems( - [Item.emptyStateHeader(attribute: Item.EmptyStateHeaderAttribute(reason: .suspended(name: name)))], - toSection: .main - ) - return - } - - var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:] - let oldSnapshot = diffableDataSource.snapshot() - for item in oldSnapshot.itemIdentifiers { - guard case let .status(objectID, attribute) = item else { continue } - oldSnapshotAttributeDict[objectID] = attribute - } - - for objectID in objectIDs { - let attribute = oldSnapshotAttributeDict[objectID] ?? Item.StatusAttribute() - items.append(.status(objectID: objectID, attribute: attribute)) - } - snapshot.appendItems(items, toSection: .main) - - if let currentState = self.stateMachine.currentState { - switch currentState { - case is State.Reloading, is State.Loading, is State.Idle, is State.Fail: - snapshot.appendItems([.bottomLoader], toSection: .main) - case is State.NoMore: - snapshot.appendItems([.emptyBottomLoader], toSection: .main) - animatingDifferences = false - // TODO: handle other states - default: - break - } - } - } - .store(in: &disposeBag) + } deinit { diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+Provider.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+Provider.swift deleted file mode 100644 index dd7730630..000000000 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+Provider.swift +++ /dev/null @@ -1,95 +0,0 @@ -// -// PublicTimelineViewController+Provider.swift -// Mastodon -// -// Created by sxiaojian on 2021/1/27. -// - -import os.log -import UIKit -import Combine -import CoreData -import CoreDataStack -import MastodonSDK - -// MARK: - StatusProvider -extension PublicTimelineViewController: StatusProvider { - - func status() -> Future<Status?, Never> { - return Future { promise in promise(.success(nil)) } - } - - func status(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future<Status?, Never> { - return Future { promise in - 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 .status(let objectID, _): - let managedObjectContext = self.viewModel.fetchedResultsController.managedObjectContext - managedObjectContext.perform { - let status = managedObjectContext.object(with: objectID) as? Status - promise(.success(status)) - } - default: - promise(.success(nil)) - } - } - } - - func status(for cell: UICollectionViewCell) -> Future<Status?, Never> { - return Future { promise in promise(.success(nil)) } - } - - var managedObjectContext: NSManagedObjectContext { - return viewModel.fetchedResultsController.managedObjectContext - } - - var tableViewDiffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>? { - return viewModel.diffableDataSource - } - - func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item? { - guard let diffableDataSource = self.viewModel.diffableDataSource else { - assertionFailure() - return nil - } - guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }), - let item = diffableDataSource.itemIdentifier(for: indexPath) else { - return nil - } - - return item - } - - func items(indexPaths: [IndexPath]) -> [Item] { - guard let diffableDataSource = self.viewModel.diffableDataSource else { - assertionFailure() - return [] - } - - var items: [Item] = [] - for indexPath in indexPaths { - guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { continue } - items.append(item) - } - return items - } - - func statusObjectItems(indexPaths: [IndexPath]) -> [StatusObjectItem] { - guard let diffableDataSource = self.viewModel.diffableDataSource else { return [] } - let items = indexPaths.compactMap { diffableDataSource.itemIdentifier(for: $0)?.statusObjectItem } - return items - } - -} - -extension PublicTimelineViewController: UserProvider {} diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift deleted file mode 100644 index 29d84b791..000000000 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift +++ /dev/null @@ -1,239 +0,0 @@ -// -// PublicTimelineViewController.swift -// Mastodon -// -// Created by sxiaojian on 2021/1/27. -// - -import AVKit -import Combine -import CoreData -import CoreDataStack -import GameplayKit -import os.log -import UIKit - -final class PublicTimelineViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { - weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } - weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } - - var disposeBag = Set<AnyCancellable>() - var viewModel: PublicTimelineViewModel! - - let mediaPreviewTransitionController = MediaPreviewTransitionController() - - let refreshControl = UIRefreshControl() - - lazy var tableView: UITableView = { - let tableView = UITableView() - tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.self)) - tableView.register(TimelineMiddleLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineMiddleLoaderTableViewCell.self)) - tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) - tableView.rowHeight = UITableView.automaticDimension - tableView.separatorStyle = .none - tableView.backgroundColor = .clear - return tableView - }() - - deinit { - os_log("%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function) - } -} - -extension PublicTimelineViewController { - override func viewDidLoad() { - super.viewDidLoad() - - title = "Public" - view.backgroundColor = ThemeService.shared.currentTheme.value.secondarySystemBackgroundColor - tableView.refreshControl = refreshControl - refreshControl.addTarget(self, action: #selector(PublicTimelineViewController.refreshControlValueChanged(_:)), for: .valueChanged) - // bind refresh control - viewModel.isFetchingLatestTimeline - .receive(on: DispatchQueue.main) - .sink { [weak self] isFetching in - guard let self = self else { return } - if !isFetching { - UIView.animate(withDuration: 0.5) { [weak self] in - guard let self = self else { return } - self.refreshControl.endRefreshing() - } - } - } - .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), - ]) - - viewModel.tableView = tableView - viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self - tableView.delegate = self - tableView.prefetchDataSource = self - viewModel.setupDiffableDataSource( - for: tableView, - dependency: self, - statusTableViewCellDelegate: self, - timelineMiddleLoaderTableViewCellDelegate: self - ) - } - - override func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - context.videoPlaybackService.viewDidDisappear(from: self) - context.audioPlaybackService.viewDidDisappear(from: self) - } -} - -// MARK: - UIScrollViewDelegate -extension PublicTimelineViewController { - func scrollViewDidScroll(_ scrollView: UIScrollView) { - handleScrollViewDidScroll(scrollView) - } -} - -// MARK: - Selector -extension PublicTimelineViewController { - @objc private func refreshControlValueChanged(_ sender: UIRefreshControl) { - guard viewModel.stateMachine.enter(PublicTimelineViewModel.State.Loading.self) else { - sender.endRefreshing() - return - } - } -} - -// MARK: - UITableViewDelegate -extension PublicTimelineViewController: UITableViewDelegate { - func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { - guard let diffableDataSource = viewModel.diffableDataSource else { return 100 } - guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return 100 } - - guard let frame = viewModel.cellFrameCache.object(forKey: NSNumber(value: item.hashValue))?.cgRectValue else { - return 200 - } - - return ceil(frame.height) - } - - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {} - func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { - handleTableView(tableView, willDisplay: cell, forRowAt: indexPath) - } - func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { - handleTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) - guard let diffableDataSource = viewModel.diffableDataSource else { return } - guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } - - let key = item.hashValue - let frame = cell.frame - viewModel.cellFrameCache.setObject(NSValue(cgRect: frame), forKey: NSNumber(value: key)) - } -} - -// MARK: - UITableViewDataSourcePrefetching -extension PublicTimelineViewController: UITableViewDataSourcePrefetching { - func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { - handleTableView(tableView, prefetchRowsAt: indexPaths) - } -} - -// MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate -extension PublicTimelineViewController: ContentOffsetAdjustableTimelineViewControllerDelegate { - func navigationBar() -> UINavigationBar? { - return navigationController?.navigationBar - } -} - -// MARK: - LoadMoreConfigurableTableViewContainer -extension PublicTimelineViewController: LoadMoreConfigurableTableViewContainer { - typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell - typealias LoadingState = PublicTimelineViewModel.State.LoadingMore - - var loadMoreConfigurableTableView: UITableView { return tableView } - var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.stateMachine } -} - -// MARK: - TimelineMiddleLoaderTableViewCellDelegate -extension PublicTimelineViewController: TimelineMiddleLoaderTableViewCellDelegate { - func configure(cell: TimelineMiddleLoaderTableViewCell, upperTimelineStatusID: String?, timelineIndexobjectID: NSManagedObjectID?) { - guard let upperTimelineStatusID = upperTimelineStatusID else {return} - viewModel.loadMiddleSateMachineList - .receive(on: DispatchQueue.main) - .sink { [weak self] ids in - guard let _ = self else { return } - if let stateMachine = ids[upperTimelineStatusID] { - guard let state = stateMachine.currentState else { - assertionFailure() - return - } - - // make success state same as loading due to snapshot updating delay - let isLoading = state is PublicTimelineViewModel.LoadMiddleState.Loading || state is PublicTimelineViewModel.LoadMiddleState.Success - if isLoading { - cell.startAnimating() - } else { - cell.stopAnimating() - } - } else { - cell.stopAnimating() - } - } - .store(in: &cell.disposeBag) - - var dict = viewModel.loadMiddleSateMachineList.value - if let _ = dict[upperTimelineStatusID] { - // do nothing - } else { - let stateMachine = GKStateMachine(states: [ - PublicTimelineViewModel.LoadMiddleState.Initial(viewModel: viewModel, upperTimelineStatusID: upperTimelineStatusID), - PublicTimelineViewModel.LoadMiddleState.Loading(viewModel: viewModel, upperTimelineStatusID: upperTimelineStatusID), - PublicTimelineViewModel.LoadMiddleState.Fail(viewModel: viewModel, upperTimelineStatusID: upperTimelineStatusID), - PublicTimelineViewModel.LoadMiddleState.Success(viewModel: viewModel, upperTimelineStatusID: upperTimelineStatusID), - ]) - stateMachine.enter(PublicTimelineViewModel.LoadMiddleState.Initial.self) - dict[upperTimelineStatusID] = stateMachine - viewModel.loadMiddleSateMachineList.value = dict - } - } - - func timelineMiddleLoaderTableViewCell(_ cell: TimelineMiddleLoaderTableViewCell, loadMoreButtonDidPressed button: UIButton) { - guard let diffableDataSource = viewModel.diffableDataSource else { return } - guard let indexPath = tableView.indexPath(for: cell) else { return } - guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } - - switch item { - case .publicMiddleLoader(let upper): - guard let stateMachine = viewModel.loadMiddleSateMachineList.value[upper] else { - assertionFailure() - return - } - stateMachine.enter(PublicTimelineViewModel.LoadMiddleState.Loading.self) - default: - assertionFailure() - } - } -} - -// MARK: - AVPlayerViewControllerDelegate -extension PublicTimelineViewController: 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 PublicTimelineViewController: StatusTableViewCellDelegate { - weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self } - func parent() -> UIViewController { return self } -} diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift deleted file mode 100644 index e9d5c518b..000000000 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift +++ /dev/null @@ -1,66 +0,0 @@ -// -// PublicTimelineViewModel+Diffable.swift -// Mastodon -// -// Created by sxiaojian on 2021/1/27. -// - -import CoreData -import CoreDataStack -import os.log -import UIKit - -extension PublicTimelineViewModel { - func setupDiffableDataSource( - for tableView: UITableView, - dependency: NeedsDependency, - statusTableViewCellDelegate: StatusTableViewCellDelegate, - timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate - ) { - diffableDataSource = StatusSection.tableViewDiffableDataSource( - for: tableView, - timelineContext: .public, - dependency: dependency, - managedObjectContext: fetchedResultsController.managedObjectContext, - statusTableViewCellDelegate: statusTableViewCellDelegate, - timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate, - threadReplyLoaderTableViewCellDelegate: nil - ) - items.value = [] - stateMachine.enter(PublicTimelineViewModel.State.Loading.self) - } -} - -// MARK: - NSFetchedResultsControllerDelegate - -extension PublicTimelineViewModel: NSFetchedResultsControllerDelegate { - func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { - os_log("%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function) - - let indexes = statusIDs.value - let statuses = fetchedResultsController.fetchedObjects ?? [] - guard statuses.count == indexes.count else { return } - let indexStatusTuples: [(Int, Status)] = statuses - .compactMap { status -> (Int, Status)? in - guard status.deletedAt == nil else { return nil } - return indexes.firstIndex(of: status.id).map { index in (index, status) } - } - .sorted { $0.0 < $1.0 } - var oldSnapshotAttributeDict: [NSManagedObjectID: Item.StatusAttribute] = [:] - for item in self.items.value { - guard case let .status(objectID, attribute) = item else { continue } - oldSnapshotAttributeDict[objectID] = attribute - } - - var items = [Item]() - for (_, status) in indexStatusTuples { - let attribute = oldSnapshotAttributeDict[status.objectID] ?? Item.StatusAttribute() - items.append(Item.status(objectID: status.objectID, attribute: attribute)) - if statusIDsWhichHasGap.contains(status.id) { - items.append(Item.publicMiddleLoader(statusID: status.id)) - } - } - - self.items.value = items - } -} diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+LoadMiddleState.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+LoadMiddleState.swift deleted file mode 100644 index 4727072bf..000000000 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+LoadMiddleState.swift +++ /dev/null @@ -1,115 +0,0 @@ -// -// PublicTimelineViewModel+LoadMiddleState.swift -// Mastodon -// -// Created by sxiaojian on 2021/2/4. -// - -import CoreData -import CoreDataStack -import Foundation -import GameplayKit -import os.log - -extension PublicTimelineViewModel { - class LoadMiddleState: GKState { - weak var viewModel: PublicTimelineViewModel? - let upperTimelineStatusID: String - - init(viewModel: PublicTimelineViewModel, upperTimelineStatusID: String) { - self.viewModel = viewModel - self.upperTimelineStatusID = upperTimelineStatusID - } - - 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) - guard let viewModel = viewModel, let stateMachine = stateMachine else { return } - var dict = viewModel.loadMiddleSateMachineList.value - dict[self.upperTimelineStatusID] = stateMachine - viewModel.loadMiddleSateMachineList.value = dict // trigger value change - } - } -} - -extension PublicTimelineViewModel.LoadMiddleState { - class Initial: PublicTimelineViewModel.LoadMiddleState { - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - return stateClass == Loading.self - } - } - - class Loading: PublicTimelineViewModel.LoadMiddleState { - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - // guard let viewModel = viewModel else { return false } - return stateClass == Success.self || stateClass == Fail.self - } - - override func didEnter(from previousState: GKState?) { - super.didEnter(from: previousState) - - guard let viewModel = viewModel, let stateMachine = stateMachine else { return } - guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { - stateMachine.enter(Fail.self) - return - } - viewModel.context.apiService.publicTimeline( - domain: activeMastodonAuthenticationBox.domain, - maxID: upperTimelineStatusID - ) - .receive(on: DispatchQueue.main) - .sink { completion in - switch completion { - case .failure(let error): - os_log("%{public}s[%{public}ld], %{public}s: fetch statuses failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) - stateMachine.enter(Fail.self) - case .finished: - break - } - } receiveValue: { response in - let statuses = response.value - let addedStatuses = statuses.filter { !viewModel.statusIDs.value.contains($0.id) } - - guard let gapIndex = viewModel.statusIDs.value.firstIndex(of: self.upperTimelineStatusID) else { return } - let upStatuses = Array(viewModel.statusIDs.value[...gapIndex]) - let downStatuses = Array(viewModel.statusIDs.value[(gapIndex + 1)...]) - - // construct newStatusIDs - var newStatusIDs = upStatuses - newStatusIDs.append(contentsOf: addedStatuses.map { $0.id }) - newStatusIDs.append(contentsOf: downStatuses) - // remove old gap from viewmodel - if let index = viewModel.statusIDsWhichHasGap.firstIndex(of: self.upperTimelineStatusID) { - viewModel.statusIDsWhichHasGap.remove(at: index) - } - // add new gap from viewmodel if need - let intersection = statuses.filter { downStatuses.contains($0.id) } - if intersection.isEmpty { - addedStatuses.last.flatMap { viewModel.statusIDsWhichHasGap.append($0.id) } - } - - viewModel.statusIDs.value = newStatusIDs - os_log("%{public}s[%{public}ld], %{public}s: load %{public}ld statuses, %{public}%ld new statues", (#file as NSString).lastPathComponent, #line, #function, statuses.count, addedStatuses.count) - if addedStatuses.isEmpty { - stateMachine.enter(Fail.self) - } else { - stateMachine.enter(Success.self) - } - } - .store(in: &viewModel.disposeBag) - } - } - - class Fail: PublicTimelineViewModel.LoadMiddleState { - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - // guard let viewModel = viewModel else { return false } - return stateClass == Loading.self - } - } - - class Success: PublicTimelineViewModel.LoadMiddleState { - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - // guard let viewModel = viewModel else { return false } - return false - } - } -} diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+State.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+State.swift deleted file mode 100644 index c165adb70..000000000 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+State.swift +++ /dev/null @@ -1,168 +0,0 @@ -// -// PublicTimelineViewModel+State.swift -// Mastodon -// -// Created by sxiaojian on 2021/2/2. -// - -import Foundation -import GameplayKit -import MastodonSDK -import os.log - -extension PublicTimelineViewModel { - class State: GKState { - weak var viewModel: PublicTimelineViewModel? - - init(viewModel: PublicTimelineViewModel) { - 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 PublicTimelineViewModel.State { - class Initial: PublicTimelineViewModel.State { - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - switch stateClass { - case is Loading.Type: - return true - default: - return false - } - } - } - - class Loading: PublicTimelineViewModel.State { - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - switch stateClass { - case is Fail.Type: - return true - case is Idle.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 } - guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { - stateMachine.enter(Fail.self) - return - } - - viewModel.context.apiService.publicTimeline(domain: activeMastodonAuthenticationBox.domain) - .receive(on: DispatchQueue.main) - .sink { completion in - switch completion { - case .failure(let error): - os_log("%{public}s[%{public}ld], %{public}s: fetch user timeline latest response error: %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) - stateMachine.enter(Fail.self) - - case .finished: - break - } - } receiveValue: { response in - let resposeStatusIDs = response.value.compactMap { $0.id } - var newStatusIDs = resposeStatusIDs - let oldStatusIDs = viewModel.statusIDs.value - var hasGap = true - for statusID in oldStatusIDs { - if !newStatusIDs.contains(statusID) { - newStatusIDs.append(statusID) - } else { - hasGap = false - } - } - if hasGap && oldStatusIDs.count > 0 { - resposeStatusIDs.last.flatMap { viewModel.statusIDsWhichHasGap.append($0) } - } - viewModel.statusIDs.value = newStatusIDs - stateMachine.enter(Idle.self) - } - .store(in: &viewModel.disposeBag) - } - } - - class Fail: PublicTimelineViewModel.State { - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - switch stateClass { - case is Loading.Type, is LoadingMore.Type: - return true - default: - return false - } - } - - override func didEnter(from previousState: GKState?) { - super.didEnter(from: previousState) - guard let viewModel = viewModel else { return } - - // trigger items update - viewModel.items.value = viewModel.items.value - } - } - - class Idle: PublicTimelineViewModel.State { - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - switch stateClass { - case is Loading.Type, is LoadingMore.Type: - return true - default: - return false - } - } - } - - class LoadingMore: PublicTimelineViewModel.State { - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - switch stateClass { - case is Fail.Type: - return true - case is Idle.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 } - guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { - stateMachine.enter(Fail.self) - return - } - let maxID = viewModel.statusIDs.value.last - viewModel.context.apiService.publicTimeline( - domain: activeMastodonAuthenticationBox.domain, - maxID: maxID - ) - .sink { completion in - switch completion { - case .failure(let error): - stateMachine.enter(Fail.self) - os_log("%{public}s[%{public}ld], %{public}s: load more fail: %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) - case .finished: - break - } - } receiveValue: { response in - stateMachine.enter(Idle.self) - var oldStatusIDs = viewModel.statusIDs.value - for status in response.value { - if !oldStatusIDs.contains(status.id) { - oldStatusIDs.append(status.id) - } - } - - viewModel.statusIDs.value = oldStatusIDs - } - .store(in: &viewModel.disposeBag) - } - } -} diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel.swift deleted file mode 100644 index 6d6ecbd34..000000000 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel.swift +++ /dev/null @@ -1,165 +0,0 @@ -// -// PublicTimelineViewModel.swift -// Mastodon -// -// Created by sxiaojian on 2021/1/27. -// - -import AlamofireImage -import Combine -import CoreData -import CoreDataStack -import GameplayKit -import MastodonSDK -import os.log -import UIKit - -class PublicTimelineViewModel: NSObject { - var disposeBag = Set<AnyCancellable>() - - // input - let context: AppContext - let fetchedResultsController: NSFetchedResultsController<Status> - - let isFetchingLatestTimeline = CurrentValueSubject<Bool, Never>(false) - - // middle loader - let loadMiddleSateMachineList = CurrentValueSubject<[String: GKStateMachine], Never>([:]) - - weak var tableView: UITableView? - - weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate? - - // - var statusIDsWhichHasGap = [String]() - // output - var diffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>? - - lazy var stateMachine: GKStateMachine = { - let stateMachine = GKStateMachine(states: [ - State.Initial(viewModel: self), - State.Loading(viewModel: self), - State.Fail(viewModel: self), - State.Idle(viewModel: self), - State.LoadingMore(viewModel: self), - ]) - stateMachine.enter(State.Initial.self) - return stateMachine - }() - - let statusIDs = CurrentValueSubject<[String], Never>([]) - let items = CurrentValueSubject<[Item], Never>([]) - var cellFrameCache = NSCache<NSNumber, NSValue>() - - init(context: AppContext) { - self.context = context - self.fetchedResultsController = { - let fetchRequest = Status.sortedFetchRequest - fetchRequest.predicate = Status.predicate(domain: "", ids: []) - fetchRequest.returnsObjectsAsFaults = false - fetchRequest.fetchBatchSize = 20 - let controller = NSFetchedResultsController( - fetchRequest: fetchRequest, - managedObjectContext: context.managedObjectContext, - sectionNameKeyPath: nil, - cacheName: nil - ) - - return controller - }() - super.init() - - fetchedResultsController.delegate = self - - items - .receive(on: DispatchQueue.main) - .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) - .sink { [weak self] items in - guard let self = self else { return } - guard let diffableDataSource = self.diffableDataSource else { return } - guard let navigationBar = self.contentOffsetAdjustableTimelineViewControllerDelegate?.navigationBar() else { return } - guard let tableView = self.tableView else { return } - let oldSnapshot = diffableDataSource.snapshot() - os_log("%{public}s[%{public}ld], %{public}s: items did change", (#file as NSString).lastPathComponent, #line, #function) - - var snapshot = NSDiffableDataSourceSnapshot<StatusSection, Item>() - snapshot.appendSections([.main]) - snapshot.appendItems(items) - if let currentState = self.stateMachine.currentState { - switch currentState { - case is State.Idle, is State.LoadingMore, is State.Fail: - snapshot.appendItems([.bottomLoader], toSection: .main) - default: - break - } - } - - DispatchQueue.main.async { - - guard let difference = self.calculateReloadSnapshotDifference(navigationBar: navigationBar, tableView: tableView, oldSnapshot: oldSnapshot, newSnapshot: snapshot) else { - diffableDataSource.apply(snapshot) - self.isFetchingLatestTimeline.value = false - return - } - - 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 - } - } - } - .store(in: &disposeBag) - - statusIDs - .receive(on: DispatchQueue.main) - .sink { [weak self] ids in - guard let self = self else { return } - let domain = self.context.authenticationService.activeMastodonAuthenticationBox.value?.domain ?? "" - self.fetchedResultsController.fetchRequest.predicate = Status.predicate(domain: domain, ids: ids) - do { - try self.fetchedResultsController.performFetch() - } catch { - assertionFailure(error.localizedDescription) - } - } - .store(in: &disposeBag) - } - - deinit { - os_log("%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function) - } - - private struct Difference<T> { - let item: T - let sourceIndexPath: IndexPath - let targetIndexPath: IndexPath - let offset: CGFloat - } - - private func calculateReloadSnapshotDifference<T: Hashable>( - navigationBar: UINavigationBar, - tableView: UITableView, - oldSnapshot: NSDiffableDataSourceSnapshot<StatusSection, T>, - newSnapshot: NSDiffableDataSourceSnapshot<StatusSection, T> - ) -> Difference<T>? { - guard oldSnapshot.numberOfItems != 0 else { return nil } - - // old snapshot not empty. set source index path to first item if not match - let sourceIndexPath = UIViewController.topVisibleTableViewCellIndexPath(in: tableView, navigationBar: navigationBar) ?? IndexPath(row: 0, section: 0) - - guard sourceIndexPath.row < oldSnapshot.itemIdentifiers(inSection: .main).count else { return nil } - - let timelineItem = oldSnapshot.itemIdentifiers(inSection: .main)[sourceIndexPath.row] - guard let itemIndex = newSnapshot.itemIdentifiers(inSection: .main).firstIndex(of: timelineItem) else { return nil } - let targetIndexPath = IndexPath(row: itemIndex, section: 0) - - let offset = UIViewController.tableViewCellOriginOffsetToWindowTop(in: tableView, at: sourceIndexPath, navigationBar: navigationBar) - return Difference( - item: timelineItem, - sourceIndexPath: sourceIndexPath, - targetIndexPath: targetIndexPath, - offset: offset - ) - } -} diff --git a/Mastodon/Scene/Report/ReportFooterView.swift b/Mastodon/Scene/Report/ReportFooterView.swift deleted file mode 100644 index 0bad78cb2..000000000 --- a/Mastodon/Scene/Report/ReportFooterView.swift +++ /dev/null @@ -1,108 +0,0 @@ -// -// ReportFooterView.swift -// Mastodon -// -// Created by ihugo on 2021/4/22. -// - -import UIKit - -final class ReportFooterView: UIView { - enum Step: Int { - case one - case two - } - - lazy var stackview: UIStackView = { - let view = UIStackView() - view.axis = .vertical - view.alignment = .fill - view.spacing = 8 - view.translatesAutoresizingMaskIntoConstraints = false - return view - }() - - lazy var nextStepButton: PrimaryActionButton = { - let button = PrimaryActionButton() - button.setTitle(L10n.Common.Controls.Actions.continue, for: .normal) - button.translatesAutoresizingMaskIntoConstraints = false - return button - }() - - lazy var skipButton: UIButton = { - let button = UIButton(type: .system) - button.tintColor = Asset.Colors.brandBlue.color - button.setTitle(L10n.Common.Controls.Actions.skip, for: .normal) - button.translatesAutoresizingMaskIntoConstraints = false - return button - }() - - var step: Step = .one { - didSet { - switch step { - case .one: - nextStepButton.setTitle(L10n.Common.Controls.Actions.continue, for: .normal) - skipButton.setTitle(L10n.Common.Controls.Actions.skip, for: .normal) - case .two: - nextStepButton.setTitle(L10n.Scene.Report.send, for: .normal) - skipButton.setTitle(L10n.Scene.Report.skipToSend, for: .normal) - } - } - } - - override init(frame: CGRect) { - super.init(frame: frame) - - self.backgroundColor = ThemeService.shared.currentTheme.value.systemElevatedBackgroundColor - - stackview.addArrangedSubview(nextStepButton) - stackview.addArrangedSubview(skipButton) - addSubview(stackview) - - NSLayoutConstraint.activate([ - stackview.topAnchor.constraint( - equalTo: self.topAnchor, - constant: ReportView.continuTopMargin - ), - stackview.leadingAnchor.constraint( - equalTo: self.readableContentGuide.leadingAnchor, - constant: ReportView.horizontalMargin - ), - stackview.bottomAnchor.constraint( - equalTo: self.safeAreaLayoutGuide.bottomAnchor, - constant: -1 * ReportView.skipBottomMargin - ), - stackview.trailingAnchor.constraint( - equalTo: self.readableContentGuide.trailingAnchor, - constant: -1 * ReportView.horizontalMargin - ), - nextStepButton.heightAnchor.constraint( - equalToConstant: ReportView.buttonHeight - ), - skipButton.heightAnchor.constraint( - equalTo: nextStepButton.heightAnchor - ) - ]) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } -} - -#if canImport(SwiftUI) && DEBUG -import SwiftUI - -struct ReportFooterView_Previews: PreviewProvider { - static var previews: some View { - Group { - UIViewPreview(width: 375) { () -> UIView in - return ReportFooterView(frame: CGRect(origin: .zero, size: CGSize(width: 375, height: 164))) - } - .previewLayout(.fixed(width: 375, height: 164)) - } - } - -} - -#endif diff --git a/Mastodon/Scene/Report/ReportHeaderView.swift b/Mastodon/Scene/Report/ReportHeaderView.swift deleted file mode 100644 index 23572c118..000000000 --- a/Mastodon/Scene/Report/ReportHeaderView.swift +++ /dev/null @@ -1,126 +0,0 @@ -// -// ReportView.swift -// Mastodon -// -// Created by ihugo on 2021/4/20. -// - -import UIKit - -struct ReportView { - static var horizontalMargin: CGFloat { return 12 } - static var verticalMargin: CGFloat { return 22 } - static var buttonHeight: CGFloat { return 46 } - static var skipBottomMargin: CGFloat { return 8 } - static var continuTopMargin: CGFloat { return 22 } -} - -final class ReportHeaderView: UIView { - enum Step: Int { - case one - case two - } - - lazy var titleLabel: UILabel = { - let label = UILabel() - label.textColor = Asset.Colors.Label.secondary.color - label.font = UIFontMetrics(forTextStyle: .subheadline) - .scaledFont(for: .systemFont(ofSize: 15, weight: .regular)) - label.numberOfLines = 0 - return label - }() - - lazy var contentLabel: UILabel = { - let label = UILabel() - label.textColor = Asset.Colors.Label.primary.color - label.font = UIFontMetrics(forTextStyle: .title3) - .scaledFont(for: .systemFont(ofSize: 20, weight: .semibold)) - label.numberOfLines = 0 - return label - }() - - lazy var stackview: UIStackView = { - let view = UIStackView() - view.axis = .vertical - view.alignment = .leading - view.spacing = 2 - return view - }() - - let bottomSeparatorLine = UIView.separatorLine - - var step: Step = .one { - didSet { - switch step { - case .one: - titleLabel.text = L10n.Scene.Report.step1 - contentLabel.text = L10n.Scene.Report.content1 - case .two: - titleLabel.text = L10n.Scene.Report.step2 - contentLabel.text = L10n.Scene.Report.content2 - } - } - } - - override init(frame: CGRect) { - super.init(frame: frame) - - self.backgroundColor = ThemeService.shared.currentTheme.value.systemElevatedBackgroundColor - stackview.addArrangedSubview(titleLabel) - stackview.addArrangedSubview(contentLabel) - addSubview(stackview) - - stackview.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - stackview.topAnchor.constraint( - equalTo: self.topAnchor, - constant: ReportView.verticalMargin - ), - stackview.leadingAnchor.constraint( - equalTo: self.readableContentGuide.leadingAnchor, - constant: ReportView.horizontalMargin - ), - self.bottomAnchor.constraint( - equalTo: stackview.bottomAnchor, - constant: ReportView.verticalMargin - ), - self.readableContentGuide.trailingAnchor.constraint( - equalTo: stackview.trailingAnchor, - constant: ReportView.horizontalMargin - ) - ]) - - bottomSeparatorLine.translatesAutoresizingMaskIntoConstraints = false - addSubview(bottomSeparatorLine) - NSLayoutConstraint.activate([ - bottomSeparatorLine.leadingAnchor.constraint(equalTo: leadingAnchor), - bottomSeparatorLine.trailingAnchor.constraint(equalTo: trailingAnchor), - bottomSeparatorLine.bottomAnchor.constraint(equalTo: bottomAnchor), - bottomSeparatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: self)).priority(.defaultHigh), - ]) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } -} - -#if canImport(SwiftUI) && DEBUG -import SwiftUI - -struct ReportHeaderView_Previews: PreviewProvider { - static var previews: some View { - Group { - UIViewPreview { () -> UIView in - let view = ReportHeaderView() - view.step = .one - view.contentLabel.preferredMaxLayoutWidth = 335 - return view - } - .previewLayout(.fixed(width: 375, height: 110)) - } - } - -} - -#endif diff --git a/Mastodon/Scene/Report/ReportResult/ReportResultViewController.swift b/Mastodon/Scene/Report/ReportResult/ReportResultViewController.swift new file mode 100644 index 000000000..26f56b98d --- /dev/null +++ b/Mastodon/Scene/Report/ReportResult/ReportResultViewController.swift @@ -0,0 +1,113 @@ +// +// ReportResultViewController.swift +// Mastodon +// +// Created by MainasuK on 2022-2-8. +// + +import os.log +import UIKit +import Combine +import MastodonAsset +import MastodonLocalization + +final class ReportResultViewController: UIViewController, NeedsDependency, ReportViewControllerAppearance { + + var disposeBag = Set<AnyCancellable>() + private var observations = Set<NSKeyValueObservation>() + + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + var viewModel: ReportResultViewModel! + + let tableView: UITableView = { + let tableView = ControlContainableTableView() + tableView.backgroundColor = Asset.Scene.Report.background.color + tableView.rowHeight = UITableView.automaticDimension + tableView.separatorStyle = .none + tableView.backgroundColor = .clear + tableView.keyboardDismissMode = .onDrag + tableView.allowsMultipleSelection = true + if #available(iOS 15.0, *) { + tableView.sectionHeaderTopPadding = .leastNonzeroMagnitude + } else { + // Fallback on earlier versions + } + return tableView + }() + + let navigationActionView: NavigationActionView = { + let navigationActionView = NavigationActionView() + navigationActionView.backgroundColor = Asset.Scene.Onboarding.background.color + navigationActionView.hidesBackButton = true + navigationActionView.nextButton.setTitle(L10n.Common.Controls.Actions.done, for: .normal) + return navigationActionView + }() + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension ReportResultViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + setupAppearance() + defer { setupNavigationBarBackgroundView() } + + navigationItem.hidesBackButton = true + + 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( + tableView: tableView + ) + + navigationActionView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(navigationActionView) + defer { + view.bringSubviewToFront(navigationActionView) + } + NSLayoutConstraint.activate([ + navigationActionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + navigationActionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + view.bottomAnchor.constraint(equalTo: navigationActionView.bottomAnchor), + ]) + + navigationActionView + .observe(\.bounds, options: [.initial, .new]) { [weak self] navigationActionView, _ in + guard let self = self else { return } + let inset = navigationActionView.frame.height + self.tableView.contentInset.bottom = inset + self.tableView.verticalScrollIndicatorInsets.bottom = inset + } + .store(in: &observations) + + + navigationActionView.nextButton.addTarget(self, action: #selector(ReportSupplementaryViewController.nextButtonDidPressed(_:)), for: .touchUpInside) + } + +} + +extension ReportResultViewController { + + @objc func nextButtonDidPressed(_ sender: UIButton) { + dismiss(animated: true, completion: nil) + } + +} + +// MARK: - UITableViewDelegate +extension ReportResultViewController: UITableViewDelegate { } diff --git a/Mastodon/Scene/Report/ReportResult/ReportResultViewModel+Diffable.swift b/Mastodon/Scene/Report/ReportResult/ReportResultViewModel+Diffable.swift new file mode 100644 index 000000000..a9c1272df --- /dev/null +++ b/Mastodon/Scene/Report/ReportResult/ReportResultViewModel+Diffable.swift @@ -0,0 +1,37 @@ +// +// ReportResultViewModel+Diffable.swift +// Mastodon +// +// Created by MainasuK on 2022-2-8. +// + +import UIKit +import Combine +import CoreData +import CoreDataStack +import MastodonAsset +import MastodonLocalization + +extension ReportResultViewModel { + + static let reportItemHeaderContext = ReportItem.HeaderContext( + primaryLabelText: "Thanks for reporting, we’ll look into this.", + secondaryLabelText: "" + ) + + func setupDiffableDataSource( + tableView: UITableView + ) { + diffableDataSource = ReportSection.diffableDataSource( + tableView: tableView, + context: context, + configuration: ReportSection.Configuration() + ) + + var snapshot = NSDiffableDataSourceSnapshot<ReportSection, ReportItem>() + snapshot.appendSections([.main]) + snapshot.appendItems([.header(context: ReportResultViewModel.reportItemHeaderContext)], toSection: .main) + snapshot.appendItems([.result(record: user)], toSection: .main) + diffableDataSource?.apply(snapshot) + } +} diff --git a/Mastodon/Scene/Report/ReportResult/ReportResultViewModel.swift b/Mastodon/Scene/Report/ReportResult/ReportResultViewModel.swift new file mode 100644 index 000000000..79fec4936 --- /dev/null +++ b/Mastodon/Scene/Report/ReportResult/ReportResultViewModel.swift @@ -0,0 +1,36 @@ +// +// ReportResultViewModel.swift +// Mastodon +// +// Created by MainasuK on 2022-2-8. +// + +import Combine +import CoreData +import CoreDataStack +import Foundation +import MastodonSDK +import os.log +import UIKit + +class ReportResultViewModel { + + var disposeBag = Set<AnyCancellable>() + + // input + let context: AppContext + let user: ManagedObjectRecord<MastodonUser> + + // output + var diffableDataSource: UITableViewDiffableDataSource<ReportSection, ReportItem>? + + init( + context: AppContext, + user: ManagedObjectRecord<MastodonUser> + ) { + self.context = context + self.user = user + // end init + } + +} diff --git a/Mastodon/Scene/Report/ReportStatus/ReportViewController.swift b/Mastodon/Scene/Report/ReportStatus/ReportViewController.swift new file mode 100644 index 000000000..12291a964 --- /dev/null +++ b/Mastodon/Scene/Report/ReportStatus/ReportViewController.swift @@ -0,0 +1,221 @@ +// +// ReportViewController.swift +// Mastodon +// +// Created by ihugo on 2021/4/20. +// + +import os.log +import UIKit +import Combine +import CoreDataStack +import MastodonAsset +import MastodonLocalization + +class ReportViewController: UIViewController, NeedsDependency, ReportViewControllerAppearance { + + var disposeBag = Set<AnyCancellable>() + private var observations = Set<NSKeyValueObservation>() + + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + var viewModel: ReportViewModel! + + // MAKK: - UI + lazy var cancelBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .cancel, + target: self, + action: #selector(ReportViewController.cancelBarButtonItemDidPressed(_:)) + ) + + let tableView: UITableView = { + let tableView = ControlContainableTableView() + tableView.backgroundColor = Asset.Scene.Report.background.color + tableView.rowHeight = UITableView.automaticDimension + tableView.separatorStyle = .none + tableView.backgroundColor = .clear + tableView.keyboardDismissMode = .onDrag + tableView.allowsMultipleSelection = true + if #available(iOS 15.0, *) { + tableView.sectionHeaderTopPadding = .leastNonzeroMagnitude + } else { + // Fallback on earlier versions + } + return tableView + }() + + let navigationActionView: NavigationActionView = { + let navigationActionView = NavigationActionView() + navigationActionView.backgroundColor = Asset.Scene.Onboarding.background.color + navigationActionView.backButton.setTitle(L10n.Common.Controls.Actions.skip, for: .normal) + return navigationActionView + }() + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension ReportViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + setupAppearance() + defer { setupNavigationBarBackgroundView() } + + navigationItem.rightBarButtonItem = cancelBarButtonItem + + 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( + tableView: tableView + ) + + navigationActionView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(navigationActionView) + defer { + view.bringSubviewToFront(navigationActionView) + } + NSLayoutConstraint.activate([ + navigationActionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + navigationActionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + view.bottomAnchor.constraint(equalTo: navigationActionView.bottomAnchor), + ]) + + navigationActionView + .observe(\.bounds, options: [.initial, .new]) { [weak self] navigationActionView, _ in + guard let self = self else { return } + let inset = navigationActionView.frame.height + self.tableView.contentInset.bottom = inset + self.tableView.verticalScrollIndicatorInsets.bottom = inset + } + .store(in: &observations) + + // 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 } + guard self.view.window != nil else { return } + self.viewModel.stateMachine.enter(ReportViewModel.State.Loading.self) + } + .store(in: &disposeBag) + + viewModel.$isNextButtonEnabled + .receive(on: DispatchQueue.main) + .assign(to: \.isEnabled, on: navigationActionView.nextButton) + .store(in: &disposeBag) + + navigationActionView.backButton.addTarget(self, action: #selector(ReportViewController.skipButtonDidPressed(_:)), for: .touchUpInside) + navigationActionView.nextButton.addTarget(self, action: #selector(ReportViewController.nextButtonDidPressed(_:)), for: .touchUpInside) + } + +} + +extension ReportViewController { + + @objc private func cancelBarButtonItemDidPressed(_ sender: UIBarButtonItem) { + dismiss(animated: true, completion: nil) + } + + @objc func skipButtonDidPressed(_ sender: UIButton) { + var selectStatuses: [ManagedObjectRecord<Status>] = [] + if let selectStatus = viewModel.status { + selectStatuses.append(selectStatus) + } + + let reportSupplementaryViewModel = ReportSupplementaryViewModel( + context: context, + user: viewModel.user, + selectStatuses: selectStatuses + ) + coordinator.present( + scene: .reportSupplementary(viewModel: reportSupplementaryViewModel), + from: self, + transition: .show + ) + } + + @objc func nextButtonDidPressed(_ sender: UIButton) { + let selectStatuses = Array(viewModel.selectStatuses) + guard !selectStatuses.isEmpty else { return } + + let reportSupplementaryViewModel = ReportSupplementaryViewModel( + context: context, + user: viewModel.user, + selectStatuses: selectStatuses + ) + coordinator.present( + scene: .reportSupplementary(viewModel: reportSupplementaryViewModel), + from: self, + transition: .show + ) + } + +} + +// MARK: - UITableViewDelegate +extension ReportViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { + guard let item = viewModel.diffableDataSource?.itemIdentifier(for: indexPath), + case .status = item + else { + return nil + } + + return indexPath + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard let item = viewModel.diffableDataSource?.itemIdentifier(for: indexPath), + case let .status(record) = item + else { + tableView.deselectRow(at: indexPath, animated: true) + return + } + + viewModel.selectStatuses.append(record) + } + + func tableView(_ tableView: UITableView, willDeselectRowAt indexPath: IndexPath) -> IndexPath? { + guard let item = viewModel.diffableDataSource?.itemIdentifier(for: indexPath), + case let .status(record) = item + else { + return nil + } + + // disallow deselect initial selection + guard record != viewModel.status else { return nil } + + return indexPath + } + + func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) { + guard let item = viewModel.diffableDataSource?.itemIdentifier(for: indexPath), + case let .status(record) = item + else { + return + } + + viewModel.selectStatuses.remove(record) + } +} + +// MARK: - UIAdaptivePresentationControllerDelegate +extension ReportViewController: UIAdaptivePresentationControllerDelegate { + func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool { + return false + } +} diff --git a/Mastodon/Scene/Report/ReportStatus/ReportViewModel+Diffable.swift b/Mastodon/Scene/Report/ReportStatus/ReportViewModel+Diffable.swift new file mode 100644 index 000000000..30ec5d872 --- /dev/null +++ b/Mastodon/Scene/Report/ReportStatus/ReportViewModel+Diffable.swift @@ -0,0 +1,85 @@ +// +// ReportViewModel+Diffable.swift +// Mastodon +// +// Created by ihugo on 2021/4/19. +// + +import UIKit +import Combine +import CoreData +import CoreDataStack +import MastodonAsset +import MastodonLocalization + +extension ReportViewModel { + + static let reportItemHeaderContext = ReportItem.HeaderContext( + primaryLabelText: L10n.Scene.Report.content1, + secondaryLabelText: L10n.Scene.Report.step1 + ) + + func setupDiffableDataSource( + tableView: UITableView + ) { + diffableDataSource = ReportSection.diffableDataSource( + tableView: tableView, + context: context, + configuration: ReportSection.Configuration() + ) + + var snapshot = NSDiffableDataSourceSnapshot<ReportSection, ReportItem>() + snapshot.appendSections([.main]) + diffableDataSource?.apply(snapshot) + + statusFetchedResultsController.$records + .receive(on: DispatchQueue.main) + .sink { [weak self] records in + guard let self = self else { return } + guard let diffableDataSource = self.diffableDataSource else { return } + + var snapshot = NSDiffableDataSourceSnapshot<ReportSection, ReportItem>() + snapshot.appendSections([.main]) + + snapshot.appendItems([.header(context: ReportViewModel.reportItemHeaderContext)], toSection: .main) + + let items = records.map { ReportItem.status(record: $0) } + snapshot.appendItems(items, toSection: .main) + + let selectItems = items.filter { item in + guard case let .status(record) = item else { return false } + return self.selectStatuses.contains(record) + } + + guard let currentState = self.stateMachine.currentState else { return } + switch currentState { + case is State.Initial, + is State.Loading, + is State.Idle, + is State.Fail: + snapshot.appendItems([.bottomLoader], toSection: .main) + case is State.NoMore: + break + default: + assertionFailure() + break + } + + diffableDataSource.applySnapshot(snapshot, animated: false) { [weak self] in + guard let self = self else { return } + guard let diffableDataSource = self.diffableDataSource else { return } + + let selectIndexPaths = selectItems.compactMap { item in + diffableDataSource.indexPath(for: item) + } + + // Only the first selection make the initial selection + // The later selection could be ignored + for indexPath in selectIndexPaths { + tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none) + } + } + } + .store(in: &disposeBag) + } +} diff --git a/Mastodon/Scene/Report/ReportStatus/ReportViewModel+State.swift b/Mastodon/Scene/Report/ReportStatus/ReportViewModel+State.swift new file mode 100644 index 000000000..1bc43830f --- /dev/null +++ b/Mastodon/Scene/Report/ReportStatus/ReportViewModel+State.swift @@ -0,0 +1,173 @@ +// +// ReportViewModel+State.swift +// Mastodon +// +// Created by MainasuK on 2022-2-7. +// + +import os.log +import func QuartzCore.CACurrentMediaTime +import Foundation +import CoreData +import CoreDataStack +import GameplayKit + +extension ReportViewModel { + class State: GKState { + + let logger = Logger(subsystem: "ReportViewModel.State", category: "StateMachine") + + let id = UUID() + + var name: String { + String(describing: Self.self) + } + + weak var viewModel: ReportViewModel? + + init(viewModel: ReportViewModel) { + self.viewModel = viewModel + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + let previousState = previousState as? ReportViewModel.State + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [\(self.id.uuidString)] enter \(self.name), previous: \(previousState?.name ?? "<nil>")") + } + + @MainActor + func enter(state: State.Type) { + stateMachine?.enter(state) + } + + deinit { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [\(self.id.uuidString)] \(self.name)") + } + } +} + +extension ReportViewModel.State { + class Initial: ReportViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + guard let _ = viewModel else { return false } + switch stateClass { + case is Loading.Type: + return true + default: + return false + } + } + } + + class Loading: ReportViewModel.State { + 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) + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + guard let authenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { + stateMachine.enter(Fail.self) + return + } + + let maxID = viewModel.statusFetchedResultsController.statusIDs.value.last + + Task { + let managedObjectContext = viewModel.context.managedObjectContext + let _userID: MastodonUser.ID? = try await managedObjectContext.perform { + guard let user = viewModel.user.object(in: managedObjectContext) else { return nil } + return user.id + } + guard let userID = _userID else { + await enter(state: Fail.self) + return + } + + do { + let response = try await viewModel.context.apiService.userTimeline( + accountID: userID, + maxID: maxID, + sinceID: nil, + excludeReplies: true, + excludeReblogs: true, + onlyMedia: false, + authenticationBox: authenticationBox + ) + + var hasNewStatusesAppend = false + var statusIDs = viewModel.statusFetchedResultsController.statusIDs.value + for status in response.value { + guard !statusIDs.contains(status.id) else { continue } + statusIDs.append(status.id) + hasNewStatusesAppend = true + } + + if hasNewStatusesAppend { + await enter(state: Idle.self) + } else { + await enter(state: NoMore.self) + } + viewModel.statusFetchedResultsController.statusIDs.value = statusIDs + + } catch { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch user timeline fail: \(error.localizedDescription)") + await enter(state: Fail.self) + } + } + } + } + + class Fail: ReportViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Loading.Type: + return true + default: + return false + } + } + } + + class Idle: ReportViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Loading.Type: + return true + default: + return false + } + } + } + + class NoMore: ReportViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return false + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let viewModel = viewModel, let _ = stateMachine else { return } + guard let diffableDataSource = viewModel.diffableDataSource else { + assertionFailure() + return + } + DispatchQueue.main.async { + var snapshot = diffableDataSource.snapshot() + snapshot.deleteItems([.bottomLoader]) + diffableDataSource.apply(snapshot) + } + } + } + +} diff --git a/Mastodon/Scene/Report/ReportStatus/ReportViewModel.swift b/Mastodon/Scene/Report/ReportStatus/ReportViewModel.swift new file mode 100644 index 000000000..46a475262 --- /dev/null +++ b/Mastodon/Scene/Report/ReportStatus/ReportViewModel.swift @@ -0,0 +1,78 @@ +// +// ReportViewModel.swift +// Mastodon +// +// Created by ihugo on 2021/4/19. +// + +import Combine +import CoreData +import CoreDataStack +import Foundation +import GameplayKit +import MastodonSDK +import OrderedCollections +import os.log +import UIKit + +class ReportViewModel { + + var disposeBag = Set<AnyCancellable>() + + // input + let context: AppContext + let user: ManagedObjectRecord<MastodonUser> + let status: ManagedObjectRecord<Status>? + let statusFetchedResultsController: StatusFetchedResultsController + let listBatchFetchViewModel = ListBatchFetchViewModel() + + @Published var selectStatuses = OrderedSet<ManagedObjectRecord<Status>>() + + // output + var diffableDataSource: UITableViewDiffableDataSource<ReportSection, ReportItem>? + private(set) lazy var stateMachine: GKStateMachine = { + let stateMachine = GKStateMachine(states: [ + State.Initial(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 + }() + + @Published var isNextButtonEnabled = false + + init( + context: AppContext, + user: ManagedObjectRecord<MastodonUser>, + status: ManagedObjectRecord<Status>? + ) { + self.context = context + self.user = user + self.status = status + self.statusFetchedResultsController = StatusFetchedResultsController( + managedObjectContext: context.managedObjectContext, + domain: nil, + additionalTweetPredicate: nil + ) + // end init + + if let status = status { + selectStatuses.append(status) + } + + context.authenticationService.activeMastodonAuthenticationBox + .map { $0?.domain } + .assign(to: \.value, on: statusFetchedResultsController.domain) + .store(in: &disposeBag) + + $selectStatuses + .map { statuses -> Bool in + return status == nil ? !statuses.isEmpty : statuses.count > 1 + } + .assign(to: &$isNextButtonEnabled) + } + +} diff --git a/Mastodon/Scene/Report/ReportSupplementary/ReportSupplementaryViewController.swift b/Mastodon/Scene/Report/ReportSupplementary/ReportSupplementaryViewController.swift new file mode 100644 index 000000000..4f6e102b2 --- /dev/null +++ b/Mastodon/Scene/Report/ReportSupplementary/ReportSupplementaryViewController.swift @@ -0,0 +1,181 @@ +// +// ReportSupplementaryViewController.swift +// Mastodon +// +// Created by MainasuK on 2022-2-7. +// + +import os.log +import UIKit +import Combine +import MastodonAsset +import MastodonLocalization + +final class ReportSupplementaryViewController: UIViewController, NeedsDependency, ReportViewControllerAppearance { + + let logger = Logger(subsystem: "ReportSupplementaryViewController", category: "ViewController") + + var disposeBag = Set<AnyCancellable>() + private var observations = Set<NSKeyValueObservation>() + + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + var viewModel: ReportSupplementaryViewModel! { willSet { precondition(!isViewLoaded) } } + + + // MAKK: - UI + lazy var cancelBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .cancel, + target: self, + action: #selector(ReportSupplementaryViewController.cancelBarButtonItemDidPressed(_:)) + ) + + let activityIndicatorBarButtonItem: UIBarButtonItem = { + let activityIndicatorView = UIActivityIndicatorView(style: .medium) + activityIndicatorView.startAnimating() + let barButtonItem = UIBarButtonItem(customView: activityIndicatorView) + return barButtonItem + }() + + let tableView: UITableView = { + let tableView = ControlContainableTableView() + tableView.backgroundColor = Asset.Scene.Report.background.color + tableView.rowHeight = UITableView.automaticDimension + tableView.separatorStyle = .none + tableView.backgroundColor = .clear + tableView.keyboardDismissMode = .onDrag + if #available(iOS 15.0, *) { + tableView.sectionHeaderTopPadding = .leastNonzeroMagnitude + } else { + // Fallback on earlier versions + } + return tableView + }() + + let navigationActionView: NavigationActionView = { + let navigationActionView = NavigationActionView() + navigationActionView.backgroundColor = Asset.Scene.Onboarding.background.color + navigationActionView.backButton.setTitle(L10n.Common.Controls.Actions.skip, for: .normal) + return navigationActionView + }() + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension ReportSupplementaryViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + setupAppearance() + defer { setupNavigationBarBackgroundView() } + + navigationItem.rightBarButtonItem = cancelBarButtonItem + + viewModel.$isReporting + .receive(on: DispatchQueue.main) + .sink { [weak self] isReporting in + guard let self = self else { return } + self.navigationActionView.isUserInteractionEnabled = !isReporting + } + .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( + tableView: tableView + ) + + navigationActionView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(navigationActionView) + defer { + view.bringSubviewToFront(navigationActionView) + } + NSLayoutConstraint.activate([ + navigationActionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + navigationActionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + view.bottomAnchor.constraint(equalTo: navigationActionView.bottomAnchor), + ]) + + navigationActionView + .observe(\.bounds, options: [.initial, .new]) { [weak self] navigationActionView, _ in + guard let self = self else { return } + let inset = navigationActionView.frame.height + self.tableView.contentInset.bottom = inset + self.tableView.verticalScrollIndicatorInsets.bottom = inset + } + .store(in: &observations) + + viewModel.$isNextButtonEnabled + .receive(on: DispatchQueue.main) + .assign(to: \.isEnabled, on: navigationActionView.nextButton) + .store(in: &disposeBag) + + navigationActionView.backButton.addTarget(self, action: #selector(ReportSupplementaryViewController.skipButtonDidPressed(_:)), for: .touchUpInside) + navigationActionView.nextButton.addTarget(self, action: #selector(ReportSupplementaryViewController.nextButtonDidPressed(_:)), for: .touchUpInside) + } + +} + +extension ReportSupplementaryViewController { + private func report(withComment: Bool) { + Task { + do { + let _ = try await viewModel.report(withComment: withComment) + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): report success") + + let reportResultViewModel = ReportResultViewModel( + context: context, + user: viewModel.user + ) + + coordinator.present( + scene: .reportResult(viewModel: reportResultViewModel), + from: self, + transition: .show + ) + + } catch { + let alertController = UIAlertController(for: error, title: nil, preferredStyle: .alert) + let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil) + alertController.addAction(okAction) + self.coordinator.present( + scene: .alertController(alertController: alertController), + from: nil, + transition: .alertController(animated: true, completion: nil) + ) + } + } // end Task + } +} + +extension ReportSupplementaryViewController { + + @objc private func cancelBarButtonItemDidPressed(_ sender: UIBarButtonItem) { + dismiss(animated: true, completion: nil) + } + + @objc func skipButtonDidPressed(_ sender: UIButton) { + report(withComment: false) + } + + @objc func nextButtonDidPressed(_ sender: UIButton) { + report(withComment: true) + } + +} + +// MARK: - UITableViewDelegate +extension ReportSupplementaryViewController: UITableViewDelegate { } diff --git a/Mastodon/Scene/Report/ReportSupplementary/ReportSupplementaryViewModel+Diffable.swift b/Mastodon/Scene/Report/ReportSupplementary/ReportSupplementaryViewModel+Diffable.swift new file mode 100644 index 000000000..5fb9e7421 --- /dev/null +++ b/Mastodon/Scene/Report/ReportSupplementary/ReportSupplementaryViewModel+Diffable.swift @@ -0,0 +1,38 @@ +// +// ReportSupplementaryViewModel+Diffable.swift +// Mastodon +// +// Created by MainasuK on 2022-2-7. +// + +import UIKit +import Combine +import CoreData +import CoreDataStack +import MastodonAsset +import MastodonLocalization + +extension ReportSupplementaryViewModel { + + static let reportItemHeaderContext = ReportItem.HeaderContext( + primaryLabelText: L10n.Scene.Report.content2, + secondaryLabelText: L10n.Scene.Report.step2 + ) + + func setupDiffableDataSource( + tableView: UITableView + ) { + diffableDataSource = ReportSection.diffableDataSource( + tableView: tableView, + context: context, + configuration: ReportSection.Configuration() + ) + + var snapshot = NSDiffableDataSourceSnapshot<ReportSection, ReportItem>() + snapshot.appendSections([.main]) + snapshot.appendItems([.header(context: ReportSupplementaryViewModel.reportItemHeaderContext)], toSection: .main) + snapshot.appendItems([.comment(context: commentContext)], toSection: .main) + + diffableDataSource?.apply(snapshot, animatingDifferences: false) + } +} diff --git a/Mastodon/Scene/Report/ReportSupplementary/ReportSupplementaryViewModel.swift b/Mastodon/Scene/Report/ReportSupplementary/ReportSupplementaryViewModel.swift new file mode 100644 index 000000000..e73e82dd6 --- /dev/null +++ b/Mastodon/Scene/Report/ReportSupplementary/ReportSupplementaryViewModel.swift @@ -0,0 +1,82 @@ +// +// ReportSupplementaryViewModel.swift +// Mastodon +// +// Created by MainasuK on 2022-2-7. +// + +import UIKit +import Combine +import CoreDataStack +import MastodonSDK + +class ReportSupplementaryViewModel { + + // Input + var context: AppContext + let user: ManagedObjectRecord<MastodonUser> + let selectStatuses: [ManagedObjectRecord<Status>] + let commentContext = ReportItem.CommentContext() + + // output + var diffableDataSource: UITableViewDiffableDataSource<ReportSection, ReportItem>? + @Published var isNextButtonEnabled = false + @Published var isReporting = false + @Published var isReportSuccess = false + + init( + context: AppContext, + user: ManagedObjectRecord<MastodonUser>, + selectStatuses: [ManagedObjectRecord<Status>] + ) { + self.context = context + self.user = user + self.selectStatuses = selectStatuses + // end init + + commentContext.$comment + .map { comment -> Bool in + return !comment.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + .assign(to: &$isNextButtonEnabled) + } + +} + +extension ReportSupplementaryViewModel { + func report(withComment: Bool) async throws { + guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { + assertionFailure() + return + } + + let managedObjectContext = context.managedObjectContext + let _query: Mastodon.API.Reports.FileReportQuery? = try await managedObjectContext.perform { + guard let user = self.user.object(in: managedObjectContext) else { return nil } + let statusIDs = self.selectStatuses.compactMap { record -> Status.ID? in + guard let status = record.object(in: managedObjectContext) else { return nil } + return status.id + } + return Mastodon.API.Reports.FileReportQuery( + accountID: user.id, + statusIDs: statusIDs, + comment: withComment ? self.commentContext.comment : nil, + forward: nil + ) + } + + guard let query = _query else { return } + + do { + isReporting = true + let _ = try await context.apiService.report( + query: query, + authenticationBox: authenticationBox + ) + isReportSuccess = true + } catch { + isReporting = false + throw error + } + } +} diff --git a/Mastodon/Scene/Report/ReportViewController.swift b/Mastodon/Scene/Report/ReportViewController.swift deleted file mode 100644 index b97424cb5..000000000 --- a/Mastodon/Scene/Report/ReportViewController.swift +++ /dev/null @@ -1,368 +0,0 @@ -// -// ReportViewController.swift -// Mastodon -// -// Created by ihugo on 2021/4/20. -// - -import AVKit -import Combine -import CoreData -import CoreDataStack -import os.log -import UIKit -import MastodonSDK -import MastodonMeta - -class ReportViewController: UIViewController, NeedsDependency { - static let kAnimationDuration: TimeInterval = 0.33 - - weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } - weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } - - var viewModel: ReportViewModel! { willSet { precondition(!isViewLoaded) } } - var disposeBag = Set<AnyCancellable>() - let didToggleSelected = PassthroughSubject<Item, Never>() - let comment = CurrentValueSubject<String?, Never>(nil) - let step1Continue = PassthroughSubject<Void, Never>() - let step1Skip = PassthroughSubject<Void, Never>() - let step2Continue = PassthroughSubject<Void, Never>() - let step2Skip = PassthroughSubject<Void, Never>() - let cancel = PassthroughSubject<Void, Never>() - - // MAKK: - UI - lazy var header: ReportHeaderView = { - let view = ReportHeaderView() - view.translatesAutoresizingMaskIntoConstraints = false - return view - }() - - lazy var footer: ReportFooterView = { - let view = ReportFooterView() - view.translatesAutoresizingMaskIntoConstraints = false - return view - }() - - lazy var contentView: UIView = { - let view = UIView() - view.translatesAutoresizingMaskIntoConstraints = false - view.setContentHuggingPriority(.defaultLow, for: .vertical) - view.backgroundColor = ThemeService.shared.currentTheme.value.systemElevatedBackgroundColor - return view - }() - - lazy var stackView: UIStackView = { - let view = UIStackView() - view.axis = .vertical - view.alignment = .fill - view.distribution = .fill - view.translatesAutoresizingMaskIntoConstraints = false - return view - }() - - lazy var tableView: UITableView = { - let tableView = ControlContainableTableView() - tableView.register(ReportedStatusTableViewCell.self, forCellReuseIdentifier: String(describing: ReportedStatusTableViewCell.self)) - tableView.rowHeight = UITableView.automaticDimension - tableView.separatorStyle = .none - tableView.backgroundColor = .clear - tableView.translatesAutoresizingMaskIntoConstraints = false - tableView.delegate = self - tableView.prefetchDataSource = self - tableView.allowsMultipleSelection = true - return tableView - }() - - lazy var textView: UITextView = { - let textView = UITextView() - textView.font = .preferredFont(forTextStyle: .body) - textView.isScrollEnabled = false - textView.placeholder = L10n.Scene.Report.textPlaceholder - textView.backgroundColor = .clear - textView.delegate = self - textView.isScrollEnabled = true - textView.keyboardDismissMode = .onDrag - return textView - }() - - lazy var bottomSpacing: UIView = { - let view = UIView() - view.translatesAutoresizingMaskIntoConstraints = false - return view - }() - - var bottomConstraint: NSLayoutConstraint! - - let titleView = DoubleTitleLabelNavigationBarTitleView() - - override func viewDidLoad() { - super.viewDidLoad() - - setupView() - - viewModel.setupDiffableDataSource( - for: tableView, - dependency: self - ) - - bindViewModel() - bindActions() - } - - // MAKR: - Private methods - private func setupView() { - 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) - - setupNavigation() - - stackView.addArrangedSubview(header) - stackView.addArrangedSubview(contentView) - stackView.addArrangedSubview(footer) - stackView.addArrangedSubview(bottomSpacing) - - contentView.addSubview(tableView) - - 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), - tableView.topAnchor.constraint(equalTo: contentView.topAnchor), - tableView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - tableView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - tableView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - ]) - - self.bottomConstraint = bottomSpacing.heightAnchor.constraint(equalToConstant: 0) - bottomConstraint.isActive = true - - header.step = .one - } - - private func bindActions() { - footer.nextStepButton.addTarget(self, action: #selector(continueButtonDidClick), for: .touchUpInside) - footer.skipButton.addTarget(self, action: #selector(skipButtonDidClick), for: .touchUpInside) - } - - private func bindViewModel() { - let input = ReportViewModel.Input( - didToggleSelected: didToggleSelected.eraseToAnyPublisher(), - comment: comment.eraseToAnyPublisher(), - step1Continue: step1Continue.eraseToAnyPublisher(), - step1Skip: step1Skip.eraseToAnyPublisher(), - step2Continue: step2Continue.eraseToAnyPublisher(), - step2Skip: step2Skip.eraseToAnyPublisher(), - cancel: cancel.eraseToAnyPublisher() - ) - let output = viewModel.transform(input: input) - output?.currentStep - .receive(on: DispatchQueue.main) - .sink(receiveValue: { [weak self] (step) in - guard step == .two else { return } - guard let self = self else { return } - - self.header.step = .two - self.footer.step = .two - self.switchToStep2Content() - }) - .store(in: &disposeBag) - - output?.continueEnableSubject - .receive(on: DispatchQueue.main) - .filter { [weak self] _ in - guard let step = self?.viewModel.currentStep.value, step == .one else { return false } - return true - } - .assign(to: \.nextStepButton.isEnabled, on: footer) - .store(in: &disposeBag) - - output?.sendEnableSubject - .receive(on: DispatchQueue.main) - .filter { [weak self] _ in - guard let step = self?.viewModel.currentStep.value, step == .two else { return false } - return true - } - .assign(to: \.nextStepButton.isEnabled, on: footer) - .store(in: &disposeBag) - - output?.reportResult - .print() - .receive(on: DispatchQueue.main) - .sink(receiveCompletion: { _ in - }, receiveValue: { [weak self] data in - let (success, error) = data - if success { - self?.dismiss(animated: true, completion: nil) - } else if let error = error { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fail to file a report : %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) - - let alertController = UIAlertController(for: error, title: nil, preferredStyle: .alert) - let okAction = UIAlertAction(title: "OK", style: .default, handler: nil) - alertController.addAction(okAction) - self?.coordinator.present( - scene: .alertController(alertController: alertController), - from: nil, - transition: .alertController(animated: true, completion: nil) - ) - } - }) - .store(in: &disposeBag) - - Publishers.CombineLatest( - KeyboardResponderService.shared.state.eraseToAnyPublisher(), - KeyboardResponderService.shared.endFrame.eraseToAnyPublisher() - ) - .sink(receiveValue: { [weak self] state, endFrame in - guard let self = self else { return } - - guard state == .dock else { - self.bottomConstraint.constant = 0.0 - return - } - - let contentFrame = self.view.convert(self.view.frame, to: nil) - let padding = contentFrame.maxY - endFrame.minY - guard padding > 0 else { - self.bottomConstraint.constant = 0.0 - UIView.animate(withDuration: 0.33) { - self.view.layoutIfNeeded() - } - return - } - - self.bottomConstraint.constant = padding - UIView.animate(withDuration: 0.33) { - self.view.layoutIfNeeded() - } - }) - .store(in: &disposeBag) - } - - private func setupNavigation() { - navigationItem.rightBarButtonItem - = UIBarButtonItem(barButtonSystemItem: UIBarButtonItem.SystemItem.cancel, - target: self, - action: #selector(doneButtonDidClick)) - navigationItem.rightBarButtonItem?.tintColor = ThemeService.tintColor - - // fetch old mastodon user - let beReportedUser: MastodonUser? = { - guard let domain = context.authenticationService.activeMastodonAuthenticationBox.value?.domain else { - return nil - } - let request = MastodonUser.sortedFetchRequest - request.predicate = MastodonUser.predicate(domain: domain, id: viewModel.user.id) - request.fetchLimit = 1 - request.returnsObjectsAsFaults = false - do { - return try viewModel.statusFetchedResultsController.fetchedResultsController.managedObjectContext.fetch(request).first - } catch { - assertionFailure(error.localizedDescription) - return nil - } - }() - - navigationItem.titleView = titleView - if let user = beReportedUser { - do { - 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) - titleView.update(titleMetaContent: metaContent, subtitle: nil) - } - } - - } - - private func switchToStep2Content() { - self.contentView.addSubview(self.textView) - self.textView.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - self.textView.topAnchor.constraint(equalTo: self.contentView.topAnchor), - self.textView.leadingAnchor.constraint( - equalTo: self.contentView.readableContentGuide.leadingAnchor, - constant: ReportView.horizontalMargin - ), - self.textView.bottomAnchor.constraint(equalTo: self.contentView.bottomAnchor), - self.contentView.trailingAnchor.constraint( - equalTo: self.textView.trailingAnchor, - constant: ReportView.horizontalMargin - ), - ]) - self.textView.layoutIfNeeded() - - UIView.transition( - with: contentView, - duration: ReportViewController.kAnimationDuration, - options: UIView.AnimationOptions.transitionCrossDissolve) { - [weak self] in - guard let self = self else { return } - - self.contentView.addSubview(self.textView) - self.tableView.isHidden = true - } completion: { (_) in - } - } - - // Mark: - Actions - @objc func doneButtonDidClick() { - dismiss(animated: true, completion: nil) - } - - @objc func continueButtonDidClick() { - if viewModel.currentStep.value == .one { - step1Continue.send() - } else { - step2Continue.send() - } - } - - @objc func skipButtonDidClick() { - if viewModel.currentStep.value == .one { - step1Skip.send() - } else { - step2Skip.send() - } - } -} - -// MARK: - UITableViewDelegate -extension ReportViewController: UITableViewDelegate { - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - guard let item = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else { - return - } - didToggleSelected.send(item) - } - - func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) { - guard let item = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else { - return - } - didToggleSelected.send(item) - } -} - -// MARK: - UITableViewDataSourcePrefetching -extension ReportViewController: UITableViewDataSourcePrefetching { - func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { - viewModel.prefetchData(prefetchRowsAt: indexPaths) - } -} - -// MARK: - UITextViewDelegate -extension ReportViewController: UITextViewDelegate { - func textViewDidChange(_ textView: UITextView) { - self.comment.send(textView.text) - } -} diff --git a/Mastodon/Scene/Report/ReportViewModel+Data.swift b/Mastodon/Scene/Report/ReportViewModel+Data.swift deleted file mode 100644 index 178fc18a5..000000000 --- a/Mastodon/Scene/Report/ReportViewModel+Data.swift +++ /dev/null @@ -1,138 +0,0 @@ -// -// ReportViewModel+Data.swift -// Mastodon -// -// Created by ihugo on 2021/4/20. -// - -import Combine -import CoreData -import CoreDataStack -import Foundation -import MastodonSDK -import UIKit -import os.log - -extension ReportViewModel { - func requestRecentStatus( - domain: String, - accountId: String, - authorizationBox: MastodonAuthenticationBox - ) { - context.apiService.userTimeline( - domain: domain, - accountID: accountId, - excludeReblogs: true, - authorizationBox: authorizationBox - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] 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) - guard let self = self else { return } - guard let reportStatusId = self.status?.id else { return } - var statusIDs = self.statusFetchedResultsController.statusIDs.value - guard statusIDs.contains(reportStatusId) else { return } - - statusIDs.append(reportStatusId) - self.statusFetchedResultsController.statusIDs.value = statusIDs - case .finished: - break - } - } receiveValue: { [weak self] response in - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - guard let self = self else { return } - - var statusIDs = response.value.map { $0.id } - if let reportStatusId = self.status?.id, !statusIDs.contains(reportStatusId) { - statusIDs.append(reportStatusId) - } - - self.statusFetchedResultsController.statusIDs.value = statusIDs - } - .store(in: &disposeBag) - } - - func fetchStatus() { - let managedObjectContext = self.statusFetchedResultsController.fetchedResultsController.managedObjectContext - statusFetchedResultsController.objectIDs.eraseToAnyPublisher() - .receive(on: DispatchQueue.main) - .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) - .sink { [weak self] objectIDs in - guard let self = self else { return } - guard let diffableDataSource = self.diffableDataSource else { return } - - var items: [Item] = [] - var snapshot = NSDiffableDataSourceSnapshot<ReportSection, Item>() - snapshot.appendSections([.main]) - - defer { - // not animate when empty items fix loader first appear layout issue - diffableDataSource.apply(snapshot, animatingDifferences: !items.isEmpty) - } - - var oldSnapshotAttributeDict: [NSManagedObjectID : Item.ReportStatusAttribute] = [:] - let oldSnapshot = diffableDataSource.snapshot() - for item in oldSnapshot.itemIdentifiers { - guard case let .reportStatus(objectID, attribute) = item else { continue } - oldSnapshotAttributeDict[objectID] = attribute - } - - for objectID in objectIDs { - let attribute = oldSnapshotAttributeDict[objectID] ?? Item.ReportStatusAttribute() - let item = Item.reportStatus(objectID: objectID, attribute: attribute) - items.append(item) - - guard let status = managedObjectContext.object(with: objectID) as? Status else { - continue - } - if status.id == self.status?.id { - attribute.isSelected = true - self.append(statusID: status.id) - self.continueEnableSubject.send(true) - } - } - snapshot.appendItems(items, toSection: .main) - } - .store(in: &disposeBag) - } - - func prefetchData(prefetchRowsAt indexPaths: [IndexPath]) { - guard let diffableDataSource = diffableDataSource else { return } - - // prefetch reply status - guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } - let domain = activeMastodonAuthenticationBox.domain - - var statusObjectIDs: [NSManagedObjectID] = [] - for indexPath in indexPaths { - let item = diffableDataSource.itemIdentifier(for: indexPath) - switch item { - case .reportStatus(let objectID, _): - statusObjectIDs.append(objectID) - default: - continue - } - } - - let backgroundManagedObjectContext = context.backgroundManagedObjectContext - backgroundManagedObjectContext.perform { [weak self] in - guard let self = self else { return } - for objectID in statusObjectIDs { - let status = backgroundManagedObjectContext.object(with: objectID) as! Status - guard let replyToID = status.inReplyToID, status.replyTo == nil else { - // skip - continue - } - self.context.statusPrefetchingService.prefetchReplyTo( - domain: domain, - statusObjectID: status.objectID, - statusID: status.id, - replyToStatusID: replyToID, - authorizationBox: activeMastodonAuthenticationBox - ) - } - } - } -} diff --git a/Mastodon/Scene/Report/ReportViewModel+Diffable.swift b/Mastodon/Scene/Report/ReportViewModel+Diffable.swift deleted file mode 100644 index 73d6ffa0d..000000000 --- a/Mastodon/Scene/Report/ReportViewModel+Diffable.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// ReportViewModel+Diffable.swift -// Mastodon -// -// Created by ihugo on 2021/4/19. -// - -import UIKit -import Combine -import CoreData -import CoreDataStack - -extension ReportViewModel { - func setupDiffableDataSource( - for tableView: UITableView, - dependency: ReportViewController - ) { - let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common) - .autoconnect() - .share() - .eraseToAnyPublisher() - - diffableDataSource = ReportSection.tableViewDiffableDataSource( - for: tableView, - dependency: dependency, - managedObjectContext: statusFetchedResultsController.fetchedResultsController.managedObjectContext, - timestampUpdatePublisher: timestampUpdatePublisher - ) - - // set empty section to make update animation top-to-bottom style - var snapshot = NSDiffableDataSourceSnapshot<ReportSection, Item>() - snapshot.appendSections([.main]) - diffableDataSource?.apply(snapshot) - } -} diff --git a/Mastodon/Scene/Report/ReportViewModel.swift b/Mastodon/Scene/Report/ReportViewModel.swift deleted file mode 100644 index c8e59e8d6..000000000 --- a/Mastodon/Scene/Report/ReportViewModel.swift +++ /dev/null @@ -1,215 +0,0 @@ -// -// ReportViewModel.swift -// Mastodon -// -// Created by ihugo on 2021/4/19. -// - -import Combine -import CoreData -import CoreDataStack -import Foundation -import MastodonSDK -import UIKit -import os.log - -class ReportViewModel: NSObject { - typealias FileReportQuery = Mastodon.API.Reports.FileReportQuery - - enum Step: Int { - case one - case two - } - - // confirm set only once - weak var context: AppContext! { willSet { precondition(context == nil) } } - var user: MastodonUser - var status: Status? - - var statusIDs = [Mastodon.Entity.Status.ID]() - var comment: String? - - var reportQuery: FileReportQuery - var disposeBag = Set<AnyCancellable>() - let currentStep = CurrentValueSubject<Step, Never>(.one) - let statusFetchedResultsController: StatusFetchedResultsController - var diffableDataSource: UITableViewDiffableDataSource<ReportSection, Item>? - let continueEnableSubject = CurrentValueSubject<Bool, Never>(false) - let sendEnableSubject = CurrentValueSubject<Bool, Never>(false) - - struct Input { - let didToggleSelected: AnyPublisher<Item, Never> - let comment: AnyPublisher<String?, Never> - let step1Continue: AnyPublisher<Void, Never> - let step1Skip: AnyPublisher<Void, Never> - let step2Continue: AnyPublisher<Void, Never> - let step2Skip: AnyPublisher<Void, Never> - let cancel: AnyPublisher<Void, Never> - } - - struct Output { - let currentStep: AnyPublisher<Step, Never> - let continueEnableSubject: AnyPublisher<Bool, Never> - let sendEnableSubject: AnyPublisher<Bool, Never> - let reportResult: AnyPublisher<(Bool, Error?), Never> - } - - init(context: AppContext, - domain: String, - user: MastodonUser, - status: Status? - ) { - self.context = context - self.user = user - self.status = status - self.statusFetchedResultsController = StatusFetchedResultsController( - managedObjectContext: context.managedObjectContext, - domain: domain, - additionalTweetPredicate: Status.notDeleted() - ) - - self.reportQuery = FileReportQuery( - accountID: user.id, - statusIDs: [], - comment: nil, - forward: nil - ) - super.init() - } - - func transform(input: Input?) -> Output? { - guard let input = input else { return nil } - guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { - return nil - } - let domain = activeMastodonAuthenticationBox.domain - - // data binding - bindData(input: input) - - // step1 and step2 binding - bindForStep1(input: input) - let reportResult = bindForStep2( - input: input, - domain: domain, - activeMastodonAuthenticationBox: activeMastodonAuthenticationBox - ) - - requestRecentStatus( - domain: domain, - accountId: self.user.id, - authorizationBox: activeMastodonAuthenticationBox - ) - - fetchStatus() - - return Output( - currentStep: currentStep.eraseToAnyPublisher(), - continueEnableSubject: continueEnableSubject.eraseToAnyPublisher(), - sendEnableSubject: sendEnableSubject.eraseToAnyPublisher(), - reportResult: reportResult - ) - } - - // MARK: - Private methods - func bindData(input: Input) { - input.didToggleSelected.sink { [weak self] (item) in - guard let self = self else { return } - guard case let .reportStatus(objectID, attribute) = item else { return } - let managedObjectContext = self.statusFetchedResultsController.fetchedResultsController.managedObjectContext - guard let status = managedObjectContext.object(with: objectID) as? Status else { - return - } - - attribute.isSelected = !attribute.isSelected - if attribute.isSelected { - self.append(statusID: status.id) - } else { - self.remove(statusID: status.id) - } - - let continueEnable = self.statusIDs.count > 0 - self.continueEnableSubject.send(continueEnable) - } - .store(in: &disposeBag) - - input.comment.sink { [weak self] (comment) in - guard let self = self else { return } - - self.comment = comment - - let sendEnable = (comment?.length ?? 0) > 0 - self.sendEnableSubject.send(sendEnable) - } - .store(in: &disposeBag) - } - - func bindForStep1(input: Input) { - let skip = input.step1Skip.map { [weak self] value -> Void in - guard let self = self else { return value } - self.reportQuery.statusIDs?.removeAll() - return value - } - - let step1Continue = input.step1Continue.map { [weak self] value -> Void in - guard let self = self else { return value } - self.reportQuery.statusIDs = self.statusIDs - return value - } - - Publishers.Merge(skip, step1Continue) - .sink { [weak self] _ in - self?.currentStep.value = .two - self?.sendEnableSubject.send(false) - } - .store(in: &disposeBag) - } - - func bindForStep2(input: Input, domain: String, activeMastodonAuthenticationBox: MastodonAuthenticationBox) -> AnyPublisher<(Bool, Error?), Never> { - let skip = input.step2Skip.map { [weak self] value -> Void in - guard let self = self else { return value } - self.reportQuery.comment = nil - return value - } - - let step2Continue = input.step2Continue.map { [weak self] value -> Void in - guard let self = self else { return value } - self.reportQuery.comment = self.comment - return value - } - - return Publishers.Merge(skip, step2Continue) - .flatMap { [weak self] (_) -> AnyPublisher<(Bool, Error?), Never> in - guard let self = self else { - return Empty(completeImmediately: true).eraseToAnyPublisher() - } - - return self.context.apiService.report( - domain: domain, - query: self.reportQuery, - mastodonAuthenticationBox: activeMastodonAuthenticationBox - ) - .map({ (content) -> (Bool, Error?) in - return (true, nil) - }) - .eraseToAnyPublisher() - .tryCatch({ (error) -> AnyPublisher<(Bool, Error?), Never> in - return Just((false, error)).eraseToAnyPublisher() - }) - // to covert to AnyPublisher<(Bool, Error?), Never> - .replaceError(with: (false, nil)) - .eraseToAnyPublisher() - } - .eraseToAnyPublisher() - } - - func append(statusID: Mastodon.Entity.Status.ID) { - guard self.statusIDs.contains(statusID) != true else { return } - self.statusIDs.append(statusID) - } - - func remove(statusID: String) { - guard let index = self.statusIDs.firstIndex(of: statusID) else { return } - self.statusIDs.remove(at: index) - } -} diff --git a/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift b/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift deleted file mode 100644 index 0880c479a..000000000 --- a/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift +++ /dev/null @@ -1,219 +0,0 @@ -// -// ReportedStatusTableViewCell.swift -// Mastodon -// -// Created by ihugo on 2021/4/20. -// - -import os.log -import UIKit -import AVKit -import Combine -import CoreData -import CoreDataStack -import Meta -import MetaTextKit - -final class ReportedStatusTableViewCell: UITableViewCell, StatusCell { - - static let bottomPaddingHeight: CGFloat = 10 - - weak var dependency: ReportViewController? - private var _disposeBag = Set<AnyCancellable>() - var disposeBag = Set<AnyCancellable>() - var observations = Set<NSKeyValueObservation>() - - let statusView = StatusView() - let separatorLine = UIView.separatorLine - - let checkbox: UIImageView = { - let imageView = UIImageView() - imageView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(textStyle: .body) - imageView.tintColor = Asset.Colors.Label.secondary.color - imageView.contentMode = .scaleAspectFill - imageView.translatesAutoresizingMaskIntoConstraints = false - return imageView - }() - - var separatorLineToEdgeLeadingLayoutConstraint: NSLayoutConstraint! - var separatorLineToEdgeTrailingLayoutConstraint: NSLayoutConstraint! - - var separatorLineToMarginLeadingLayoutConstraint: NSLayoutConstraint! - var separatorLineToMarginTrailingLayoutConstraint: NSLayoutConstraint! - - // not support filter - var isFiltered: Bool = false - - override func prepareForReuse() { - super.prepareForReuse() - statusView.updateContentWarningDisplay(isHidden: true, animated: false) - statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isUserInteractionEnabled = true - statusView.pollTableView.dataSource = nil - statusView.playerContainerView.reset() - statusView.playerContainerView.contentWarningOverlayView.isUserInteractionEnabled = true - statusView.playerContainerView.isHidden = true - disposeBag.removeAll() - observations.removeAll() - } - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - _init() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - _init() - } - - override func setHighlighted(_ highlighted: Bool, animated: Bool) { - super.setHighlighted(highlighted, animated: animated) - if highlighted { - checkbox.image = UIImage(systemName: "checkmark.circle.fill") - checkbox.tintColor = Asset.Colors.brandBlue.color - } else if !isSelected { - checkbox.image = UIImage(systemName: "circle") - checkbox.tintColor = Asset.Colors.Label.secondary.color - } - } - - override func setSelected(_ selected: Bool, animated: Bool) { - super.setSelected(selected, animated: animated) - - if isSelected { - checkbox.image = UIImage(systemName: "checkmark.circle.fill") - } else { - checkbox.image = UIImage(systemName: "circle") - } - checkbox.tintColor = Asset.Colors.Label.secondary.color - } -} - -extension ReportedStatusTableViewCell { - - 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) - - checkbox.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(checkbox) - NSLayoutConstraint.activate([ - checkbox.widthAnchor.constraint(equalToConstant: 23), - checkbox.heightAnchor.constraint(equalToConstant: 22), - checkbox.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor, constant: 12), - checkbox.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), - ]) - - statusView.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(statusView) - NSLayoutConstraint.activate([ - statusView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 20), - statusView.leadingAnchor.constraint(equalTo: checkbox.trailingAnchor, constant: 20), - contentView.readableContentGuide.trailingAnchor.constraint(equalTo: statusView.trailingAnchor), - contentView.bottomAnchor.constraint(equalTo: statusView.bottomAnchor, constant: 20), - ]) - - separatorLine.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(separatorLine) - separatorLineToEdgeLeadingLayoutConstraint = separatorLine.leadingAnchor.constraint(equalTo: contentView.leadingAnchor) - separatorLineToEdgeTrailingLayoutConstraint = separatorLine.trailingAnchor.constraint(equalTo: contentView.trailingAnchor) - separatorLineToMarginLeadingLayoutConstraint = separatorLine.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor) - separatorLineToMarginTrailingLayoutConstraint = separatorLine.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor) - NSLayoutConstraint.activate([ - separatorLine.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - separatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView)), - ]) - resetSeparatorLineLayout() - - selectionStyle = .none - statusView.delegate = self - statusView.statusMosaicImageViewContainer.delegate = self - statusView.actionToolbarContainer.isHidden = true - } - - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - - resetSeparatorLineLayout() - } -} - -extension ReportedStatusTableViewCell { - private func resetSeparatorLineLayout() { - separatorLineToEdgeLeadingLayoutConstraint.isActive = false - separatorLineToEdgeTrailingLayoutConstraint.isActive = false - separatorLineToMarginLeadingLayoutConstraint.isActive = false - separatorLineToMarginTrailingLayoutConstraint.isActive = false - - if traitCollection.userInterfaceIdiom == .phone { - // to edge - NSLayoutConstraint.activate([ - separatorLineToEdgeLeadingLayoutConstraint, - separatorLineToEdgeTrailingLayoutConstraint, - ]) - } else { - if traitCollection.horizontalSizeClass == .compact { - // to edge - NSLayoutConstraint.activate([ - separatorLineToEdgeLeadingLayoutConstraint, - separatorLineToEdgeTrailingLayoutConstraint, - ]) - } else { - // to margin - NSLayoutConstraint.activate([ - separatorLineToMarginLeadingLayoutConstraint, - separatorLineToMarginTrailingLayoutConstraint, - ]) - } - } - } -} - -extension ReportedStatusTableViewCell: MosaicImageViewContainerDelegate { - func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) { - - } - - func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { - - guard let dependency = self.dependency else { return } - StatusProviderFacade.responseToStatusContentWarningRevealAction(dependency: dependency, cell: self) - } -} - -extension ReportedStatusTableViewCell: StatusViewDelegate { - - func statusView(_ statusView: StatusView, headerInfoLabelDidPressed label: UILabel) { - } - - func statusView(_ statusView: StatusView, avatarImageViewDidPressed imageView: UIImageView) { - } - - func statusView(_ statusView: StatusView, revealContentWarningButtonDidPressed button: UIButton) { - guard let dependency = self.dependency else { return } - StatusProviderFacade.responseToStatusContentWarningRevealAction(dependency: dependency, cell: self) - } - - func statusView(_ statusView: StatusView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { - guard let dependency = self.dependency else { return } - StatusProviderFacade.responseToStatusContentWarningRevealAction(dependency: dependency, cell: self) - } - - func statusView(_ statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { - guard let dependency = self.dependency else { return } - StatusProviderFacade.responseToStatusContentWarningRevealAction(dependency: dependency, cell: self) - } - - func statusView(_ statusView: StatusView, pollVoteButtonPressed button: UIButton) { - } - - func statusView(_ statusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta) { - } - -} diff --git a/Mastodon/Scene/Report/Share/Cell/ReportCommentTableViewCell.swift b/Mastodon/Scene/Report/Share/Cell/ReportCommentTableViewCell.swift new file mode 100644 index 000000000..b982ee5ac --- /dev/null +++ b/Mastodon/Scene/Report/Share/Cell/ReportCommentTableViewCell.swift @@ -0,0 +1,83 @@ +// +// ReportCommentTableViewCell.swift +// Mastodon +// +// Created by MainasuK on 2022-2-7. +// + +import UIKit +import Combine +import MastodonUI +import MastodonLocalization +import UITextView_Placeholder + +final class ReportCommentTableViewCell: UITableViewCell { + + var disposeBag = Set<AnyCancellable>() + + let commentTextViewShadowBackgroundContainer: ShadowBackgroundContainer = { + let shadowBackgroundContainer = ShadowBackgroundContainer() + return shadowBackgroundContainer + }() + + let commentTextView: UITextView = { + let textView = UITextView() + let font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .regular)) + textView.font = font + textView.attributedPlaceholder = NSAttributedString( + string: L10n.Scene.Report.textPlaceholder, + attributes: [ + .font: font + ] + ) + textView.textContainerInset = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) + textView.isScrollEnabled = false + textView.layer.masksToBounds = true + textView.layer.cornerRadius = 10 + return textView + }() + + 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 ReportCommentTableViewCell { + + private func _init() { + selectionStyle = .none + backgroundColor = .clear + + commentTextViewShadowBackgroundContainer.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(commentTextViewShadowBackgroundContainer) + NSLayoutConstraint.activate([ + commentTextViewShadowBackgroundContainer.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 24), + commentTextViewShadowBackgroundContainer.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), + commentTextViewShadowBackgroundContainer.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: commentTextViewShadowBackgroundContainer.bottomAnchor, constant: 24), + ]) + + commentTextView.translatesAutoresizingMaskIntoConstraints = false + commentTextViewShadowBackgroundContainer.addSubview(commentTextView) + NSLayoutConstraint.activate([ + commentTextView.topAnchor.constraint(equalTo: commentTextViewShadowBackgroundContainer.topAnchor), + commentTextView.leadingAnchor.constraint(equalTo: commentTextViewShadowBackgroundContainer.leadingAnchor), + commentTextView.trailingAnchor.constraint(equalTo: commentTextViewShadowBackgroundContainer.trailingAnchor), + commentTextView.bottomAnchor.constraint(equalTo: commentTextViewShadowBackgroundContainer.bottomAnchor), + commentTextView.heightAnchor.constraint(greaterThanOrEqualToConstant: 100).priority(.defaultHigh), + ]) + } +} diff --git a/Mastodon/Scene/Report/Share/Cell/ReportHeadlineTableViewCell.swift b/Mastodon/Scene/Report/Share/Cell/ReportHeadlineTableViewCell.swift new file mode 100644 index 000000000..b066fc101 --- /dev/null +++ b/Mastodon/Scene/Report/Share/Cell/ReportHeadlineTableViewCell.swift @@ -0,0 +1,69 @@ +// +// ReportHeadlineTableViewCell.swift +// Mastodon +// +// Created by MainasuK on 2022-2-7. +// + +import UIKit +import MastodonAsset +import MastodonLocalization + +final class ReportHeadlineTableViewCell: UITableViewCell { + + let primaryLabel: UILabel = { + let label = UILabel() + label.font = UIFontMetrics(forTextStyle: .largeTitle).scaledFont(for: .systemFont(ofSize: 28, weight: .bold)) + label.textColor = Asset.Colors.Label.primary.color + label.text = L10n.Scene.Report.content1 + label.adjustsFontForContentSizeCategory = true + label.numberOfLines = 0 + return label + }() + + let secondaryLabel: UILabel = { + let label = UILabel() + label.font = UIFontMetrics(forTextStyle: .largeTitle).scaledFont(for: .systemFont(ofSize: 17, weight: .regular)) + label.textColor = Asset.Colors.Label.secondary.color + label.text = L10n.Scene.Report.step1 + label.adjustsFontForContentSizeCategory = true + 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 ReportHeadlineTableViewCell { + + private func _init() { + selectionStyle = .none + backgroundColor = .clear + + let container = UIStackView() + container.axis = .vertical + container.spacing = 16 + container.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(container) + NSLayoutConstraint.activate([ + container.topAnchor.constraint(equalTo: contentView.topAnchor), + container.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), + container.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: 11), + ]) + + container.addArrangedSubview(secondaryLabel) // put secondary label before primary + container.addArrangedSubview(primaryLabel) + } + +} diff --git a/Mastodon/Scene/Report/Share/Cell/ReportResultActionTableViewCell.swift b/Mastodon/Scene/Report/Share/Cell/ReportResultActionTableViewCell.swift new file mode 100644 index 000000000..9b605a0c7 --- /dev/null +++ b/Mastodon/Scene/Report/Share/Cell/ReportResultActionTableViewCell.swift @@ -0,0 +1,145 @@ +// +// ReportResultActionTableViewCell.swift +// Mastodon +// +// Created by MainasuK on 2022-2-8. +// + +import UIKit +import Combine +import MastodonAsset +import MastodonUI +import MastodonLocalization + +final class ReportResultActionTableViewCell: UITableViewCell { + + var disposeBag = Set<AnyCancellable>() + + let containerView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + return stackView + }() + + let avatarImageView: AvatarImageView = { + let imageView = AvatarImageView() + imageView.configure(cornerConfiguration: .init(corner: .fixed(radius: 27))) + return imageView + }() + + let reportBannerShadowContainer = ShadowBackgroundContainer() + let reportBannerLabel: UILabel = { + let label = UILabel() + let padding = Array(repeating: " ", count: 2).joined() + label.text = padding + L10n.Scene.Report.reported + padding + label.textColor = Asset.Scene.Report.reportBanner.color + label.font = FontFamily.Staatliches.regular.font(size: 49) + label.backgroundColor = Asset.Scene.Report.background.color + label.layer.borderColor = Asset.Scene.Report.reportBanner.color.cgColor + label.layer.borderWidth = 6 + label.layer.masksToBounds = true + label.layer.cornerRadius = 12 + return label + }() + + 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 ReportResultActionTableViewCell { + + private func _init() { + selectionStyle = .none + backgroundColor = .clear + + containerView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(containerView) + NSLayoutConstraint.activate([ + containerView.topAnchor.constraint(equalTo: contentView.topAnchor), + containerView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), + containerView.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), + containerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + ]) + + let avatarContainer = UIStackView() + avatarContainer.axis = .horizontal + containerView.addArrangedSubview(avatarContainer) + + let avatarLeadingPaddingView = UIView() + let avatarTrailingPaddingView = UIView() + avatarLeadingPaddingView.translatesAutoresizingMaskIntoConstraints = false + avatarContainer.addArrangedSubview(avatarLeadingPaddingView) + avatarImageView.translatesAutoresizingMaskIntoConstraints = false + avatarContainer.addArrangedSubview(avatarImageView) + avatarTrailingPaddingView.translatesAutoresizingMaskIntoConstraints = false + avatarContainer.addArrangedSubview(avatarTrailingPaddingView) + NSLayoutConstraint.activate([ + avatarImageView.widthAnchor.constraint(equalToConstant: 106).priority(.required - 1), + avatarImageView.heightAnchor.constraint(equalToConstant: 106).priority(.required - 1), + avatarLeadingPaddingView.widthAnchor.constraint(equalTo: avatarTrailingPaddingView.widthAnchor).priority(.defaultHigh), + ]) + + reportBannerShadowContainer.translatesAutoresizingMaskIntoConstraints = false + avatarContainer.addSubview(reportBannerShadowContainer) + NSLayoutConstraint.activate([ + reportBannerShadowContainer.centerXAnchor.constraint(equalTo: avatarImageView.centerXAnchor), + reportBannerShadowContainer.centerYAnchor.constraint(equalTo: avatarImageView.centerYAnchor), + ]) + reportBannerShadowContainer.transform = CGAffineTransform(rotationAngle: -(.pi / 180 * 5)) + + reportBannerLabel.translatesAutoresizingMaskIntoConstraints = false + reportBannerShadowContainer.addSubview(reportBannerLabel) + NSLayoutConstraint.activate([ + reportBannerLabel.topAnchor.constraint(equalTo: reportBannerShadowContainer.topAnchor), + reportBannerLabel.leadingAnchor.constraint(equalTo: reportBannerShadowContainer.leadingAnchor), + reportBannerLabel.trailingAnchor.constraint(equalTo: reportBannerShadowContainer.trailingAnchor), + reportBannerLabel.bottomAnchor.constraint(equalTo: reportBannerShadowContainer.bottomAnchor), + ]) + + } + + override func layoutSubviews() { + super.layoutSubviews() + + reportBannerShadowContainer.layer.setupShadow( + color: .black, + alpha: 0.25, + x: 1, + y: 0.64, + blur: 0.64, + spread: 0, + roundedRect: reportBannerShadowContainer.bounds, + byRoundingCorners: .allCorners, + cornerRadii: CGSize(width: 12, height: 12) + ) + } + +} + +#if DEBUG +import SwiftUI +struct ReportResultActionTableViewCell_Preview: PreviewProvider { + static var previews: some View { + UIViewPreview(width: 375) { + let cell = ReportResultActionTableViewCell() + cell.avatarImageView.configure(configuration: .init(image: .placeholder(color: .blue))) + return cell + } + .previewLayout(.fixed(width: 375, height: 106)) + } +} +#endif diff --git a/Mastodon/Scene/Report/Share/Cell/ReportStatusTableViewCell+ViewModel.swift b/Mastodon/Scene/Report/Share/Cell/ReportStatusTableViewCell+ViewModel.swift new file mode 100644 index 000000000..9ce759a2a --- /dev/null +++ b/Mastodon/Scene/Report/Share/Cell/ReportStatusTableViewCell+ViewModel.swift @@ -0,0 +1,49 @@ +// +// ReportStatusTableViewCell+ViewModel.swift +// Mastodon +// +// Created by MainasuK on 2022-2-7. +// + +import UIKit +import CoreDataStack + +extension ReportStatusTableViewCell { + final class ViewModel { + let value: Status + + init(value: Status) { + self.value = value + } + } +} + +extension ReportStatusTableViewCell { + + func configure( + tableView: UITableView, + viewModel: ViewModel + ) { + if statusView.frame == .zero { + // set status view width + statusView.frame.size.width = tableView.frame.width - ReportStatusTableViewCell.checkboxLeadingMargin - ReportStatusTableViewCell.checkboxSize.width - ReportStatusTableViewCell.statusViewLeadingSpacing + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): did layout for new cell") + } + + statusView.configure(status: viewModel.value) + + statusView.viewModel.$isContentReveal + .removeDuplicates() + .dropFirst() + .receive(on: DispatchQueue.main) + .sink { [weak tableView, weak self] isContentReveal in + guard let tableView = tableView else { return } + guard let _ = self else { return } + + tableView.beginUpdates() + tableView.endUpdates() + } + .store(in: &disposeBag) + } + +} diff --git a/Mastodon/Scene/Report/Share/Cell/ReportStatusTableViewCell.swift b/Mastodon/Scene/Report/Share/Cell/ReportStatusTableViewCell.swift new file mode 100644 index 000000000..b134db302 --- /dev/null +++ b/Mastodon/Scene/Report/Share/Cell/ReportStatusTableViewCell.swift @@ -0,0 +1,102 @@ +// +// ReportStatusTableViewCell.swift +// Mastodon +// +// Created by MainasuK on 2022-2-7. +// + +import os.log +import UIKit +import Combine +import MastodonUI +import MastodonAsset + +final class ReportStatusTableViewCell: UITableViewCell { + + static let checkboxLeadingMargin: CGFloat = 16 + static let checkboxSize = CGSize(width: 32, height: 32) + static let statusViewLeadingSpacing: CGFloat = 22 + + var disposeBag = Set<AnyCancellable>() + + let logger = Logger(subsystem: "ReportStatusTableViewCell", category: "View") + + let checkbox: UIImageView = { + let imageView = UIImageView() + imageView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(textStyle: .body) + imageView.tintColor = Asset.Colors.Label.secondary.color + imageView.contentMode = .scaleAspectFill + imageView.translatesAutoresizingMaskIntoConstraints = false + return imageView + }() + + let statusView = StatusView() + + let separatorLine = UIView.separatorLine + + override func prepareForReuse() { + super.prepareForReuse() + + disposeBag.removeAll() + statusView.prepareForReuse() + } + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ReportStatusTableViewCell { + + private func _init() { + selectionStyle = .none + backgroundColor = .clear + + checkbox.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(checkbox) + NSLayoutConstraint.activate([ + checkbox.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: ReportStatusTableViewCell.checkboxLeadingMargin), + checkbox.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + checkbox.heightAnchor.constraint(equalToConstant: ReportStatusTableViewCell.checkboxSize.width).priority(.required - 1), + checkbox.widthAnchor.constraint(equalToConstant: ReportStatusTableViewCell.checkboxSize.height).priority(.required - 1), + ]) + + statusView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(statusView) + NSLayoutConstraint.activate([ + statusView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 24), + statusView.leadingAnchor.constraint(equalTo: checkbox.trailingAnchor, constant: ReportStatusTableViewCell.statusViewLeadingSpacing), + statusView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: statusView.bottomAnchor, constant: 24), + ]) + statusView.setup(style: .report) + + 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)).priority(.required - 1), + ]) + } + + override func setSelected(_ selected: Bool, animated: Bool) { + super.setSelected(selected, animated: animated) + if selected { + checkbox.image = UIImage(systemName: "checkmark.circle.fill") + checkbox.tintColor = Asset.Colors.Label.primary.color + } else { + checkbox.image = UIImage(systemName: "circle") + checkbox.tintColor = Asset.Colors.Label.secondary.color + } + } + +} diff --git a/Mastodon/Scene/Report/Share/ReportViewControllerAppearance.swift b/Mastodon/Scene/Report/Share/ReportViewControllerAppearance.swift new file mode 100644 index 000000000..6b35f3d89 --- /dev/null +++ b/Mastodon/Scene/Report/Share/ReportViewControllerAppearance.swift @@ -0,0 +1,69 @@ +// +// ReportViewControllerAppearance.swift +// Mastodon +// +// Created by MainasuK on 2022-2-7. +// + +import UIKit +import MastodonAsset +import MastodonLocalization + +protocol ReportViewControllerAppearance: UIViewController { + func setupAppearance() + func setupNavigationBarAppearance() +} + +extension ReportViewControllerAppearance { + + + func setupAppearance() { + + title = L10n.Scene.Report.titleReport + view.backgroundColor = Asset.Scene.Report.background.color + + setupNavigationBarAppearance() + + 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() + navigationItem.standardAppearance = barAppearance + navigationItem.compactAppearance = barAppearance + navigationItem.scrollEdgeAppearance = barAppearance + if #available(iOS 15.0, *) { + navigationItem.compactScrollEdgeAppearance = barAppearance + } else { + // Fallback on earlier versions + } + } + + func setupNavigationBarBackgroundView() { + let navigationBarBackgroundView: UIView = { + let view = UIView() + view.backgroundColor = Asset.Scene.Report.background.color + return view + }() + + navigationBarBackgroundView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(navigationBarBackgroundView) + NSLayoutConstraint.activate([ + navigationBarBackgroundView.topAnchor.constraint(equalTo: view.topAnchor), + navigationBarBackgroundView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + navigationBarBackgroundView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + navigationBarBackgroundView.bottomAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor), + ]) + } + +} diff --git a/Mastodon/Scene/Root/ContentSplitViewController.swift b/Mastodon/Scene/Root/ContentSplitViewController.swift index 8ca597872..5a34e1ed8 100644 --- a/Mastodon/Scene/Root/ContentSplitViewController.swift +++ b/Mastodon/Scene/Root/ContentSplitViewController.swift @@ -38,8 +38,8 @@ final class ContentSplitViewController: UIViewController, NeedsDependency { 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 + homeTimelineViewController.viewModel.displayComposeBarButtonItem = false + homeTimelineViewController.viewModel.displaySettingBarButtonItem = false } return mainTabBarController }() diff --git a/Mastodon/Scene/Root/MainTab/MainTabBarController.swift b/Mastodon/Scene/Root/MainTab/MainTabBarController.swift index 4b803bc49..db50565aa 100644 --- a/Mastodon/Scene/Root/MainTab/MainTabBarController.swift +++ b/Mastodon/Scene/Root/MainTab/MainTabBarController.swift @@ -9,6 +9,8 @@ import os.log import UIKit import Combine import SafariServices +import MastodonAsset +import MastodonLocalization class MainTabBarController: UITabBarController { @@ -74,11 +76,7 @@ class MainTabBarController: UITabBarController { let viewController: UIViewController switch self { case .home: - #if ASDK - let _viewController: NeedsDependency & UIViewController = UserDefaults.shared.preferAsyncHomeTimeline ? AsyncHomeTimelineViewController() : HomeTimelineViewController() - #else let _viewController = HomeTimelineViewController() - #endif _viewController.context = context _viewController.coordinator = coordinator viewController = _viewController @@ -591,38 +589,13 @@ extension MainTabBarController { @objc private func composeNewPostKeyCommandHandler(_ sender: UIKeyCommand) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - let composeViewModel = ComposeViewModel(context: context, composeKind: .post) + guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } + let composeViewModel = ComposeViewModel( + context: context, + composeKind: .post, + authenticationBox: authenticationBox + ) coordinator.present(scene: .compose(viewModel: composeViewModel), from: nil, transition: .modal(animated: true, completion: nil)) } } - -#if ASDK -extension MainTabBarController { - override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) { - guard let event = event else { return } - switch event.subtype { - case .motionShake: - let alertController = UIAlertController(title: "ASDK Debug Panel", message: nil, preferredStyle: .alert) - let toggleHomeAction = UIAlertAction(title: "Toggle Home", style: .default) { [weak self] _ in - guard let self = self else { return } - MainTabBarController.toggleAsyncHome() - let okAlertController = UIAlertController(title: "Success", message: "Please restart the app", preferredStyle: .alert) - let okAction = UIAlertAction(title: "OK", style: .default, handler: nil) - okAlertController.addAction(okAction) - self.coordinator.present(scene: .alertController(alertController: okAlertController), from: nil, transition: .alertController(animated: true, completion: nil)) - } - alertController.addAction(toggleHomeAction) - let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil) - alertController.addAction(cancelAction) - self.coordinator.present(scene: .alertController(alertController: alertController), from: nil, transition: .alertController(animated: true, completion: nil)) - default: - break - } - } - - static func toggleAsyncHome() { - UserDefaults.shared.preferAsyncHomeTimeline.toggle() - } -} -#endif diff --git a/Mastodon/Scene/Root/RootSplitViewController.swift b/Mastodon/Scene/Root/RootSplitViewController.swift index e9d7549bd..d9b18b0b4 100644 --- a/Mastodon/Scene/Root/RootSplitViewController.swift +++ b/Mastodon/Scene/Root/RootSplitViewController.swift @@ -101,12 +101,7 @@ extension RootSplitViewController { 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 - } + self.updateBehavior(size: size) } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { diff --git a/Mastodon/Scene/Root/Sidebar/SidebarViewController.swift b/Mastodon/Scene/Root/Sidebar/SidebarViewController.swift index b5f67e769..6568ab0cd 100644 --- a/Mastodon/Scene/Root/Sidebar/SidebarViewController.swift +++ b/Mastodon/Scene/Root/Sidebar/SidebarViewController.swift @@ -199,9 +199,15 @@ extension SidebarViewController: UICollectionViewDelegate { case secondaryCollectionView: guard let diffableDataSource = viewModel.secondaryDiffableDataSource else { return } guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } + + guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } switch item { case .compose: - let composeViewModel = ComposeViewModel(context: context, composeKind: .post) + let composeViewModel = ComposeViewModel( + context: context, + composeKind: .post, + authenticationBox: authenticationBox + ) coordinator.present(scene: .compose(viewModel: composeViewModel), from: self, transition: .modal(animated: true, completion: nil)) default: assertionFailure() diff --git a/Mastodon/Scene/Root/Sidebar/SidebarViewModel.swift b/Mastodon/Scene/Root/Sidebar/SidebarViewModel.swift index 37b46932b..3cc277dc6 100644 --- a/Mastodon/Scene/Root/Sidebar/SidebarViewModel.swift +++ b/Mastodon/Scene/Root/Sidebar/SidebarViewModel.swift @@ -11,6 +11,8 @@ import CoreData import CoreDataStack import Meta import MastodonMeta +import MastodonAsset +import MastodonLocalization final class SidebarViewModel { diff --git a/Mastodon/Scene/Root/Sidebar/View/SidebarAddAccountCollectionViewCell.swift b/Mastodon/Scene/Root/Sidebar/View/SidebarAddAccountCollectionViewCell.swift index 72b2577f1..da3793a9c 100644 --- a/Mastodon/Scene/Root/Sidebar/View/SidebarAddAccountCollectionViewCell.swift +++ b/Mastodon/Scene/Root/Sidebar/View/SidebarAddAccountCollectionViewCell.swift @@ -6,6 +6,8 @@ // import UIKit +import MastodonAsset +import MastodonLocalization final class SidebarAddAccountCollectionViewCell: UICollectionViewListCell { diff --git a/Mastodon/Scene/Root/Sidebar/View/SidebarListHeaderView.swift b/Mastodon/Scene/Root/Sidebar/View/SidebarListHeaderView.swift index 6a1bb3ddf..33e0867c6 100644 --- a/Mastodon/Scene/Root/Sidebar/View/SidebarListHeaderView.swift +++ b/Mastodon/Scene/Root/Sidebar/View/SidebarListHeaderView.swift @@ -6,6 +6,8 @@ // import UIKit +import MastodonAsset +import MastodonLocalization final class SidebarListHeaderView: UICollectionReusableView { diff --git a/Mastodon/Scene/Search/Search/Cell/TrendCollectionViewCell.swift b/Mastodon/Scene/Search/Search/Cell/TrendCollectionViewCell.swift new file mode 100644 index 000000000..a43d65df4 --- /dev/null +++ b/Mastodon/Scene/Search/Search/Cell/TrendCollectionViewCell.swift @@ -0,0 +1,135 @@ +// +// TrendCollectionViewCell.swift +// Mastodon +// +// Created by MainasuK on 2022-1-18. +// + +import UIKit +import Combine +import MetaTextKit +import MastodonAsset + +final class TrendCollectionViewCell: UICollectionViewCell { + + var _disposeBag = Set<AnyCancellable>() + + let container: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.spacing = 16 + return stackView + }() + + let infoContainer: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + return stackView + }() + + let lineChartContainer: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + return stackView + }() + + let primaryLabel: UILabel = { + let label = UILabel() + label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .regular)) + label.textColor = Asset.Colors.Label.primary.color + return label + }() + + let secondaryLabel: UILabel = { + let label = UILabel() + label.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(for: .systemFont(ofSize: 15, weight: .regular)) + label.textColor = Asset.Colors.Label.secondary.color + return label + }() + + let lineChartView = LineChartView() + + override func prepareForReuse() { + super.prepareForReuse() + } + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension TrendCollectionViewCell { + + private func _init() { + ThemeService.shared.currentTheme + .map { $0.secondarySystemGroupedBackgroundColor } + .sink { [weak self] backgroundColor in + guard let self = self else { return } + self.backgroundColor = backgroundColor + self.setNeedsUpdateConfiguration() + } + .store(in: &_disposeBag) + + container.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(container) + NSLayoutConstraint.activate([ + container.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 11), + container.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + container.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: 11), + ]) + + container.layoutMargins = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16) + container.isLayoutMarginsRelativeArrangement = true + + // container: H - [ info container | padding | line chart container ] + container.addArrangedSubview(infoContainer) + + // info container: V - [ primary | secondary ] + infoContainer.addArrangedSubview(primaryLabel) + infoContainer.addArrangedSubview(secondaryLabel) + + // padding + let padding = UIView() + container.addArrangedSubview(padding) + + // line chart + container.addArrangedSubview(lineChartContainer) + + let lineChartViewTopPadding = UIView() + let lineChartViewBottomPadding = UIView() + lineChartViewTopPadding.translatesAutoresizingMaskIntoConstraints = false + lineChartViewBottomPadding.translatesAutoresizingMaskIntoConstraints = false + lineChartView.translatesAutoresizingMaskIntoConstraints = false + lineChartContainer.addArrangedSubview(lineChartViewTopPadding) + lineChartContainer.addArrangedSubview(lineChartView) + lineChartContainer.addArrangedSubview(lineChartViewBottomPadding) + NSLayoutConstraint.activate([ + lineChartView.widthAnchor.constraint(equalToConstant: 50), + lineChartView.heightAnchor.constraint(equalToConstant: 26), + lineChartViewTopPadding.heightAnchor.constraint(equalTo: lineChartViewBottomPadding.heightAnchor), + ]) + } + + override func updateConfiguration(using state: UICellConfigurationState) { + super.updateConfiguration(using: state) + + var backgroundConfiguration = UIBackgroundConfiguration.listGroupedCell() + backgroundConfiguration.backgroundColorTransformer = .init { _ in + if state.isHighlighted || state.isSelected { + return ThemeService.shared.currentTheme.value.tableViewCellSelectionBackgroundColor + } + return ThemeService.shared.currentTheme.value.secondarySystemGroupedBackgroundColor + } + self.backgroundConfiguration = backgroundConfiguration + } + +} + diff --git a/Mastodon/Scene/Search/Search/Cell/TrendSectionHeaderCollectionReusableView.swift b/Mastodon/Scene/Search/Search/Cell/TrendSectionHeaderCollectionReusableView.swift new file mode 100644 index 000000000..9d21ee287 --- /dev/null +++ b/Mastodon/Scene/Search/Search/Cell/TrendSectionHeaderCollectionReusableView.swift @@ -0,0 +1,65 @@ +// +// TrendSectionHeaderCollectionReusableView.swift +// Mastodon +// +// Created by MainasuK on 2022-1-18. +// + +import UIKit +import MastodonAsset +import MastodonLocalization + +final class TrendSectionHeaderCollectionReusableView: UICollectionReusableView { + + let container: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.spacing = 4 + return stackView + }() + + let primaryLabel: UILabel = { + let label = UILabel() + label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 22, weight: .bold)) + label.textColor = Asset.Colors.Label.primary.color + label.text = L10n.Scene.Search.Recommend.HashTag.title + label.numberOfLines = 0 + return label + }() + + let secondaryLabel: UILabel = { + let label = UILabel() + label.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(for: .systemFont(ofSize: 15, weight: .regular)) + label.textColor = Asset.Colors.Label.secondary.color + label.text = L10n.Scene.Search.Recommend.HashTag.description + label.numberOfLines = 0 + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension TrendSectionHeaderCollectionReusableView { + private func _init() { + container.translatesAutoresizingMaskIntoConstraints = false + addSubview(container) + NSLayoutConstraint.activate([ + container.topAnchor.constraint(equalTo: topAnchor, constant: 16), + container.leadingAnchor.constraint(equalTo: leadingAnchor), + container.trailingAnchor.constraint(equalTo: trailingAnchor), + bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: 16), + ]) + + container.addArrangedSubview(primaryLabel) + container.addArrangedSubview(secondaryLabel) + } +} diff --git a/Mastodon/Scene/Search/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift b/Mastodon/Scene/Search/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift deleted file mode 100644 index 2b0c4736d..000000000 --- a/Mastodon/Scene/Search/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift +++ /dev/null @@ -1,210 +0,0 @@ -// -// SearchRecommendAccountsCollectionViewCell.swift -// Mastodon -// -// Created by sxiaojian on 2021/4/1. -// - -import os.log -import Combine -import CoreDataStack -import Foundation -import MastodonSDK -import UIKit -import MetaTextKit -import MastodonMeta - -protocol SearchRecommendAccountsCollectionViewCellDelegate: NSObject { - func searchRecommendAccountsCollectionViewCell(_ cell: SearchRecommendAccountsCollectionViewCell, followButtonDidPressed button: UIButton) -} - -class SearchRecommendAccountsCollectionViewCell: UICollectionViewCell { - - let logger = Logger(subsystem: "SearchRecommendAccountsCollectionViewCell", category: "UI") - var disposeBag = Set<AnyCancellable>() - - weak var delegate: SearchRecommendAccountsCollectionViewCellDelegate? - - let avatarImageView: UIImageView = { - let imageView = UIImageView() - imageView.layer.cornerRadius = 8.4 - imageView.clipsToBounds = true - return imageView - }() - - let headerImageView: UIImageView = { - let imageView = UIImageView() - imageView.contentMode = .scaleAspectFill - imageView.layer.cornerRadius = 10 - imageView.layer.cornerCurve = .continuous - imageView.clipsToBounds = true - imageView.layer.borderWidth = 2 - imageView.layer.borderColor = Asset.Colors.Border.searchCard.color.cgColor - return imageView - }() - - let visualEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .regular)) - - let displayNameLabel = MetaLabel(style: .recommendAccountName) - - let acctLabel: UILabel = { - let label = UILabel() - label.textColor = .white - label.font = .preferredFont(forTextStyle: .body) - label.textAlignment = .center - label.translatesAutoresizingMaskIntoConstraints = false - return label - }() - - let followButton: HighlightDimmableButton = { - let button = HighlightDimmableButton(type: .custom) - button.setInsets(forContentPadding: UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16), imageTitlePadding: 0) - button.setTitleColor(.white, for: .normal) - button.setTitle(L10n.Scene.Search.Recommend.Accounts.follow, for: .normal) - button.titleLabel?.font = .systemFont(ofSize: 14, weight: .semibold) - button.layer.cornerRadius = 12 - button.layer.cornerCurve = .continuous - button.layer.borderWidth = 2 - button.layer.borderColor = UIColor.white.cgColor - return button - }() - - override func prepareForReuse() { - super.prepareForReuse() - headerImageView.af.cancelImageRequest() - avatarImageView.af.cancelImageRequest() - disposeBag.removeAll() - } - - override init(frame: CGRect) { - super.init(frame: .zero) - configure() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - configure() - } - - override var isHighlighted: Bool { - didSet { - contentView.alpha = isHighlighted ? 0.8 : 1.0 - } - } - -} - -extension SearchRecommendAccountsCollectionViewCell { - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - headerImageView.layer.borderColor = Asset.Colors.Border.searchCard.color.cgColor - applyShadow(color: Asset.Colors.Shadow.searchCard.color, alpha: 0.1, x: 0, y: 3, blur: 12, spread: 0) - } - - private func configure() { - headerImageView.backgroundColor = Asset.Colors.brandBlue.color - layer.cornerRadius = 10 - layer.cornerCurve = .continuous - clipsToBounds = false - applyShadow(color: Asset.Colors.Shadow.searchCard.color, alpha: 0.1, x: 0, y: 3, blur: 12, spread: 0) - - headerImageView.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(headerImageView) - NSLayoutConstraint.activate([ - headerImageView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 16), - headerImageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - headerImageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - headerImageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) - ]) - - headerImageView.addSubview(visualEffectView) - visualEffectView.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - visualEffectView.topAnchor.constraint(equalTo: headerImageView.topAnchor), - visualEffectView.leadingAnchor.constraint(equalTo: headerImageView.leadingAnchor), - visualEffectView.trailingAnchor.constraint(equalTo: headerImageView.trailingAnchor), - visualEffectView.bottomAnchor.constraint(equalTo: headerImageView.bottomAnchor) - ]) - - let containerStackView = UIStackView() - containerStackView.axis = .vertical - containerStackView.distribution = .fill - containerStackView.alignment = .center - containerStackView.spacing = 6 - containerStackView.layoutMargins = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16) - containerStackView.isLayoutMarginsRelativeArrangement = true - containerStackView.translatesAutoresizingMaskIntoConstraints = false - - contentView.addSubview(containerStackView) - NSLayoutConstraint.activate([ - containerStackView.topAnchor.constraint(equalTo: contentView.topAnchor), - containerStackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - containerStackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - ]) - - avatarImageView.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(avatarImageView) - NSLayoutConstraint.activate([ - avatarImageView.widthAnchor.constraint(equalToConstant: 88), - avatarImageView.heightAnchor.constraint(equalToConstant: 88) - ]) - containerStackView.addArrangedSubview(avatarImageView) - containerStackView.setCustomSpacing(20, after: avatarImageView) - displayNameLabel.translatesAutoresizingMaskIntoConstraints = false - containerStackView.addArrangedSubview(displayNameLabel) - containerStackView.setCustomSpacing(0, after: displayNameLabel) - - acctLabel.translatesAutoresizingMaskIntoConstraints = false - containerStackView.addArrangedSubview(acctLabel) - containerStackView.setCustomSpacing(7, after: acctLabel) - - followButton.translatesAutoresizingMaskIntoConstraints = false - containerStackView.addArrangedSubview(followButton) - NSLayoutConstraint.activate([ - followButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 76), - followButton.heightAnchor.constraint(greaterThanOrEqualToConstant: 24) - ]) - containerStackView.addArrangedSubview(followButton) - - followButton.addTarget(self, action: #selector(SearchRecommendAccountsCollectionViewCell.followButtonDidPressed(_:)), for: .touchUpInside) - - displayNameLabel.isUserInteractionEnabled = false - } - -} - -extension SearchRecommendAccountsCollectionViewCell { - @objc private func followButtonDidPressed(_ sender: UIButton) { - logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") - delegate?.searchRecommendAccountsCollectionViewCell(self, followButtonDidPressed: sender) - } -} - -#if canImport(SwiftUI) && DEBUG -import SwiftUI - -struct SearchRecommendAccountsCollectionViewCell_Previews: PreviewProvider { - static var controls: some View { - Group { - UIViewPreview { - let cell = SearchRecommendAccountsCollectionViewCell() - cell.avatarImageView.backgroundColor = .white - cell.headerImageView.backgroundColor = .red - cell.displayNameLabel.text = "sunxiaojian" - cell.acctLabel.text = "sunxiaojian@mastodon.online" - return cell - } - .previewLayout(.fixed(width: 257, height: 202)) - } - } - - static var previews: some View { - Group { - controls.colorScheme(.light) - controls.colorScheme(.dark) - } - .background(Color.gray) - } -} - -#endif diff --git a/Mastodon/Scene/Search/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift b/Mastodon/Scene/Search/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift deleted file mode 100644 index 3a20788b5..000000000 --- a/Mastodon/Scene/Search/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift +++ /dev/null @@ -1,170 +0,0 @@ -// -// SearchRecommendTagsCollectionViewCell.swift -// Mastodon -// -// Created by sxiaojian on 2021/3/31. -// - -import Foundation -import MastodonSDK -import UIKit - -class SearchRecommendTagsCollectionViewCell: UICollectionViewCell { - let backgroundImageView: UIImageView = { - let imageView = UIImageView() - return imageView - }() - - let hashtagTitleLabel: UILabel = { - let label = UILabel() - label.textColor = .white - label.font = .systemFont(ofSize: 20, weight: .semibold) - label.lineBreakMode = .byTruncatingTail - return label - }() - - let peopleLabel: UILabel = { - let label = UILabel() - label.textColor = .white - label.font = .preferredFont(forTextStyle: .body) - label.numberOfLines = 2 - return label - }() - - let lineChartView = LineChartView() - - override func prepareForReuse() { - super.prepareForReuse() - } - - override init(frame: CGRect) { - super.init(frame: .zero) - configure() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - configure() - } - - override var isHighlighted: Bool { - didSet { - backgroundColor = isHighlighted ? Asset.Colors.brandBlueDarken20.color : Asset.Colors.brandBlue.color - } - } -} - -extension SearchRecommendTagsCollectionViewCell { - - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - layer.borderColor = Asset.Colors.Border.searchCard.color.cgColor - applyShadow(color: Asset.Colors.Shadow.searchCard.color, alpha: 0.1, x: 0, y: 3, blur: 12, spread: 0) - } - - private func configure() { - backgroundColor = Asset.Colors.brandBlue.color - layer.cornerRadius = 10 - layer.cornerCurve = .continuous - clipsToBounds = false - layer.borderWidth = 2 - layer.borderColor = Asset.Colors.Border.searchCard.color.cgColor - applyShadow(color: Asset.Colors.Shadow.searchCard.color, alpha: 0.1, x: 0, y: 3, blur: 12, spread: 0) - - backgroundImageView.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(backgroundImageView) - NSLayoutConstraint.activate([ - backgroundImageView.topAnchor.constraint(equalTo: contentView.topAnchor), - backgroundImageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - backgroundImageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - backgroundImageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) - ]) - - - let containerStackView = UIStackView() - containerStackView.axis = .vertical - containerStackView.distribution = .fill - containerStackView.layoutMargins = UIEdgeInsets(top: 16, left: 16, bottom: 0, right: 16) - containerStackView.isLayoutMarginsRelativeArrangement = true - containerStackView.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(containerStackView) - NSLayoutConstraint.activate([ - containerStackView.topAnchor.constraint(equalTo: contentView.topAnchor), - containerStackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - containerStackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor) - ]) - - containerStackView.addArrangedSubview(hashtagTitleLabel) - containerStackView.addArrangedSubview(peopleLabel) - - 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 history = tag.history else { - peopleLabel.text = "" - return - } - - 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) - } - } -} - -#if canImport(SwiftUI) && DEBUG -import SwiftUI - -struct SearchRecommendTagsCollectionViewCell_Previews: PreviewProvider { - static var controls: some View { - Group { - UIViewPreview { - let cell = SearchRecommendTagsCollectionViewCell() - cell.hashtagTitleLabel.text = "# test" - cell.peopleLabel.text = "128 people are talking" - return cell - } - .previewLayout(.fixed(width: 228, height: 130)) - } - } - - static var previews: some View { - Group { - controls.colorScheme(.light) - controls.colorScheme(.dark) - } - .background(Color.gray) - } -} - -#endif diff --git a/Mastodon/Scene/Search/Search/SearchViewController+Follow.swift b/Mastodon/Scene/Search/Search/SearchViewController+Follow.swift deleted file mode 100644 index 386b0af18..000000000 --- a/Mastodon/Scene/Search/Search/SearchViewController+Follow.swift +++ /dev/null @@ -1,110 +0,0 @@ -// -// SearchViewController+Follow.swift -// Mastodon -// -// Created by xiaojian sun on 2021/4/9. -// - -import Combine -import CoreDataStack -import Foundation -import UIKit - -extension SearchViewController: UserProvider { - - func mastodonUser(for cell: UITableViewCell?) -> Future<MastodonUser?, Never> { - return Future { promise in - promise(.success(nil)) - } - } - - func mastodonUser() -> Future<MastodonUser?, Never> { - Future { promise in - promise(.success(nil)) - } - } -} - -extension SearchViewController: SearchRecommendAccountsCollectionViewCellDelegate { - func searchRecommendAccountsCollectionViewCell(_ cell: SearchRecommendAccountsCollectionViewCell, followButtonDidPressed button: UIButton) { - guard let diffableDataSource = viewModel.accountDiffableDataSource else { return } - guard let indexPath = accountsCollectionView.indexPath(for: cell), - let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } - - context.managedObjectContext.performAndWait { - guard let user = try? context.managedObjectContext.existingObject(with: item) as? MastodonUser else { return } - self.toggleFriendship(for: user) - } - } - - func toggleFriendship(for mastodonUser: MastodonUser) { - guard let currentMastodonUser = viewModel.currentMastodonUser.value else { - return - } - guard let relationshipAction = RecommendAccountSection.relationShipActionSet( - mastodonUser: mastodonUser, - currentMastodonUser: currentMastodonUser).highPriorityAction(except: .editOptions) - else { return } - switch relationshipAction { - case .none: - break - case .follow, .following: - UserProviderFacade.toggleUserFollowRelationship(provider: self, mastodonUser: mastodonUser) - .sink { _ in - // error handling - } receiveValue: { _ in - // success - } - .store(in: &disposeBag) - case .pending: - break - case .muting: - let name = mastodonUser.displayNameWithFallback - let alertController = UIAlertController( - title: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.title, - message: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.message(name), - preferredStyle: .alert - ) - let unmuteAction = UIAlertAction(title: L10n.Common.Controls.Friendship.unmute, style: .default) { [weak self] _ in - guard let self = self else { return } - UserProviderFacade.toggleUserMuteRelationship(provider: self, mastodonUser: mastodonUser) - .sink { _ in - // do nothing - } receiveValue: { _ in - // do nothing - } - .store(in: &self.context.disposeBag) - } - alertController.addAction(unmuteAction) - let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel, handler: nil) - alertController.addAction(cancelAction) - present(alertController, animated: true, completion: nil) - case .blocking: - let name = mastodonUser.displayNameWithFallback - let alertController = UIAlertController( - title: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.title, - message: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.message(name), - preferredStyle: .alert - ) - let unblockAction = UIAlertAction(title: L10n.Common.Controls.Friendship.unblock, style: .default) { [weak self] _ in - guard let self = self else { return } - UserProviderFacade.toggleUserBlockRelationship(provider: self, mastodonUser: mastodonUser) - .sink { _ in - // do nothing - } receiveValue: { _ in - // do nothing - } - .store(in: &self.context.disposeBag) - } - alertController.addAction(unblockAction) - let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel, handler: nil) - alertController.addAction(cancelAction) - present(alertController, animated: true, completion: nil) - case .blocked: - break - default: - assertionFailure() - } - } - -} diff --git a/Mastodon/Scene/Search/Search/SearchViewController+Recommend.swift b/Mastodon/Scene/Search/Search/SearchViewController+Recommend.swift deleted file mode 100644 index 4365a63f4..000000000 --- a/Mastodon/Scene/Search/Search/SearchViewController+Recommend.swift +++ /dev/null @@ -1,118 +0,0 @@ -// -// SearchViewController+Recommend.swift -// Mastodon -// -// Created by sxiaojian on 2021/3/31. -// - -import CoreData -import CoreDataStack -import Foundation -import MastodonSDK -import OSLog -import UIKit - -extension SearchViewController { - func setupHashTagCollectionView() { - let header = SearchRecommendCollectionHeader() - header.titleLabel.text = L10n.Scene.Search.Recommend.HashTag.title - header.descriptionLabel.text = L10n.Scene.Search.Recommend.HashTag.description - header.seeAllButton.isHidden = true - stackView.addArrangedSubview(header) - - hashtagCollectionView.register(SearchRecommendTagsCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: SearchRecommendTagsCollectionViewCell.self)) - hashtagCollectionView.delegate = self - - hashtagCollectionView.translatesAutoresizingMaskIntoConstraints = false - stackView.addArrangedSubview(hashtagCollectionView) - NSLayoutConstraint.activate([ - hashtagCollectionView.frameLayoutGuide.heightAnchor.constraint(equalToConstant: CGFloat(SearchViewController.hashtagCardHeight)) - ]) - } - - func setupAccountsCollectionView() { - let header = SearchRecommendCollectionHeader() - header.titleLabel.text = L10n.Scene.Search.Recommend.Accounts.title - header.descriptionLabel.text = L10n.Scene.Search.Recommend.Accounts.description - header.seeAllButton.addTarget(self, action: #selector(SearchViewController.accountSeeAllButtonPressed(_:)), for: .touchUpInside) - stackView.addArrangedSubview(header) - - accountsCollectionView.register(SearchRecommendAccountsCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: SearchRecommendAccountsCollectionViewCell.self)) - accountsCollectionView.delegate = self - - accountsCollectionView.translatesAutoresizingMaskIntoConstraints = false - stackView.addArrangedSubview(accountsCollectionView) - NSLayoutConstraint.activate([ - accountsCollectionView.frameLayoutGuide.heightAnchor.constraint(equalToConstant: CGFloat(SearchViewController.accountCardHeight)) - ]) - } - - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - hashtagCollectionView.collectionViewLayout.invalidateLayout() - accountsCollectionView.collectionViewLayout.invalidateLayout() - } -} - -extension SearchViewController: UICollectionViewDelegate { - func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: indexPath: %s", (#file as NSString).lastPathComponent, #line, #function, indexPath.debugDescription) - collectionView.selectItem(at: indexPath, animated: true, scrollPosition: .centeredHorizontally) - switch collectionView { - case self.accountsCollectionView: - guard let diffableDataSource = viewModel.accountDiffableDataSource else { return } - guard let accountObjectID = diffableDataSource.itemIdentifier(for: indexPath) else { return } - let mastodonUser = context.managedObjectContext.object(with: accountObjectID) as! MastodonUser - let viewModel = ProfileViewModel(context: context, optionalMastodonUser: mastodonUser) - DispatchQueue.main.async { - self.coordinator.present(scene: .profile(viewModel: viewModel), from: self, transition: .show) - } - case self.hashtagCollectionView: - guard let diffableDataSource = viewModel.hashtagDiffableDataSource else { return } - guard let hashtag = diffableDataSource.itemIdentifier(for: indexPath) else { return } - let (tagInCoreData, _) = APIService.CoreData.createOrMergeTag(into: context.managedObjectContext, entity: hashtag) - let viewModel = HashtagTimelineViewModel(context: context, hashtag: tagInCoreData.name) - DispatchQueue.main.async { - self.coordinator.present(scene: .hashtagTimeline(viewModel: viewModel), from: self, transition: .show) - } - default: - break - } - } -} - -// MARK: - UICollectionViewDelegateFlowLayout - -extension SearchViewController: UICollectionViewDelegateFlowLayout { - func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets { - UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16) - } - - func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat { - if collectionView == hashtagCollectionView { - return 6 - } else { - return 12 - } - } - - func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { - if collectionView == hashtagCollectionView { - return CGSize(width: 228, height: SearchViewController.hashtagCardHeight) - } else { - return CGSize(width: 257, height: SearchViewController.accountCardHeight) - } - } -} - -extension SearchViewController { - @objc func hashtagSeeAllButtonPressed(_ sender: UIButton) {} - - @objc func accountSeeAllButtonPressed(_ sender: UIButton) { - if self.viewModel.recommendAccounts.isEmpty { - return - } - let viewModel = SuggestionAccountViewModel(context: context, accounts: self.viewModel.recommendAccounts) - coordinator.present(scene: .suggestionAccount(viewModel: viewModel), from: self, transition: .modal(animated: true, completion: nil)) - } -} diff --git a/Mastodon/Scene/Search/Search/SearchViewController.swift b/Mastodon/Scene/Search/Search/SearchViewController.swift index 8dcf9cd3b..d1bed9484 100644 --- a/Mastodon/Scene/Search/Search/SearchViewController.swift +++ b/Mastodon/Scene/Search/Search/SearchViewController.swift @@ -10,6 +10,8 @@ import Combine import GameplayKit import MastodonSDK import UIKit +import MastodonAsset +import MastodonLocalization final class HeightFixedSearchBar: UISearchBar { override var intrinsicContentSize: CGSize { @@ -19,26 +21,7 @@ final class HeightFixedSearchBar: UISearchBar { final class SearchViewController: UIViewController, NeedsDependency { - let logger = Logger(subsystem: "Search", category: "UI") - - public static var hashtagCardHeight: CGFloat { - get { - if UIScreen.main.bounds.size.height > 736 { - return 186 - } - return 130 - } - } - - public static var hashtagPeopleTalkingLabelTop: CGFloat { - get { - if UIScreen.main.bounds.size.height > 736 { - return 18 - } - return 6 - } - } - public static let accountCardHeight = 202 + let logger = Logger(subsystem: "SearchViewController", category: "ViewController") weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } @@ -52,45 +35,15 @@ final class SearchViewController: UIViewController, NeedsDependency { // layout alongside with split mode button (on iPad) let titleViewContainer = UIView() let searchBar = HeightFixedSearchBar() - - // recommend - let scrollView: UIScrollView = { - let scrollView = UIScrollView() - scrollView.showsVerticalScrollIndicator = false - scrollView.alwaysBounceVertical = true - scrollView.clipsToBounds = false - return scrollView - }() - let stackView: UIStackView = { - let stackView = UIStackView() - stackView.axis = .vertical - stackView.distribution = .fill - return stackView - }() - - let hashtagCollectionView: UICollectionView = { - let flowLayout = UICollectionViewFlowLayout() - flowLayout.scrollDirection = .horizontal - let view = ControlContainableCollectionView(frame: .zero, collectionViewLayout: flowLayout) - view.backgroundColor = .clear - view.showsHorizontalScrollIndicator = false - view.showsVerticalScrollIndicator = false - view.layer.masksToBounds = false - view.translatesAutoresizingMaskIntoConstraints = false - return view - }() - - let accountsCollectionView: UICollectionView = { - let flowLayout = UICollectionViewFlowLayout() - flowLayout.scrollDirection = .horizontal - let view = ControlContainableCollectionView(frame: .zero, collectionViewLayout: flowLayout) - view.backgroundColor = .clear - view.showsHorizontalScrollIndicator = false - view.showsVerticalScrollIndicator = false - view.layer.masksToBounds = false - view.translatesAutoresizingMaskIntoConstraints = false - return view + let collectionView: UICollectionView = { + var configuration = UICollectionLayoutListConfiguration(appearance: .insetGrouped) + configuration.backgroundColor = .clear + configuration.headerMode = .supplementary + let layout = UICollectionViewCompositionalLayout.list(using: configuration) + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + collectionView.backgroundColor = .clear + return collectionView }() let searchBarTapPublisher = PassthroughSubject<Void, Never>() @@ -107,7 +60,7 @@ extension SearchViewController { setupBackgroundColor(theme: ThemeService.shared.currentTheme.value) ThemeService.shared.currentTheme - .receive(on: RunLoop.main) + .receive(on: DispatchQueue.main) .sink { [weak self] theme in guard let self = self else { return } self.setupBackgroundColor(theme: theme) @@ -117,10 +70,20 @@ extension SearchViewController { title = L10n.Scene.Search.title setupSearchBar() - setupScrollView() - setupHashTagCollectionView() - setupAccountsCollectionView() - setupDataSource() + + 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), + ]) + + collectionView.delegate = self + viewModel.setupDiffableDataSource( + collectionView: collectionView + ) } override func viewDidAppear(_ animated: Bool) { @@ -165,41 +128,6 @@ extension SearchViewController { .store(in: &disposeBag) } - private func setupScrollView() { - scrollView.translatesAutoresizingMaskIntoConstraints = false - stackView.translatesAutoresizingMaskIntoConstraints = false - - // scrollView - view.addSubview(scrollView) - NSLayoutConstraint.activate([ - scrollView.frameLayoutGuide.topAnchor.constraint(equalTo: view.topAnchor), - scrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.leadingAnchor), - view.trailingAnchor.constraint(equalTo: scrollView.frameLayoutGuide.trailingAnchor), - scrollView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.bottomAnchor), - scrollView.frameLayoutGuide.widthAnchor.constraint(equalTo: scrollView.contentLayoutGuide.widthAnchor), - ]) - - // stack view - scrollView.addSubview(stackView) - stackView.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - stackView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), - stackView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), - stackView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), - stackView.widthAnchor.constraint(equalTo: scrollView.contentLayoutGuide.widthAnchor), - scrollView.contentLayoutGuide.bottomAnchor.constraint(equalTo: stackView.bottomAnchor), - ]) - } - - private func setupDataSource() { - viewModel.hashtagDiffableDataSource = RecommendHashTagSection.collectionViewDiffableDataSource(for: hashtagCollectionView) - viewModel.accountDiffableDataSource = RecommendAccountSection.collectionViewDiffableDataSource( - for: accountsCollectionView, - dependency: self, - delegate: self, - managedObjectContext: context.managedObjectContext - ) - } } // MARK: - UISearchBarDelegate @@ -211,7 +139,7 @@ extension SearchViewController: UISearchBarDelegate { } } -// MARK - UISearchControllerDelegate +// MARK: - UISearchControllerDelegate extension SearchViewController: UISearchControllerDelegate { func willDismissSearchController(_ searchController: UISearchController) { logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") @@ -222,17 +150,22 @@ extension SearchViewController: UISearchControllerDelegate { } } -#if canImport(SwiftUI) && DEBUG -import SwiftUI - -struct SearchViewController_Previews: PreviewProvider { - static var previews: some View { - UIViewControllerPreview { - let viewController = SearchViewController() - return viewController +// MARK: - UICollectionViewDelegate +extension SearchViewController: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): select item at: \(indexPath.debugDescription)") + + defer { + collectionView.deselectItem(at: indexPath, animated: true) + } + + guard let diffableDataSource = viewModel.diffableDataSource else { return } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } + + switch item { + case .trend(let hashtag): + let viewModel = HashtagTimelineViewModel(context: context, hashtag: hashtag.name) + coordinator.present(scene: .hashtagTimeline(viewModel: viewModel), from: self, transition: .show) } - .previewLayout(.fixed(width: 375, height: 800)) } } - -#endif diff --git a/Mastodon/Scene/Search/Search/SearchViewModel+Diffable.swift b/Mastodon/Scene/Search/Search/SearchViewModel+Diffable.swift new file mode 100644 index 000000000..ca741b7f3 --- /dev/null +++ b/Mastodon/Scene/Search/Search/SearchViewModel+Diffable.swift @@ -0,0 +1,42 @@ +// +// SearchViewModel+Diffable.swift +// Mastodon +// +// Created by MainasuK on 2022-1-18. +// + +import UIKit +import MastodonSDK + +extension SearchViewModel { + + func setupDiffableDataSource( + collectionView: UICollectionView + ) { + diffableDataSource = SearchSection.diffableDataSource( + collectionView: collectionView, + context: context + ) + + var snapshot = NSDiffableDataSourceSnapshot<SearchSection, SearchItem>() + snapshot.appendSections([.trend]) + diffableDataSource?.apply(snapshot) + + $hashtags + .receive(on: DispatchQueue.main) + .sink { [weak self] hashtags in + guard let self = self else { return } + guard let diffableDataSource = self.diffableDataSource else { return } + + var snapshot = NSDiffableDataSourceSnapshot<SearchSection, SearchItem>() + snapshot.appendSections([.trend]) + + let trendItems = hashtags.map { SearchItem.trend($0) } + snapshot.appendItems(trendItems, toSection: .trend) + + diffableDataSource.apply(snapshot) + } + .store(in: &disposeBag) + } + +} diff --git a/Mastodon/Scene/Search/Search/SearchViewModel.swift b/Mastodon/Scene/Search/Search/SearchViewModel.swift index feae75190..2776713df 100644 --- a/Mastodon/Scene/Search/Search/SearchViewModel.swift +++ b/Mastodon/Scene/Search/Search/SearchViewModel.swift @@ -22,124 +22,38 @@ final class SearchViewModel: NSObject { let viewDidAppeared = PassthroughSubject<Void, Never>() // output - let currentMastodonUser = CurrentValueSubject<MastodonUser?, Never>(nil) - - var recommendAccounts = [NSManagedObjectID]() - var recommendAccountsFallback = PassthroughSubject<Void, Never>() + var diffableDataSource: UICollectionViewDiffableDataSource<SearchSection, SearchItem>? + @Published var hashtags: [Mastodon.Entity.Tag] = [] - var hashtagDiffableDataSource: UICollectionViewDiffableDataSource<RecommendHashTagSection, Mastodon.Entity.Tag>? - var accountDiffableDataSource: UICollectionViewDiffableDataSource<RecommendAccountSection, NSManagedObjectID>? - init(context: AppContext) { self.context = context super.init() - context.authenticationService.activeMastodonAuthentication - .map { $0?.user } - .assign(to: \.value, on: currentMastodonUser) - .store(in: &disposeBag) - Publishers.CombineLatest( context.authenticationService.activeMastodonAuthenticationBox, viewDidAppeared ) - .compactMap { activeMastodonAuthenticationBox, _ -> MastodonAuthenticationBox? in - return activeMastodonAuthenticationBox + .compactMap { authenticationBox, _ -> MastodonAuthenticationBox? in + return authenticationBox } - .throttle(for: 1, scheduler: DispatchQueue.main, latest: false) - .flatMap { box in - context.apiService.recommendTrends(domain: box.domain, query: nil) - .map { response in Result<Mastodon.Response.Content<[Mastodon.Entity.Tag]>, Error> { response } } - .catch { error in Just(Result<Mastodon.Response.Content<[Mastodon.Entity.Tag]>, Error> { throw error }) } - .eraseToAnyPublisher() + .throttle(for: 3, scheduler: DispatchQueue.main, latest: true) + .asyncMap { authenticationBox in + try await context.apiService.trends(domain: authenticationBox.domain, query: nil) } - .receive(on: RunLoop.main) + .retry(3) + .map { response in Result<Mastodon.Response.Content<[Mastodon.Entity.Tag]>, Error> { response } } + .catch { error in Just(Result<Mastodon.Response.Content<[Mastodon.Entity.Tag]>, Error> { throw error }) } + .receive(on: DispatchQueue.main) .sink { [weak self] result in guard let self = self else { return } switch result { case .success(let response): - guard let dataSource = self.hashtagDiffableDataSource else { return } - var snapshot = NSDiffableDataSourceSnapshot<RecommendHashTagSection, Mastodon.Entity.Tag>() - snapshot.appendSections([.main]) - snapshot.appendItems(response.value, toSection: .main) - dataSource.apply(snapshot, animatingDifferences: false, completion: nil) + self.hashtags = response.value case .failure: break } } .store(in: &disposeBag) - - Publishers.CombineLatest( - context.authenticationService.activeMastodonAuthenticationBox, - viewDidAppeared - ) - .compactMap { activeMastodonAuthenticationBox, _ -> MastodonAuthenticationBox? in - return activeMastodonAuthenticationBox - } - .throttle(for: 1, scheduler: DispatchQueue.main, latest: false) - .flatMap { box -> AnyPublisher<Result<[Mastodon.Entity.Account.ID], Error>, Never> in - context.apiService.suggestionAccountV2(domain: box.domain, query: nil, mastodonAuthenticationBox: box) - .map { response in Result<[Mastodon.Entity.Account.ID], Error> { response.value.map { $0.account.id } } } - .catch { error -> AnyPublisher<Result<[Mastodon.Entity.Account.ID], Error>, Never> in - if let apiError = error as? Mastodon.API.Error, apiError.httpResponseStatus == .notFound { - return context.apiService.suggestionAccount(domain: box.domain, query: nil, mastodonAuthenticationBox: box) - .map { response in Result<[Mastodon.Entity.Account.ID], Error> { response.value.map { $0.id } } } - .catch { error in Just(Result<[Mastodon.Entity.Account.ID], Error> { throw error }) } - .eraseToAnyPublisher() - } else { - return Just(Result<[Mastodon.Entity.Account.ID], Error> { throw error }) - .eraseToAnyPublisher() - } - } - .eraseToAnyPublisher() - } - .receive(on: RunLoop.main) - .sink { [weak self] result in - guard let self = self else { return } - switch result { - case .success(let userIDs): - self.receiveAccounts(ids: userIDs) - case .failure: - break - } - } - .store(in: &disposeBag) - } - - func receiveAccounts(ids: [Mastodon.Entity.Account.ID]) { - guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { - return - } - let userFetchRequest = MastodonUser.sortedFetchRequest - userFetchRequest.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, ids: ids) - let mastodonUsers: [MastodonUser]? = { - let userFetchRequest = MastodonUser.sortedFetchRequest - userFetchRequest.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, ids: ids) - userFetchRequest.returnsObjectsAsFaults = false - do { - return try self.context.managedObjectContext.fetch(userFetchRequest) - } catch { - assertionFailure(error.localizedDescription) - return nil - } - }() - guard let users = mastodonUsers else { return } - let objectIDs: [NSManagedObjectID] = users - .compactMap { object in - ids.firstIndex(of: object.id).map { index in (index, object) } - } - .sorted { $0.0 < $1.0 } - .map { $0.1.objectID } - - // append at front - let newObjectIDs = objectIDs.filter { !self.recommendAccounts.contains($0) } - self.recommendAccounts = newObjectIDs + self.recommendAccounts - - guard let dataSource = self.accountDiffableDataSource else { return } - var snapshot = NSDiffableDataSourceSnapshot<RecommendAccountSection, NSManagedObjectID>() - snapshot.appendSections([.main]) - snapshot.appendItems(self.recommendAccounts, toSection: .main) - dataSource.apply(snapshot, animatingDifferences: false, completion: nil) } } diff --git a/Mastodon/Scene/Search/Search/View/LineChartView.swift b/Mastodon/Scene/Search/Search/View/LineChartView.swift index a64aa270d..cd76fb0c8 100644 --- a/Mastodon/Scene/Search/Search/View/LineChartView.swift +++ b/Mastodon/Scene/Search/Search/View/LineChartView.swift @@ -8,6 +8,7 @@ import UIKit import Accelerate import simd +import MastodonAsset final class LineChartView: UIView { @@ -43,8 +44,8 @@ extension LineChartView { // layer.addSublayer(dotShapeLayer) gradientLayer.colors = [ - UIColor.white.withAlphaComponent(0.5).cgColor, - UIColor.white.withAlphaComponent(0).cgColor, + Asset.Colors.brandBlue.color.withAlphaComponent(0.5).cgColor, // set the same alpha to fill + Asset.Colors.brandBlue.color.withAlphaComponent(0.5).cgColor, ] gradientLayer.startPoint = CGPoint(x: 0.5, y: 0) gradientLayer.endPoint = CGPoint(x: 0.5, y: 1) @@ -95,8 +96,8 @@ extension LineChartView { dotPath.addArc(withCenter: last, radius: 3, startAngle: 0, endAngle: 2 * .pi, clockwise: true) } - lineShapeLayer.lineWidth = 3 - lineShapeLayer.strokeColor = UIColor.white.cgColor + lineShapeLayer.lineWidth = 1 + lineShapeLayer.strokeColor = Asset.Colors.brandBlue.color.cgColor lineShapeLayer.fillColor = UIColor.clear.cgColor lineShapeLayer.lineJoin = .round lineShapeLayer.lineCap = .round @@ -108,7 +109,7 @@ extension LineChartView { maskPath.close() let maskLayer = CAShapeLayer() maskLayer.path = maskPath.cgPath - maskLayer.fillColor = UIColor.red.cgColor + maskLayer.fillColor = Asset.Colors.brandBlue.color.cgColor maskLayer.strokeColor = UIColor.clear.cgColor maskLayer.lineWidth = 0.0 gradientLayer.mask = maskLayer diff --git a/Mastodon/Scene/Search/Search/View/SearchRecommendCollectionHeader.swift b/Mastodon/Scene/Search/Search/View/SearchRecommendCollectionHeader.swift index a828c64b6..0b7495cc8 100644 --- a/Mastodon/Scene/Search/Search/View/SearchRecommendCollectionHeader.swift +++ b/Mastodon/Scene/Search/Search/View/SearchRecommendCollectionHeader.swift @@ -7,6 +7,8 @@ import Foundation import UIKit +import MastodonAsset +import MastodonLocalization class SearchRecommendCollectionHeader: UIView { let titleLabel: UILabel = { diff --git a/Mastodon/Scene/Search/SearchDetail/SearchDetailViewController.swift b/Mastodon/Scene/Search/SearchDetail/SearchDetailViewController.swift index 486a3b48a..5e143a33c 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchDetailViewController.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchDetailViewController.swift @@ -9,6 +9,8 @@ import os.log import UIKit import Combine import Pageboy +import MastodonAsset +import MastodonLocalization // Fake search bar not works on iPad with UISplitViewController // check device and fallback to standard UISearchController @@ -137,7 +139,7 @@ extension SearchDetailViewController { // set initial items from "all" search scope for non-appeared lists if let allSearchScopeViewController = viewControllers.first(where: { $0.viewModel.searchScope == .all }) { - allSearchScopeViewController.viewModel.items + allSearchScopeViewController.viewModel.$items .receive(on: DispatchQueue.main) .sink { [weak self] items in guard let self = self else { return } @@ -151,20 +153,11 @@ extension SearchDetailViewController { assertionFailure() break case .people: - viewController.viewModel.items.value = items.filter { item in - guard case .account = item else { return false } - return true - } + viewController.viewModel.userFetchedResultsController.userIDs = allSearchScopeViewController.viewModel.userFetchedResultsController.userIDs case .hashtags: - viewController.viewModel.items.value = items.filter { item in - guard case .hashtag = item else { return false } - return true - } + viewController.viewModel.hashtags = allSearchScopeViewController.viewModel.hashtags case .posts: - viewController.viewModel.items.value = items.filter { item in - guard case .status = item else { return false } - return true - } + viewController.viewModel.statusFetchedResultsController.statusIDs.value = allSearchScopeViewController.viewModel.statusFetchedResultsController.statusIDs.value } } } diff --git a/Mastodon/Scene/Search/SearchDetail/SearchDetailViewModel.swift b/Mastodon/Scene/Search/SearchDetail/SearchDetailViewModel.swift index e53108bc5..140fe14e8 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchDetailViewModel.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchDetailViewModel.swift @@ -10,6 +10,8 @@ import Foundation import CoreGraphics import Combine import MastodonSDK +import MastodonAsset +import MastodonLocalization final class SearchDetailViewModel { diff --git a/Mastodon/Scene/Search/SearchDetail/SearchHistory/Cell/SearchHistorySectionHeaderCollectionReusableView.swift b/Mastodon/Scene/Search/SearchDetail/SearchHistory/Cell/SearchHistorySectionHeaderCollectionReusableView.swift new file mode 100644 index 000000000..4af94304f --- /dev/null +++ b/Mastodon/Scene/Search/SearchDetail/SearchHistory/Cell/SearchHistorySectionHeaderCollectionReusableView.swift @@ -0,0 +1,80 @@ +// +// SearchHistorySectionHeaderCollectionReusableView.swift +// Mastodon +// +// Created by MainasuK on 2022-1-20. +// + +import os.log +import UIKit +import MastodonAsset +import MastodonLocalization + +protocol SearchHistorySectionHeaderCollectionReusableViewDelegate: AnyObject { + func searchHistorySectionHeaderCollectionReusableView(_ searchHistorySectionHeaderCollectionReusableView: SearchHistorySectionHeaderCollectionReusableView, clearButtonDidPressed button: UIButton) +} + +final class SearchHistorySectionHeaderCollectionReusableView: UICollectionReusableView { + + let logger = Logger(subsystem: "SearchHistorySectionHeaderCollectionReusableView", category: "View") + + weak var delegate: SearchHistorySectionHeaderCollectionReusableViewDelegate? + + let primaryLabel: UILabel = { + let label = UILabel() + label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 22, weight: .bold)) + label.textColor = Asset.Colors.Label.primary.color + label.text = L10n.Scene.Search.Searching.recentSearch + return label + }() + + let clearButton: UIButton = { + let button = UIButton(type: .system) + button.setImage(UIImage(systemName: "xmark.circle.fill"), for: .normal) + button.tintColor = Asset.Colors.Label.secondary.color + return button + }() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension SearchHistorySectionHeaderCollectionReusableView { + private func _init() { + primaryLabel.translatesAutoresizingMaskIntoConstraints = false + addSubview(primaryLabel) + NSLayoutConstraint.activate([ + primaryLabel.topAnchor.constraint(equalTo: topAnchor, constant: 16), + primaryLabel.leadingAnchor.constraint(equalTo: leadingAnchor), + bottomAnchor.constraint(equalTo: primaryLabel.bottomAnchor, constant: 16).priority(.required - 1), + ]) + primaryLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) + + clearButton.translatesAutoresizingMaskIntoConstraints = false + addSubview(clearButton) + NSLayoutConstraint.activate([ + clearButton.centerYAnchor.constraint(equalTo: centerYAnchor), + clearButton.leadingAnchor.constraint(equalTo: primaryLabel.trailingAnchor, constant: 16), + clearButton.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) + clearButton.setContentHuggingPriority(.required - 10, for: .horizontal) + clearButton.setContentCompressionResistancePriority(.required - 10, for: .horizontal) + + clearButton.addTarget(self, action: #selector(SearchHistorySectionHeaderCollectionReusableView.clearButtonDidPressed(_:)), for: .touchUpInside) + } +} + +extension SearchHistorySectionHeaderCollectionReusableView { + @objc private func clearButtonDidPressed(_ sender: UIButton) { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") + delegate?.searchHistorySectionHeaderCollectionReusableView(self, clearButtonDidPressed: sender) + } +} diff --git a/Mastodon/Scene/Search/SearchDetail/SearchHistory/Cell/SearchHistoryUserCollectionViewCell+ViewModel.swift b/Mastodon/Scene/Search/SearchDetail/SearchHistory/Cell/SearchHistoryUserCollectionViewCell+ViewModel.swift new file mode 100644 index 000000000..d4cb86eb0 --- /dev/null +++ b/Mastodon/Scene/Search/SearchDetail/SearchHistory/Cell/SearchHistoryUserCollectionViewCell+ViewModel.swift @@ -0,0 +1,27 @@ +// +// SearchHistoryUserCollectionViewCell+ViewModel.swift +// Mastodon +// +// Created by MainasuK on 2022-1-20. +// + +import Foundation +import CoreDataStack + +extension SearchHistoryUserCollectionViewCell { + final class ViewModel { + let value: MastodonUser + + init(value: MastodonUser) { + self.value = value + } + } +} + +extension SearchHistoryUserCollectionViewCell { + func configure( + viewModel: ViewModel + ) { + userView.configure(user: viewModel.value) + } +} diff --git a/Mastodon/Scene/Search/SearchDetail/SearchHistory/Cell/SearchHistoryUserCollectionViewCell.swift b/Mastodon/Scene/Search/SearchDetail/SearchHistory/Cell/SearchHistoryUserCollectionViewCell.swift new file mode 100644 index 000000000..71663dd66 --- /dev/null +++ b/Mastodon/Scene/Search/SearchDetail/SearchHistory/Cell/SearchHistoryUserCollectionViewCell.swift @@ -0,0 +1,71 @@ +// +// SearchHistoryUserCollectionViewCell.swift +// Mastodon +// +// Created by MainasuK on 2022-1-20. +// + +import UIKit +import Combine +import MastodonUI + +final class SearchHistoryUserCollectionViewCell: UICollectionViewCell { + + var _disposeBag = Set<AnyCancellable>() + + let userView = UserView() + + override func prepareForReuse() { + super.prepareForReuse() + + userView.prepareForReuse() + } + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension SearchHistoryUserCollectionViewCell { + + private func _init() { + ThemeService.shared.currentTheme + .map { $0.secondarySystemGroupedBackgroundColor } + .sink { [weak self] backgroundColor in + guard let self = self else { return } + self.backgroundColor = backgroundColor + self.setNeedsUpdateConfiguration() + } + .store(in: &_disposeBag) + + userView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(userView) + NSLayoutConstraint.activate([ + userView.topAnchor.constraint(equalTo: contentView.topAnchor), + userView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + userView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: 16), + userView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + ]) + } + + override func updateConfiguration(using state: UICellConfigurationState) { + super.updateConfiguration(using: state) + + var backgroundConfiguration = UIBackgroundConfiguration.listGroupedCell() + backgroundConfiguration.backgroundColorTransformer = .init { _ in + if state.isHighlighted || state.isSelected { + return ThemeService.shared.currentTheme.value.tableViewCellSelectionBackgroundColor + } + return ThemeService.shared.currentTheme.value.secondarySystemGroupedBackgroundColor + } + self.backgroundConfiguration = backgroundConfiguration + } + +} diff --git a/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewController+DataSourceProvider.swift b/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewController+DataSourceProvider.swift new file mode 100644 index 000000000..a1bae2638 --- /dev/null +++ b/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewController+DataSourceProvider.swift @@ -0,0 +1,36 @@ +// +// SearchHistoryViewController+DataSourceProvider.swift +// Mastodon +// +// Created by MainasuK on 2022-1-20. +// + +import UIKit + +// MARK: - DataSourceProvider +extension SearchHistoryViewController: DataSourceProvider { + func item(from source: DataSourceItem.Source) async -> DataSourceItem? { + var _indexPath = source.indexPath + if _indexPath == nil, let cell = source.collectionViewCell { + _indexPath = await self.indexPath(for: cell) + } + guard let indexPath = _indexPath else { return nil } + + guard let item = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else { + return nil + } + + switch item { + case .user(let record): + return .user(record: record) + case .hashtag(let record): + return .hashtag(tag: .record(record)) + } + } + + @MainActor + private func indexPath(for cell: UICollectionViewCell) async -> IndexPath? { + return collectionView.indexPath(for: cell) + } +} + diff --git a/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewController.swift b/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewController.swift index f60b2029d..0dbb89cf4 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewController.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewController.swift @@ -5,30 +5,29 @@ // Created by MainasuK Cirno on 2021-7-13. // +import os.log import UIKit import Combine import CoreDataStack final class SearchHistoryViewController: UIViewController, NeedsDependency { - - var disposeBag = Set<AnyCancellable>() + + let logger = Logger(subsystem: "SearchHistoryViewController", category: "ViewController") weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + var disposeBag = Set<AnyCancellable>() var viewModel: SearchHistoryViewModel! - - let searchHistoryTableHeaderView = SearchHistoryTableHeaderView() - let tableView: UITableView = { - let tableView = UITableView() - tableView.register(SearchResultTableViewCell.self, forCellReuseIdentifier: String(describing: SearchResultTableViewCell.self)) -// tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.self)) - tableView.separatorStyle = .none - tableView.tableFooterView = UIView() - tableView.backgroundColor = .clear - return tableView + + let collectionView: UICollectionView = { + var configuration = UICollectionLayoutListConfiguration(appearance: .insetGrouped) + configuration.backgroundColor = .clear + configuration.headerMode = .supplementary + let layout = UICollectionViewCompositionalLayout.list(using: configuration) + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + return collectionView }() - } extension SearchHistoryViewController { @@ -38,37 +37,28 @@ extension SearchHistoryViewController { setupBackgroundColor(theme: ThemeService.shared.currentTheme.value) ThemeService.shared.currentTheme - .receive(on: RunLoop.main) + .receive(on: DispatchQueue.main) .sink { [weak self] theme in guard let self = self else { return } self.setupBackgroundColor(theme: theme) } .store(in: &disposeBag) - tableView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(tableView) + collectionView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(collectionView) 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), + collectionView.topAnchor.constraint(equalTo: view.topAnchor), + collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor), ]) - - tableView.delegate = self + + collectionView.delegate = self viewModel.setupDiffableDataSource( - tableView: tableView, - dependency: self + collectionView: collectionView, + searchHistorySectionHeaderCollectionReusableViewDelegate: self ) - - searchHistoryTableHeaderView.delegate = self } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - - tableView.deselectRow(with: transitionCoordinator, animated: animated) - } - } extension SearchHistoryViewController { @@ -77,52 +67,59 @@ extension SearchHistoryViewController { } } -// MARK: - UITableViewDelegate -extension SearchHistoryViewController: UITableViewDelegate { - func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - switch section { - case 0: - return searchHistoryTableHeaderView - default: - return UIView() +// MARK: - UICollectionViewDelegate +extension SearchHistoryViewController: UICollectionViewDelegate { + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): select item at: \(indexPath.debugDescription)") + + defer { + collectionView.deselectItem(at: indexPath, animated: true) + } + + Task { + let source = DataSourceItem.Source(indexPath: indexPath) + guard let item = await item(from: source) else { + return + } + + await DataSourceFacade.responseToCreateSearchHistory( + provider: self, + item: item + ) + + switch item { + case .user(let record): + await DataSourceFacade.coordinateToProfileScene( + provider: self, + user: record + ) + case .hashtag(let record): + await DataSourceFacade.coordinateToHashtagScene( + provider: self, + tag: record + ) + default: + assertionFailure() + break + } } } - func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - switch section { - case 0: - return UITableView.automaticDimension - default: - return .leastNonzeroMagnitude - } - } +} - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - guard let diffableDataSource = viewModel.diffableDataSource else { return } - guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } - - viewModel.persistSearchHistory(for: item) - - switch item { - case .account(let objectID): - guard let user = try? viewModel.searchHistoryFetchedResultController.fetchedResultsController.managedObjectContext.existingObject(with: objectID) as? MastodonUser else { return } - let profileViewModel = CachedProfileViewModel(context: context, mastodonUser: user) - coordinator.present(scene: .profile(viewModel: profileViewModel), from: self, transition: .show) - case .hashtag(let objectID): - guard let hashtag = try? viewModel.searchHistoryFetchedResultController.fetchedResultsController.managedObjectContext.existingObject(with: objectID) as? Tag else { return } - let hashtagViewModel = HashtagTimelineViewModel(context: context, hashtag: hashtag.name) - coordinator.present(scene: .hashtagTimeline(viewModel: hashtagViewModel), from: self, transition: .show) - case .status(let objectID, _): - guard let status = try? viewModel.searchHistoryFetchedResultController.fetchedResultsController.managedObjectContext.existingObject(with: objectID) as? Status else { return } - let threadViewModel = CachedThreadViewModel(context: context, status: status) - coordinator.present(scene: .thread(viewModel: threadViewModel), from: self, transition: .show) +// MARK: - SearchHistorySectionHeaderCollectionReusableViewDelegate +extension SearchHistoryViewController: SearchHistorySectionHeaderCollectionReusableViewDelegate { + func searchHistorySectionHeaderCollectionReusableView( + _ searchHistorySectionHeaderCollectionReusableView: SearchHistorySectionHeaderCollectionReusableView, + clearButtonDidPressed button: UIButton + ) { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") + + Task { + try await DataSourceFacade.responseToDeleteSearchHistory( + provider: self + ) } } } - -// MARK: - SearchHistoryTableHeaderViewDelegate -extension SearchHistoryViewController: SearchHistoryTableHeaderViewDelegate { - func searchHistoryTableHeaderView(_ searchHistoryTableHeaderView: SearchHistoryTableHeaderView, clearSearchHistoryButtonDidPressed button: UIButton) { - viewModel.clearSearchHistory() - } -} diff --git a/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewModel+Diffable.swift b/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewModel+Diffable.swift new file mode 100644 index 000000000..c559523a7 --- /dev/null +++ b/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewModel+Diffable.swift @@ -0,0 +1,66 @@ +// +// SearchHistoryViewModel+Diffable.swift +// Mastodon +// +// Created by MainasuK on 2022-1-20. +// + +import UIKit + +extension SearchHistoryViewModel { + + func setupDiffableDataSource( + collectionView: UICollectionView, + searchHistorySectionHeaderCollectionReusableViewDelegate: SearchHistorySectionHeaderCollectionReusableViewDelegate + ) { + diffableDataSource = SearchHistorySection.diffableDataSource( + collectionView: collectionView, + context: context, + configuration: SearchHistorySection.Configuration( + searchHistorySectionHeaderCollectionReusableViewDelegate: searchHistorySectionHeaderCollectionReusableViewDelegate + ) + ) + + var snapshot = NSDiffableDataSourceSnapshot<SearchHistorySection, SearchHistoryItem>() + snapshot.appendSections([.main]) + diffableDataSource?.apply(snapshot, animatingDifferences: false) + + searchHistoryFetchedResultController.$records + .receive(on: DispatchQueue.main) + .sink { [weak self] records in + guard let self = self else { return } + guard let diffableDataSource = self.diffableDataSource else { return } + + Task { + do { + let managedObjectContext = self.context.managedObjectContext + let items: [SearchHistoryItem] = try await managedObjectContext.perform { + var users: [SearchHistoryItem] = [] + var hashtags: [SearchHistoryItem] = [] + + for record in records { + guard let searchHistory = record.object(in: managedObjectContext) else { continue } + if let user = searchHistory.account { + users.append(.user(.init(objectID: user.objectID))) + } else if let hashtag = searchHistory.hashtag { + hashtags.append(.hashtag(.init(objectID: hashtag.objectID))) + } else { + continue + } + } + + return users + hashtags + } + var snapshot = NSDiffableDataSourceSnapshot<SearchHistorySection, SearchHistoryItem>() + snapshot.appendSections([.main]) + snapshot.appendItems(items, toSection: .main) + diffableDataSource.apply(snapshot, animatingDifferences: false) + } catch { + // do nothing + } + } // end Task + } + .store(in: &disposeBag) + } + +} diff --git a/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewModel.swift b/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewModel.swift index 0ed58b07e..c7a135964 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewModel.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewModel.swift @@ -19,7 +19,7 @@ final class SearchHistoryViewModel { let searchHistoryFetchedResultController: SearchHistoryFetchedResultController // output - var diffableDataSource: UITableViewDiffableDataSource<SearchHistorySection, SearchHistoryItem>! + var diffableDataSource: UICollectionViewDiffableDataSource<SearchHistorySection, SearchHistoryItem>? init(context: AppContext) { self.context = context @@ -33,126 +33,74 @@ final class SearchHistoryViewModel { self.searchHistoryFetchedResultController.userID.value = box?.userID } .store(in: &disposeBag) - - // may block main queue by large dataset - searchHistoryFetchedResultController.objectIDs - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink { [weak self] objectIDs in - guard let self = self else { return } - guard let diffableDataSource = self.diffableDataSource else { return } - let managedObjectContext = self.searchHistoryFetchedResultController.fetchedResultsController.managedObjectContext - - var items: [SearchHistoryItem] = [] - for objectID in objectIDs { - guard let searchHistory = try? managedObjectContext.existingObject(with: objectID) as? SearchHistory else { continue } - if let account = searchHistory.account { - let item: SearchHistoryItem = .account(objectID: account.objectID) - guard !items.contains(item) else { continue } - items.append(item) - } else if let hashtag = searchHistory.hashtag { - let item: SearchHistoryItem = .hashtag(objectID: hashtag.objectID) - guard !items.contains(item) else { continue } - items.append(item) - } else { - // TODO: status - } - } - - var snapshot = NSDiffableDataSourceSnapshot<SearchHistorySection, SearchHistoryItem>() - snapshot.appendSections([.main]) - snapshot.appendItems(items, toSection: .main) - - diffableDataSource.apply(snapshot, animatingDifferences: false) - } - .store(in: &disposeBag) - - try? searchHistoryFetchedResultController.fetchedResultsController.performFetch() } } -extension SearchHistoryViewModel { - func setupDiffableDataSource( - tableView: UITableView, - dependency: NeedsDependency - ) { - diffableDataSource = SearchHistorySection.tableViewDiffableDataSource( - for: tableView, - dependency: dependency - ) - - var snapshot = NSDiffableDataSourceSnapshot<SearchHistorySection, SearchHistoryItem>() - snapshot.appendSections([.main]) - diffableDataSource.apply(snapshot, animatingDifferences: false) - } -} - -extension SearchHistoryViewModel { - func persistSearchHistory(for item: SearchHistoryItem) { - guard let box = context.authenticationService.activeMastodonAuthenticationBox.value else { return } - let property = SearchHistory.Property(domain: box.domain, userID: box.userID) - - switch item { - case .account(let objectID): - let managedObjectContext = context.backgroundManagedObjectContext - managedObjectContext.performChanges { - guard let user = try? managedObjectContext.existingObject(with: objectID) as? MastodonUser else { return } - 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 - switch result { - case .failure(let error): - assertionFailure(error.localizedDescription) - case .success: - break - } - } - .store(in: &context.disposeBag) - - case .hashtag(let objectID): - let managedObjectContext = context.backgroundManagedObjectContext - managedObjectContext.performChanges { - guard let hashtag = try? managedObjectContext.existingObject(with: objectID) as? Tag else { return } - if let searchHistory = hashtag.findSearchHistory(domain: box.domain, userID: box.userID) { - searchHistory.update(updatedAt: Date()) - } else { - _ = SearchHistory.insert(into: managedObjectContext, property: property, hashtag: hashtag) - } - } - .sink { result in - switch result { - case .failure(let error): - assertionFailure(error.localizedDescription) - case .success: - break - } - } - .store(in: &context.disposeBag) - - case .status: - // FIXME: - break - } - } - - func clearSearchHistory() { - let managedObjectContext = context.backgroundManagedObjectContext - managedObjectContext.performChanges { - let request = SearchHistory.sortedFetchRequest - let searchHistories = managedObjectContext.safeFetch(request) - for searchHistory in searchHistories { - managedObjectContext.delete(searchHistory) - } - } - .sink { result in - // do nothing - } - .store(in: &context.disposeBag) - - } -} +//extension SearchHistoryViewModel { +// func persistSearchHistory(for item: SearchHistoryItem) { +// guard let box = context.authenticationService.activeMastodonAuthenticationBox.value else { return } +// let property = SearchHistory.Property(domain: box.domain, userID: box.userID) +// +// switch item { +// case .account(let objectID): +// let managedObjectContext = context.backgroundManagedObjectContext +// managedObjectContext.performChanges { +// guard let user = try? managedObjectContext.existingObject(with: objectID) as? MastodonUser else { return } +// 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 +// switch result { +// case .failure(let error): +// assertionFailure(error.localizedDescription) +// case .success: +// break +// } +// } +// .store(in: &context.disposeBag) +// +// case .hashtag(let objectID): +// let managedObjectContext = context.backgroundManagedObjectContext +// managedObjectContext.performChanges { +// guard let hashtag = try? managedObjectContext.existingObject(with: objectID) as? Tag else { return } +// if let searchHistory = hashtag.findSearchHistory(domain: box.domain, userID: box.userID) { +// searchHistory.update(updatedAt: Date()) +// } else { +// _ = SearchHistory.insert(into: managedObjectContext, property: property, hashtag: hashtag) +// } +// } +// .sink { result in +// switch result { +// case .failure(let error): +// assertionFailure(error.localizedDescription) +// case .success: +// break +// } +// } +// .store(in: &context.disposeBag) +// +// case .status: +// // FIXME: +// break +// } +// } +// +// func clearSearchHistory() { +// let managedObjectContext = context.backgroundManagedObjectContext +// managedObjectContext.performChanges { +// let request = SearchHistory.sortedFetchRequest +// let searchHistories = managedObjectContext.safeFetch(request) +// for searchHistory in searchHistories { +// managedObjectContext.delete(searchHistory) +// } +// } +// .sink { result in +// // do nothing +// } +// .store(in: &context.disposeBag) +// } +//} diff --git a/Mastodon/Scene/Search/SearchDetail/SearchHistory/View/SearchHistoryTableHeaderView.swift b/Mastodon/Scene/Search/SearchDetail/SearchHistory/View/SearchHistoryTableHeaderView.swift index 6a360e78b..8ac661b18 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchHistory/View/SearchHistoryTableHeaderView.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchHistory/View/SearchHistoryTableHeaderView.swift @@ -8,6 +8,8 @@ import os.log import UIKit import Combine +import MastodonAsset +import MastodonLocalization protocol SearchHistoryTableHeaderViewDelegate: AnyObject { func searchHistoryTableHeaderView(_ searchHistoryTableHeaderView: SearchHistoryTableHeaderView, clearSearchHistoryButtonDidPressed button: UIButton) diff --git a/Mastodon/Scene/Search/SearchDetail/SearchResult/Cell/HashtagTableViewCell.swift b/Mastodon/Scene/Search/SearchDetail/SearchResult/Cell/HashtagTableViewCell.swift new file mode 100644 index 000000000..c8938c549 --- /dev/null +++ b/Mastodon/Scene/Search/SearchDetail/SearchResult/Cell/HashtagTableViewCell.swift @@ -0,0 +1,53 @@ +// +// HashtagTableViewCell.swift +// Mastodon +// +// Created by MainasuK on 2022-1-20. +// + +import UIKit +import MetaTextKit + +final class HashtagTableViewCell: UITableViewCell { + + let primaryLabel = MetaLabel(style: .statusName) + + 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 HashtagTableViewCell { + + private func _init() { + primaryLabel.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(primaryLabel) + NSLayoutConstraint.activate([ + primaryLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 11), + primaryLabel.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), + primaryLabel.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: primaryLabel.bottomAnchor, constant: 11), + ]) + + separatorLine.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(separatorLine) + NSLayoutConstraint.activate([ + separatorLine.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), + separatorLine.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), + separatorLine.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + separatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView)).priority(.required - 1), + ]) + + primaryLabel.isUserInteractionEnabled = false + } + +} diff --git a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewController+DataSourceProvider.swift b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewController+DataSourceProvider.swift new file mode 100644 index 000000000..71ac81ef6 --- /dev/null +++ b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewController+DataSourceProvider.swift @@ -0,0 +1,77 @@ +// +// SearchResultViewController+DataSourceProvider.swift +// Mastodon +// +// Created by MainasuK on 2022-1-19. +// + +import UIKit + +// MARK: - DataSourceProvider +extension SearchResultViewController: DataSourceProvider { + func item(from source: DataSourceItem.Source) async -> DataSourceItem? { + var _indexPath = source.indexPath + if _indexPath == nil, let cell = source.tableViewCell { + _indexPath = await self.indexPath(for: cell) + } + guard let indexPath = _indexPath else { return nil } + + guard let item = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else { + return nil + } + + switch item { + case .user(let record): + return .user(record: record) + case .status(let record): + return .status(record: record) + case .hashtag(let entity): + return .hashtag(tag: .entity(entity)) + default: + return nil + } + } + + @MainActor + private func indexPath(for cell: UITableViewCell) async -> IndexPath? { + return tableView.indexPath(for: cell) + } +} + +extension SearchResultViewController { + func aspectTableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): indexPath: \(indexPath.debugDescription)") + Task { + let source = DataSourceItem.Source(tableViewCell: nil, indexPath: indexPath) + guard let item = await item(from: source) else { + return + } + + await DataSourceFacade.responseToCreateSearchHistory( + provider: self, + item: item + ) + + switch item { + case .status(let status): + await DataSourceFacade.coordinateToStatusThreadScene( + provider: self, + target: .status, // remove reblog wrapper + status: status + ) + case .user(let user): + await DataSourceFacade.coordinateToProfileScene( + provider: self, + user: user + ) + case .hashtag(let tag): + await DataSourceFacade.coordinateToHashtagScene( + provider: self, + tag: tag + ) + case .notification: + assertionFailure() + } // end switch + } // end Task + } // end func +} diff --git a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewController+StatusProvider.swift b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewController+StatusProvider.swift deleted file mode 100644 index 73e3ffb82..000000000 --- a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewController+StatusProvider.swift +++ /dev/null @@ -1,75 +0,0 @@ -// -// SearchResultViewController+StatusProvider.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-7-14. -// - -import os.log -import UIKit -import Combine -import CoreData -import CoreDataStack - -// MARK: - StatusProvider -extension SearchResultViewController: StatusProvider { - - func status() -> Future<Status?, Never> { - return Future { promise in promise(.success(nil)) } - } - - func status(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future<Status?, Never> { - return Future { promise in - 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 .status(let objectID, _): - let managedObjectContext = self.viewModel.statusFetchedResultsController.fetchedResultsController.managedObjectContext - managedObjectContext.perform { - let status = managedObjectContext.object(with: objectID) as? Status - promise(.success(status)) - } - default: - promise(.success(nil)) - } - } - } - - func status(for cell: UICollectionViewCell) -> Future<Status?, Never> { - return Future { promise in promise(.success(nil)) } - } - - var managedObjectContext: NSManagedObjectContext { - return self.viewModel.statusFetchedResultsController.fetchedResultsController.managedObjectContext - } - - var tableViewDiffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>? { - return nil - } - - func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item? { - return nil - } - - func items(indexPaths: [IndexPath]) -> [Item] { - return [] - } - - func statusObjectItems(indexPaths: [IndexPath]) -> [StatusObjectItem] { - guard let diffableDataSource = self.viewModel.diffableDataSource else { return [] } - let items = indexPaths.compactMap { diffableDataSource.itemIdentifier(for: $0)?.statusObjectItem } - return items - } - -} - -extension SearchResultViewController: UserProvider {} diff --git a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewController.swift b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewController.swift index 6c320af51..f3d989b41 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewController.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewController.swift @@ -5,12 +5,13 @@ // Created by MainasuK Cirno on 2021-7-14. // +import os.log import UIKit import Combine -import AVKit -import GameplayKit final class SearchResultViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { + + let logger = Logger(subsystem: "SearchResultViewController", category: "ViewController") weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } @@ -22,9 +23,6 @@ final class SearchResultViewController: UIViewController, NeedsDependency, Media let tableView: UITableView = { let tableView = UITableView() - tableView.register(SearchResultTableViewCell.self, forCellReuseIdentifier: String(describing: SearchResultTableViewCell.self)) - tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.self)) - tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) tableView.separatorStyle = .none tableView.tableFooterView = UIView() tableView.backgroundColor = .clear @@ -40,7 +38,7 @@ extension SearchResultViewController { setupBackgroundColor(theme: ThemeService.shared.currentTheme.value) ThemeService.shared.currentTheme - .receive(on: RunLoop.main) + .receive(on: DispatchQueue.main) .sink { [weak self] theme in guard let self = self else { return } self.setupBackgroundColor(theme: theme) @@ -57,12 +55,22 @@ extension SearchResultViewController { ]) tableView.delegate = self - tableView.prefetchDataSource = self +// tableView.prefetchDataSource = self viewModel.setupDiffableDataSource( tableView: tableView, - dependency: self, 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 } + guard self.view.window != nil else { return } + self.viewModel.stateMachine.enter(SearchResultViewModel.State.Loading.self) + } + .store(in: &disposeBag) // listen keyboard events and set content inset let keyboardEventPublishers = Publishers.CombineLatest3( @@ -100,7 +108,7 @@ extension SearchResultViewController { self.tableView.verticalScrollIndicatorInsets.bottom = padding - self.view.safeAreaInsets.bottom }) .store(in: &disposeBag) - +// // works for already onscreen page viewModel.navigationBarFrame .removeDuplicates() @@ -109,6 +117,7 @@ extension SearchResultViewController { guard let self = self else { return } guard self.viewModel.viewDidAppear.value else { return } self.tableView.contentInset.top = frame.height + self.tableView.verticalScrollIndicatorInsets.top = frame.height } .store(in: &disposeBag) } @@ -122,7 +131,7 @@ extension SearchResultViewController { tableView.contentOffset.y = -viewModel.navigationBarFrame.value.height } - aspectViewWillAppear(animated) + tableView.deselectRow(with: transitionCoordinator, animated: animated) } override func viewDidAppear(_ animated: Bool) { @@ -131,12 +140,6 @@ extension SearchResultViewController { viewModel.viewDidAppear.value = true } - override func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - - aspectViewDidDisappear(animated) - } - } extension SearchResultViewController { @@ -149,106 +152,110 @@ extension SearchResultViewController { } // MARK: - StatusTableViewCellDelegate -extension SearchResultViewController: StatusTableViewCellDelegate { - weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self } - func parent() -> UIViewController { return self } -} - -// MARK: - StatusTableViewControllerAspect -extension SearchResultViewController: StatusTableViewControllerAspect { } - -// MARK: - LoadMoreConfigurableTableViewContainer -extension SearchResultViewController: LoadMoreConfigurableTableViewContainer { - typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell - typealias LoadingState = SearchResultViewModel.State.Loading - var loadMoreConfigurableTableView: UITableView { tableView } - var loadMoreConfigurableStateMachine: GKStateMachine { viewModel.stateMachine } -} - -// MARK: - UIScrollViewDelegate -extension SearchResultViewController { - func scrollViewDidScroll(_ scrollView: UIScrollView) { - aspectScrollViewDidScroll(scrollView) - } -} - -// MARK: - TableViewCellHeightCacheableContainer -extension SearchResultViewController: TableViewCellHeightCacheableContainer { - var cellFrameCache: NSCache<NSNumber, NSValue> { - viewModel.cellFrameCache - } -} +//extension SearchResultViewController: StatusTableViewCellDelegate { +// weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self } +// func parent() -> UIViewController { return self } +//} // MARK: - UITableViewDelegate -extension SearchResultViewController: UITableViewDelegate { - func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { - aspectTableView(tableView, estimatedHeightForRowAt: indexPath) - } - - func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { - aspectTableView(tableView, willDisplay: cell, forRowAt: indexPath) - } - - func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { - aspectTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) - } +extension SearchResultViewController: UITableViewDelegate, AutoGenerateTableViewDelegate { + // sourcery:inline:SearchResultViewController.AutoGenerateTableViewDelegate + // Generated using Sourcery + // DO NOT EDIT func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - guard let diffableDataSource = viewModel.diffableDataSource else { return } - guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } - - viewModel.persistSearchHistory(for: item) - - switch item { - case .account(let account): - let profileViewModel = RemoteProfileViewModel(context: context, userID: account.id) - coordinator.present(scene: .profile(viewModel: profileViewModel), from: self, transition: .show) - case .hashtag(let hashtag): - let hashtagViewModel = HashtagTimelineViewModel(context: context, hashtag: hashtag.name) - coordinator.present(scene: .hashtagTimeline(viewModel: hashtagViewModel), from: self, transition: .show) - case .status: - aspectTableView(tableView, didSelectRowAt: indexPath) - case .bottomLoader: - break - } + aspectTableView(tableView, didSelectRowAt: indexPath) } func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { - aspectTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point) + return aspectTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point) } func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { - aspectTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration) + return aspectTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration) } func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { - aspectTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration) + return aspectTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration) } func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { aspectTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator) } + // sourcery:end + +// func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { +// aspectTableView(tableView, estimatedHeightForRowAt: indexPath) +// } +// +// func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { +// aspectTableView(tableView, willDisplay: cell, forRowAt: indexPath) +// } +// +// func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { +// aspectTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) +// } +// +// func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { +// guard let diffableDataSource = viewModel.diffableDataSource else { return } +// guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } +// +// viewModel.persistSearchHistory(for: item) +// +// switch item { +// case .account(let account): +// let profileViewModel = RemoteProfileViewModel(context: context, userID: account.id) +// coordinator.present(scene: .profile(viewModel: profileViewModel), from: self, transition: .show) +// case .hashtag(let hashtag): +// let hashtagViewModel = HashtagTimelineViewModel(context: context, hashtag: hashtag.name) +// coordinator.present(scene: .hashtagTimeline(viewModel: hashtagViewModel), from: self, transition: .show) +// case .status: +// aspectTableView(tableView, didSelectRowAt: indexPath) +// case .bottomLoader: +// break +// } +// } +// +// func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { +// aspectTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point) +// } +// +// func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { +// aspectTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration) +// } +// +// func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { +// aspectTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration) +// } +// +// func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { +// aspectTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator) +// } + } // MARK: - UITableViewDataSourcePrefetching -extension SearchResultViewController: UITableViewDataSourcePrefetching { - func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { - aspectTableView(tableView, cancelPrefetchingForRowsAt: indexPaths) - } - - func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) { - aspectTableView(tableView, cancelPrefetchingForRowsAt: indexPaths) - } -} +//extension SearchResultViewController: UITableViewDataSourcePrefetching { +// func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { +// aspectTableView(tableView, cancelPrefetchingForRowsAt: indexPaths) +// } +// +// func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) { +// aspectTableView(tableView, cancelPrefetchingForRowsAt: indexPaths) +// } +//} // MARK: - AVPlayerViewControllerDelegate -extension SearchResultViewController: AVPlayerViewControllerDelegate { - func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { - handlePlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator) - } +//extension SearchResultViewController: AVPlayerViewControllerDelegate { +// func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { +// handlePlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator) +// } +// +// func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { +// handlePlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator) +// } +//} - func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { - handlePlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator) - } -} +// MARK: - StatusTableViewCellDelegate +extension SearchResultViewController: StatusTableViewCellDelegate { } diff --git a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel+Diffable.swift b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel+Diffable.swift new file mode 100644 index 000000000..ff64b80f0 --- /dev/null +++ b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel+Diffable.swift @@ -0,0 +1,90 @@ +// +// SearchResultViewModel+Diffable.swift +// Mastodon +// +// Created by MainasuK on 2022-1-19. +// + +import UIKit +import Combine + +extension SearchResultViewModel { + + func setupDiffableDataSource( + tableView: UITableView, + statusTableViewCellDelegate: StatusTableViewCellDelegate + ) { + diffableDataSource = SearchResultSection.tableViewDiffableDataSource( + tableView: tableView, + context: context, + configuration: .init( + statusViewTableViewCellDelegate: statusTableViewCellDelegate + ) + ) + + var snapshot = NSDiffableDataSourceSnapshot<SearchResultSection, SearchResultItem>() + snapshot.appendSections([.main]) + // snapshot.appendItems(items.value, toSection: .main) // with initial items + diffableDataSource.apply(snapshot, animatingDifferences: false) + + Publishers.CombineLatest3( + statusFetchedResultsController.$records, + userFetchedResultsController.$records, + $hashtags + ) + .map { statusRecrods, userRecords, hashtags in + var items: [SearchResultItem] = [] + + let userItems = userRecords.map { SearchResultItem.user($0) } + items.append(contentsOf: userItems) + + let hashtagItems = hashtags.map { SearchResultItem.hashtag(tag: $0) } + items.append(contentsOf: hashtagItems) + + let statusItems = statusRecrods.map { SearchResultItem.status($0) } + items.append(contentsOf: statusItems) + + return items + } + .assign(to: &$items) + + $items + .receive(on: DispatchQueue.main) + .sink { [weak self] items in + guard let self = self else { return } + guard let diffableDataSource = self.diffableDataSource else { return } + + var snapshot = NSDiffableDataSourceSnapshot<SearchResultSection, SearchResultItem>() + snapshot.appendSections([.main]) + snapshot.appendItems(items, toSection: .main) + + if let currentState = self.stateMachine.currentState { + switch currentState { + case is State.Loading, + is State.Fail, + is State.Idle: + let attribute = SearchResultItem.BottomLoaderAttribute(isEmptyResult: false) + snapshot.appendItems([.bottomLoader(attribute: attribute)], toSection: .main) + case is State.Fail: + break + case is State.NoMore: + if snapshot.itemIdentifiers.isEmpty { + let attribute = SearchResultItem.BottomLoaderAttribute(isEmptyResult: true) + snapshot.appendItems([.bottomLoader(attribute: attribute)], toSection: .main) + } + default: + break + } + } + + diffableDataSource.defaultRowAnimation = .fade + diffableDataSource.apply(snapshot) { [weak self] in + guard let self = self else { return } + self.didDataSourceUpdate.send() + } + } + .store(in: &disposeBag) + } + + +} diff --git a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel+State.swift b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel+State.swift index dba71b50e..b763547bf 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel+State.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel+State.swift @@ -11,7 +11,15 @@ import GameplayKit import MastodonSDK extension SearchResultViewModel { - class State: GKState { + class State: GKState, NamingState { + + let logger = Logger(subsystem: "SearchResultViewModel.State", category: "StateMachine") + + let id = UUID() + + var name: String { + String(describing: Self.self) + } weak var viewModel: SearchResultViewModel? init(viewModel: SearchResultViewModel) { @@ -19,8 +27,18 @@ extension SearchResultViewModel { } override func didEnter(from previousState: GKState?) { - os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", (#file as NSString).lastPathComponent, #line, #function, debugDescription, previousState.debugDescription) -// viewModel?.loadOldestStateMachinePublisher.send(self) + super.didEnter(from: previousState) + let previousState = previousState as? SearchResultViewModel.State + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [\(self.id.uuidString)] enter \(self.name), previous: \(previousState?.name ?? "<nil>")") + } + + @MainActor + func enter(state: State.Type) { + stateMachine?.enter(state) + } + + deinit { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [\(self.id.uuidString)] \(self.name)") } } } @@ -34,7 +52,6 @@ extension SearchResultViewModel.State { } class Loading: SearchResultViewModel.State { - let logger = Logger(subsystem: "SearchResultViewModel.State.Loading", category: "Logic") var previousSearchText = "" var offset: Int? = nil @@ -55,22 +72,23 @@ extension SearchResultViewModel.State { override func didEnter(from previousState: GKState?) { super.didEnter(from: previousState) guard let viewModel = viewModel, let stateMachine = stateMachine else { return } - guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { + guard let authenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { assertionFailure() stateMachine.enter(Fail.self) return } - let domain = activeMastodonAuthenticationBox.domain - let searchText = viewModel.searchText.value let searchType = viewModel.searchScope.searchType if previousState is NoMore && previousSearchText == searchText { - // same searchText from NoMore. should silent refresh + // same searchText from NoMore + // break the loading and resume NoMore state + stateMachine.enter(NoMore.self) + return } else { // trigger bottom loader display - viewModel.items.value = viewModel.items.value +// viewModel.items.value = viewModel.items.value } guard !searchText.isEmpty else { @@ -82,7 +100,7 @@ extension SearchResultViewModel.State { previousSearchText = searchText offset = nil } else { - offset = viewModel.items.value.count + offset = viewModel.items.count } // not set offset for all case @@ -109,61 +127,54 @@ extension SearchResultViewModel.State { let id = UUID() latestLoadingToken = id + + Task { + do { + let response = try await viewModel.context.apiService.search( + query: query, + authenticationBox: authenticationBox + ) + + // discard result when search text is outdated + guard searchText == self.previousSearchText else { return } + // discard result when request not the latest one + guard id == self.latestLoadingToken else { return } + // discard result when state is not Loading + guard stateMachine.currentState is Loading else { return } - viewModel.context.apiService.search( - domain: domain, - query: query, - mastodonAuthenticationBox: activeMastodonAuthenticationBox - ) - .sink { [weak self] completion in - guard let self = self else { return } - switch completion { - case .failure(let error): - self.logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): search \(searchText) fail: \(error.localizedDescription)") - stateMachine.enter(Fail.self) - case .finished: - self.logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): search \(searchText) success") + let userIDs = response.value.accounts.map { $0.id } + let statusIDs = response.value.statuses.map { $0.id } + + let isNoMore = userIDs.isEmpty && statusIDs.isEmpty + + if viewModel.searchScope == .all || isNoMore { + await enter(state: NoMore.self) + } else { + await enter(state: Idle.self) + } + + // reset data source when the search is refresh + if offset == nil { + viewModel.userFetchedResultsController.userIDs = [] + viewModel.statusFetchedResultsController.statusIDs.value = [] + viewModel.hashtags = [] + } + + viewModel.userFetchedResultsController.append(userIDs: userIDs) + viewModel.statusFetchedResultsController.append(statusIDs: statusIDs) + + var hashtags = viewModel.hashtags + for hashtag in response.value.hashtags where !hashtags.contains(hashtag) { + hashtags.append(hashtag) + } + viewModel.hashtags = hashtags + + } catch { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): search \(searchText) fail: \(error.localizedDescription)") + await enter(state: Fail.self) } - } receiveValue: { [weak self] response in - guard let self = self else { return } - - // discard result when search text is outdated - guard searchText == self.previousSearchText else { return } - // discard result when request not the latest one - guard id == self.latestLoadingToken else { return } - // discard result when state is not Loading - guard stateMachine.currentState is Loading else { return } - - let oldItems = _offset == nil ? [] : viewModel.items.value - var newItems: [SearchResultItem] = [] - - for account in response.value.accounts { - let item = SearchResultItem.account(account: account) - guard !oldItems.contains(item) else { continue } - newItems.append(item) - } - for hashtag in response.value.hashtags { - let item = SearchResultItem.hashtag(tag: hashtag) - guard !oldItems.contains(item) else { continue } - newItems.append(item) - } - - var newStatusIDs = _offset == nil ? [] : viewModel.statusFetchedResultsController.statusIDs.value - for status in response.value.statuses { - guard !newStatusIDs.contains(status.id) else { continue } - newStatusIDs.append(status.id) - } - - if viewModel.searchScope == .all || newItems.isEmpty { - stateMachine.enter(NoMore.self) - } else { - stateMachine.enter(Idle.self) - } - viewModel.items.value = oldItems + newItems - viewModel.statusFetchedResultsController.statusIDs.value = newStatusIDs - } - .store(in: &viewModel.disposeBag) - } + } // end Task + } // end func } class Fail: SearchResultViewModel.State { diff --git a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel.swift b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel.swift index b22e91c8d..ad012518d 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel.swift @@ -11,6 +11,7 @@ import CoreData import CoreDataStack import GameplayKit import CommonOSLog +import MastodonSDK final class SearchResultViewModel { @@ -20,12 +21,19 @@ final class SearchResultViewModel { let context: AppContext let searchScope: SearchDetailViewModel.SearchScope let searchText = CurrentValueSubject<String, Never>("") + @Published var hashtags: [Mastodon.Entity.Tag] = [] + let userFetchedResultsController: UserFetchedResultsController let statusFetchedResultsController: StatusFetchedResultsController + let listBatchFetchViewModel = ListBatchFetchViewModel() + let viewDidAppear = CurrentValueSubject<Bool, Never>(false) var cellFrameCache = NSCache<NSNumber, NSValue>() var navigationBarFrame = CurrentValueSubject<CGRect, Never>(.zero) // output + var diffableDataSource: UITableViewDiffableDataSource<SearchResultSection, SearchResultItem>! + @Published var items: [SearchResultItem] = [] + private(set) lazy var stateMachine: GKStateMachine = { let stateMachine = GKStateMachine(states: [ State.Initial(viewModel: self), @@ -37,174 +45,164 @@ final class SearchResultViewModel { stateMachine.enter(State.Initial.self) return stateMachine }() - let items = CurrentValueSubject<[SearchResultItem], Never>([]) - var diffableDataSource: UITableViewDiffableDataSource<SearchResultSection, SearchResultItem>! let didDataSourceUpdate = PassthroughSubject<Void, Never>() init(context: AppContext, searchScope: SearchDetailViewModel.SearchScope) { self.context = context self.searchScope = searchScope + self.userFetchedResultsController = UserFetchedResultsController( + managedObjectContext: context.managedObjectContext, + domain: nil, + additionalPredicate: nil + ) self.statusFetchedResultsController = StatusFetchedResultsController( managedObjectContext: context.managedObjectContext, domain: nil, additionalTweetPredicate: nil ) + context.authenticationService.activeMastodonAuthenticationBox + .map { $0?.domain } + .assign(to: \.domain, on: userFetchedResultsController) + .store(in: &disposeBag) + context.authenticationService.activeMastodonAuthenticationBox .map { $0?.domain } .assign(to: \.value, on: statusFetchedResultsController.domain) .store(in: &disposeBag) - Publishers.CombineLatest( - items, - statusFetchedResultsController.objectIDs.removeDuplicates() - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] items, statusObjectIDs in - guard let self = self else { return } - guard let diffableDataSource = self.diffableDataSource else { return } - - var snapshot = NSDiffableDataSourceSnapshot<SearchResultSection, SearchResultItem>() - snapshot.appendSections([.main]) - - // append account & hashtag items - - var items = items - if self.searchScope == .all { - // all search scope not paging. it's safe sort on whole dataset - items.sort(by: { ($0.sortKey ?? "") < ($1.sortKey ?? "")}) - } - snapshot.appendItems(items, toSection: .main) - - var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:] - let oldSnapshot = diffableDataSource.snapshot() - for item in oldSnapshot.itemIdentifiers { - guard case let .status(objectID, attribute) = item else { continue } - oldSnapshotAttributeDict[objectID] = attribute - } - - // append statuses - var statusItems: [SearchResultItem] = [] - for objectID in statusObjectIDs { - let attribute = oldSnapshotAttributeDict[objectID] ?? Item.StatusAttribute() - statusItems.append(.status(statusObjectID: objectID, attribute: attribute)) - } - snapshot.appendItems(statusItems, toSection: .main) - - if let currentState = self.stateMachine.currentState { - switch currentState { - case is State.Loading, is State.Fail, is State.Idle: - let attribute = SearchResultItem.BottomLoaderAttribute(isEmptyResult: false) - snapshot.appendItems([.bottomLoader(attribute: attribute)], toSection: .main) - case is State.Fail: - break - case is State.NoMore: - if snapshot.itemIdentifiers.isEmpty { - let attribute = SearchResultItem.BottomLoaderAttribute(isEmptyResult: true) - snapshot.appendItems([.bottomLoader(attribute: attribute)], toSection: .main) - } - default: - break - } - } - - diffableDataSource.defaultRowAnimation = .fade - diffableDataSource.apply(snapshot, animatingDifferences: true) { [weak self] in - guard let self = self else { return } - self.didDataSourceUpdate.send() - } - - } - .store(in: &disposeBag) +// Publishers.CombineLatest( +// items, +// statusFetchedResultsController.objectIDs.removeDuplicates() +// ) +// .receive(on: DispatchQueue.main) +// .sink { [weak self] items, statusObjectIDs in +// guard let self = self else { return } +// guard let diffableDataSource = self.diffableDataSource else { return } +// +// var snapshot = NSDiffableDataSourceSnapshot<SearchResultSection, SearchResultItem>() +// snapshot.appendSections([.main]) +// +// // append account & hashtag items +// +// var items = items +// if self.searchScope == .all { +// // all search scope not paging. it's safe sort on whole dataset +// items.sort(by: { ($0.sortKey ?? "") < ($1.sortKey ?? "")}) +// } +// snapshot.appendItems(items, toSection: .main) +// +// var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:] +// let oldSnapshot = diffableDataSource.snapshot() +// for item in oldSnapshot.itemIdentifiers { +// guard case let .status(objectID, attribute) = item else { continue } +// oldSnapshotAttributeDict[objectID] = attribute +// } +// +// // append statuses +// var statusItems: [SearchResultItem] = [] +// for objectID in statusObjectIDs { +// let attribute = oldSnapshotAttributeDict[objectID] ?? Item.StatusAttribute() +// statusItems.append(.status(statusObjectID: objectID, attribute: attribute)) +// } +// snapshot.appendItems(statusItems, toSection: .main) +// +// if let currentState = self.stateMachine.currentState { +// switch currentState { +// case is State.Loading, is State.Fail, is State.Idle: +// let attribute = SearchResultItem.BottomLoaderAttribute(isEmptyResult: false) +// snapshot.appendItems([.bottomLoader(attribute: attribute)], toSection: .main) +// case is State.Fail: +// break +// case is State.NoMore: +// if snapshot.itemIdentifiers.isEmpty { +// let attribute = SearchResultItem.BottomLoaderAttribute(isEmptyResult: true) +// snapshot.appendItems([.bottomLoader(attribute: attribute)], toSection: .main) +// } +// default: +// break +// } +// } +// +// diffableDataSource.defaultRowAnimation = .fade +// diffableDataSource.apply(snapshot, animatingDifferences: true) { [weak self] in +// guard let self = self else { return } +// self.didDataSourceUpdate.send() +// } +// +// } +// .store(in: &disposeBag) } } -extension SearchResultViewModel { - func setupDiffableDataSource( - tableView: UITableView, - dependency: NeedsDependency, - statusTableViewCellDelegate: StatusTableViewCellDelegate - ) { - diffableDataSource = SearchResultSection.tableViewDiffableDataSource( - for: tableView, - dependency: dependency, - statusTableViewCellDelegate: statusTableViewCellDelegate - ) - - var snapshot = NSDiffableDataSourceSnapshot<SearchResultSection, SearchResultItem>() - snapshot.appendSections([.main]) - snapshot.appendItems(self.items.value, toSection: .main) // with initial items - diffableDataSource.apply(snapshot, animatingDifferences: false) - } -} - extension SearchResultViewModel { func persistSearchHistory(for item: SearchResultItem) { - guard let box = context.authenticationService.activeMastodonAuthenticationBox.value else { return } - let property = SearchHistory.Property(domain: box.domain, userID: box.userID) - let domain = box.domain - - switch item { - case .account(let entity): - let managedObjectContext = context.backgroundManagedObjectContext - managedObjectContext.performChanges { - let (user, _) = APIService.CoreData.createOrMergeMastodonUser( - into: managedObjectContext, - for: nil, - in: domain, - entity: entity, - userCache: nil, - networkDate: Date(), - log: OSLog.api - ) - 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 - switch result { - case .failure(let error): - assertionFailure(error.localizedDescription) - case .success: - break - } - } - .store(in: &context.disposeBag) - - case .hashtag(let entity): - let managedObjectContext = context.backgroundManagedObjectContext - var tag: Tag? - managedObjectContext.performChanges { - let (hashtag, _) = APIService.CoreData.createOrMergeTag( - into: managedObjectContext, - entity: entity - ) - 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) - } - } - .sink { result in - switch result { - case .failure(let error): - assertionFailure(error.localizedDescription) - case .success: - print(tag?.searchHistories) - break - } - } - .store(in: &context.disposeBag) - - case .status: - // FIXME: - break - case .bottomLoader: - break - } + fatalError() +// guard let box = context.authenticationService.activeMastodonAuthenticationBox.value else { return } +// let property = SearchHistory.Property(domain: box.domain, userID: box.userID) +// let domain = box.domain +// +// switch item { +// case .account(let entity): +// let managedObjectContext = context.backgroundManagedObjectContext +// managedObjectContext.performChanges { +// let (user, _) = APIService.CoreData.createOrMergeMastodonUser( +// into: managedObjectContext, +// for: nil, +// in: domain, +// entity: entity, +// userCache: nil, +// networkDate: Date(), +// log: OSLog.api +// ) +// 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 +// switch result { +// case .failure(let error): +// assertionFailure(error.localizedDescription) +// case .success: +// break +// } +// } +// .store(in: &context.disposeBag) +// +// case .hashtag(let entity): +// let managedObjectContext = context.backgroundManagedObjectContext +// var tag: Tag? +// managedObjectContext.performChanges { +// let (hashtag, _) = APIService.CoreData.createOrMergeTag( +// into: managedObjectContext, +// entity: entity +// ) +// 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) +// } +// } +// .sink { result in +// switch result { +// case .failure(let error): +// assertionFailure(error.localizedDescription) +// case .success: +// print(tag?.searchHistories) +// break +// } +// } +// .store(in: &context.disposeBag) +// +// case .status: +// // FIXME: +// break +// case .bottomLoader: +// break +// } } } diff --git a/Mastodon/Scene/Search/SearchDetail/TableViewCell/SearchResultTableViewCell.swift b/Mastodon/Scene/Search/SearchDetail/TableViewCell/SearchResultTableViewCell.swift deleted file mode 100644 index 0c919e7d5..000000000 --- a/Mastodon/Scene/Search/SearchDetail/TableViewCell/SearchResultTableViewCell.swift +++ /dev/null @@ -1,281 +0,0 @@ -// -// SearchResultTableViewCell.swift -// Mastodon -// -// Created by sxiaojian on 2021/4/2. -// - -import CoreData -import CoreDataStack -import Foundation -import MastodonSDK -import UIKit -import FLAnimatedImage -import MetaTextKit -import MastodonMeta - -final class SearchResultTableViewCell: UITableViewCell { - - let avatarImageView: AvatarImageView = { - let imageView = AvatarImageView() - imageView.tintColor = Asset.Colors.Label.primary.color - imageView.layer.cornerRadius = 4 - imageView.clipsToBounds = true - 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 = { - let label = UILabel() - label.textColor = Asset.Colors.Label.secondary.color - label.font = .preferredFont(forTextStyle: .body) - return label - }() - - let separatorLine = UIView.separatorLine - - var separatorLineToEdgeLeadingLayoutConstraint: NSLayoutConstraint! - var separatorLineToEdgeTrailingLayoutConstraint: NSLayoutConstraint! - - var separatorLineToMarginLeadingLayoutConstraint: NSLayoutConstraint! - var separatorLineToMarginTrailingLayoutConstraint: NSLayoutConstraint! - - override func prepareForReuse() { - super.prepareForReuse() - avatarImageView.af.cancelImageRequest() - setDisplayAvatarImage() - } - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - configure() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - configure() - } -} - -extension SearchResultTableViewCell { - private func configure() { - 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), - ]) - - 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() - textStackView.axis = .vertical - textStackView.distribution = .fill - textStackView.translatesAutoresizingMaskIntoConstraints = false - _titleLabel.translatesAutoresizingMaskIntoConstraints = false - textStackView.addArrangedSubview(_titleLabel) - _subTitleLabel.translatesAutoresizingMaskIntoConstraints = false - textStackView.addArrangedSubview(_subTitleLabel) - _subTitleLabel.setContentHuggingPriority(.defaultLow - 1, for: .vertical) - - containerStackView.addArrangedSubview(textStackView) - - separatorLine.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(separatorLine) - separatorLineToEdgeLeadingLayoutConstraint = separatorLine.leadingAnchor.constraint(equalTo: contentView.leadingAnchor) - separatorLineToEdgeTrailingLayoutConstraint = separatorLine.trailingAnchor.constraint(equalTo: contentView.trailingAnchor) - separatorLineToMarginLeadingLayoutConstraint = separatorLine.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor) - separatorLineToMarginTrailingLayoutConstraint = separatorLine.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor) - NSLayoutConstraint.activate([ - separatorLine.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - separatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView)), - ]) - resetSeparatorLineLayout() - - _titleLabel.isUserInteractionEnabled = false - _subTitleLabel.isUserInteractionEnabled = false - avatarImageView.isUserInteractionEnabled = false - - setDisplayAvatarImage() - } - - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - - resetSeparatorLineLayout() - } - -} - -extension SearchResultTableViewCell { - - private func resetSeparatorLineLayout() { - separatorLineToEdgeLeadingLayoutConstraint.isActive = false - separatorLineToEdgeTrailingLayoutConstraint.isActive = false - separatorLineToMarginLeadingLayoutConstraint.isActive = false - separatorLineToMarginTrailingLayoutConstraint.isActive = false - - if traitCollection.userInterfaceIdiom == .phone { - // to edge - NSLayoutConstraint.activate([ - separatorLineToEdgeLeadingLayoutConstraint, - separatorLineToEdgeTrailingLayoutConstraint, - ]) - } else { - if traitCollection.horizontalSizeClass == .compact { - // to edge - NSLayoutConstraint.activate([ - separatorLineToEdgeLeadingLayoutConstraint, - separatorLineToEdgeTrailingLayoutConstraint, - ]) - } else { - // to margin - NSLayoutConstraint.activate([ - separatorLineToMarginLeadingLayoutConstraint, - separatorLineToMarginTrailingLayoutConstraint, - ]) - } - } - } - -} - -extension SearchResultTableViewCell { - - func config(with account: Mastodon.Entity.Account) { - configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: account.avatarImageURL())) - let name = account.displayName.isEmpty ? account.username : account.displayName - do { - let mastodonContent = MastodonContent(content: name, emojis: account.emojiMeta) - let metaContent = try MastodonMetaContent.convert(document: mastodonContent) - _titleLabel.configure(content: metaContent) - } catch { - let metaContent = PlaintextMetaContent(string: name) - _titleLabel.configure(content: metaContent) - } - _subTitleLabel.text = "@" + account.acct - } - - func config(with account: MastodonUser) { - configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: account.avatarImageURL())) - do { - let mastodonContent = MastodonContent(content: account.displayNameWithFallback, emojis: account.emojiMeta) - let metaContent = try MastodonMetaContent.convert(document: mastodonContent) - _titleLabel.configure(content: metaContent) - } catch { - let metaContent = PlaintextMetaContent(string: account.displayNameWithFallback) - _titleLabel.configure(content: metaContent) - } - _subTitleLabel.text = "@" + account.acct - } - - func config(with tag: Mastodon.Entity.Tag) { - configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: nil)) - setDisplayHashtagImage() - let metaContent = PlaintextMetaContent(string: "#" + tag.name) - _titleLabel.configure(content: metaContent) - guard let histories = tag.history else { - _subTitleLabel.text = "" - return - } - let recentHistory = histories.prefix(2) - let peopleAreTalking = recentHistory.compactMap { Int($0.accounts) }.reduce(0, +) - let string = L10n.Scene.Search.Recommend.HashTag.peopleTalking(String(peopleAreTalking)) - _subTitleLabel.text = string - } - - func config(with tag: Tag) { - configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: nil)) - setDisplayHashtagImage() - let metaContent = PlaintextMetaContent(string: "#" + tag.name) - _titleLabel.configure(content: metaContent) - guard let histories = tag.histories?.sorted(by: { - $0.createAt.compare($1.createAt) == .orderedAscending - }) else { - _subTitleLabel.text = "" - return - } - let recentHistory = histories.prefix(2) - let peopleAreTalking = recentHistory.compactMap { Int($0.accounts) }.reduce(0, +) - let string = L10n.Scene.Search.Recommend.HashTag.peopleTalking(String(peopleAreTalking)) - _subTitleLabel.text = string - } -} - -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? { avatarImageView } -} - -#if canImport(SwiftUI) && DEBUG -import SwiftUI - -struct SearchResultTableViewCell_Previews: PreviewProvider { - static var controls: some View { - Group { - UIViewPreview { - let cell = SearchResultTableViewCell() - cell.backgroundColor = .white - cell.setDisplayHashtagImage() - cell._titleLabel.text = "Electronic Frontier Foundation" - cell._subTitleLabel.text = "@eff@mastodon.social" - return cell - } - .previewLayout(.fixed(width: 228, height: 130)) - } - } - - static var previews: some View { - Group { - controls.colorScheme(.light) - controls.colorScheme(.dark) - } - .background(Color.gray) - } -} - -#endif diff --git a/Mastodon/Scene/Settings/Cell/SettingsAppearanceTableViewCell+ViewModel.swift b/Mastodon/Scene/Settings/Cell/SettingsAppearanceTableViewCell+ViewModel.swift new file mode 100644 index 000000000..153ed8907 --- /dev/null +++ b/Mastodon/Scene/Settings/Cell/SettingsAppearanceTableViewCell+ViewModel.swift @@ -0,0 +1,72 @@ +// +// SettingsAppearanceTableViewCell+ViewModel.swift +// Mastodon +// +// Created by MainasuK on 2022-2-8. +// + +import UIKit +import Combine +import CoreDataStack + +extension SettingsAppearanceTableViewCell { + final class ViewModel: ObservableObject { + var disposeBag = Set<AnyCancellable>() + private var observations = Set<NSKeyValueObservation>() + + // input + @Published public var customUserInterfaceStyle: UIUserInterfaceStyle = .unspecified + @Published public var preferredTrueBlackDarkMode = false + // output + @Published public var appearanceMode: SettingsItem.AppearanceMode = .system + + init() { + UserDefaults.shared.observe(\.customUserInterfaceStyle, options: [.initial, .new]) { [weak self] defaults, _ in + guard let self = self else { return } + self.customUserInterfaceStyle = defaults.customUserInterfaceStyle + } + .store(in: &observations) + } + + public func prepareForReuse() { + // do nothing + } + } +} + +extension SettingsAppearanceTableViewCell.ViewModel { + func bind(cell: SettingsAppearanceTableViewCell) { + Publishers.CombineLatest( + $customUserInterfaceStyle.removeDuplicates(), + $preferredTrueBlackDarkMode.removeDuplicates() + ) + .debounce(for: 0.1, scheduler: DispatchQueue.main) + .receive(on: DispatchQueue.main) + .sink { customUserInterfaceStyle, preferredTrueBlackDarkMode in + cell.appearanceViews.forEach { view in + view.selected = false + } + + switch customUserInterfaceStyle { + case .unspecified: + cell.systemAppearanceView.selected = true + case .dark: + cell.darkAppearanceView.selected = true + case .light: + cell.lightAppearanceView.selected = true + @unknown default: + assertionFailure() + } + } + .store(in: &disposeBag) + } +} + +extension SettingsAppearanceTableViewCell { + func configure(setting: Setting) { + setting.publisher(for: \.preferredTrueBlackDarkMode) + .assign(to: \.preferredTrueBlackDarkMode, on: viewModel) + .store(in: &disposeBag) + } + +} diff --git a/Mastodon/Scene/Settings/Cell/SettingsAppearanceTableViewCell.swift b/Mastodon/Scene/Settings/Cell/SettingsAppearanceTableViewCell.swift new file mode 100644 index 000000000..3760fd8ed --- /dev/null +++ b/Mastodon/Scene/Settings/Cell/SettingsAppearanceTableViewCell.swift @@ -0,0 +1,140 @@ +// +// SettingsAppearanceTableViewCell.swift +// Mastodon +// +// Created by ihugo on 2021/4/8. +// + +import UIKit +import Combine +import MastodonAsset +import MastodonLocalization + +protocol SettingsAppearanceTableViewCellDelegate: AnyObject { + func settingsAppearanceTableViewCell(_ cell: SettingsAppearanceTableViewCell, didSelectAppearanceMode appearanceMode: SettingsItem.AppearanceMode) +} + +class SettingsAppearanceTableViewCell: UITableViewCell { + + var disposeBag = Set<AnyCancellable>() + var observations = Set<NSKeyValueObservation>() + + static let spacing: CGFloat = 28 + + weak var delegate: SettingsAppearanceTableViewCellDelegate? + + public private(set) lazy var viewModel: ViewModel = { + let viewModel = ViewModel() + viewModel.bind(cell: self) + return viewModel + }() + + lazy var stackView: UIStackView = { + let view = UIStackView() + view.axis = .horizontal + view.distribution = .fillEqually + view.spacing = SettingsAppearanceTableViewCell.spacing + return view + }() + + let systemAppearanceView = AppearanceView( + image: Asset.Settings.automatic.image, + title: L10n.Scene.Settings.Section.Appearance.automatic + ) + let darkAppearanceView = AppearanceView( + image: Asset.Settings.dark.image, + title: L10n.Scene.Settings.Section.Appearance.dark + ) + let lightAppearanceView = AppearanceView( + image: Asset.Settings.light.image, + title: L10n.Scene.Settings.Section.Appearance.light + ) + + var appearanceViews: [AppearanceView] { + return [ + systemAppearanceView, + darkAppearanceView, + lightAppearanceView, + ] + } + + override func prepareForReuse() { + super.prepareForReuse() + + disposeBag.removeAll() + observations.removeAll() + viewModel.prepareForReuse() + } + + // MARK: - Methods + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + + // remove separator line in section of group tableview + for subview in self.subviews { + if subview != self.contentView && subview.frame.width == self.frame.width { + subview.removeFromSuperview() + } + } + } + +} + +extension SettingsAppearanceTableViewCell { + + // MARK: Private methods + private func setupUI() { + backgroundColor = .clear + selectionStyle = .none + + stackView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(stackView) + NSLayoutConstraint.activate([ + stackView.topAnchor.constraint(equalTo: contentView.topAnchor), + stackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + stackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + stackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + ]) + + stackView.addArrangedSubview(systemAppearanceView) + stackView.addArrangedSubview(darkAppearanceView) + stackView.addArrangedSubview(lightAppearanceView) + + appearanceViews.forEach { view in + let tapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer + view.addGestureRecognizer(tapGestureRecognizer) + tapGestureRecognizer.addTarget(self, action: #selector(SettingsAppearanceTableViewCell.appearanceViewDidPressed(_:))) + } + } + +} + +// MARK: - Actions +extension SettingsAppearanceTableViewCell { + @objc func appearanceViewDidPressed(_ sender: UITapGestureRecognizer) { + let mode: SettingsItem.AppearanceMode + + switch sender.view { + case systemAppearanceView: + mode = .system + case darkAppearanceView: + mode = .dark + case lightAppearanceView: + mode = .light + default: + assertionFailure() + return + } + + delegate?.settingsAppearanceTableViewCell(self, didSelectAppearanceMode: mode) + } +} diff --git a/Mastodon/Scene/Settings/View/Cell/SettingsLinkTableViewCell.swift b/Mastodon/Scene/Settings/Cell/SettingsLinkTableViewCell.swift similarity index 100% rename from Mastodon/Scene/Settings/View/Cell/SettingsLinkTableViewCell.swift rename to Mastodon/Scene/Settings/Cell/SettingsLinkTableViewCell.swift diff --git a/Mastodon/Scene/Settings/View/Cell/SettingsToggleTableViewCell.swift b/Mastodon/Scene/Settings/Cell/SettingsToggleTableViewCell.swift similarity index 73% rename from Mastodon/Scene/Settings/View/Cell/SettingsToggleTableViewCell.swift rename to Mastodon/Scene/Settings/Cell/SettingsToggleTableViewCell.swift index 18c9e5150..4926dbfce 100644 --- a/Mastodon/Scene/Settings/View/Cell/SettingsToggleTableViewCell.swift +++ b/Mastodon/Scene/Settings/Cell/SettingsToggleTableViewCell.swift @@ -7,6 +7,8 @@ import UIKit import Combine +import MastodonAsset +import MastodonLocalization protocol SettingsToggleCellDelegate: AnyObject { func settingsToggleCell(_ cell: SettingsToggleTableViewCell, switchValueDidChange switch: UISwitch) @@ -46,8 +48,15 @@ class SettingsToggleTableViewCell: UITableViewCell { accessoryView = switchButton textLabel?.numberOfLines = 0 + updateAppearance() switchButton.addTarget(self, action: #selector(switchValueDidChange(sender:)), for: .valueChanged) } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + updateAppearance() + } } @@ -69,4 +78,16 @@ extension SettingsToggleTableViewCell { switchButton.isOn = enabled ?? false } + private func updateAppearance() { + switchButton.onTintColor = { + switch traitCollection.userInterfaceStyle { + case .dark: + // set default green for Dark Mode + return nil + default: + // set tint black for Light Mode + return self.contentView.window?.tintColor ?? nil + } + }() + } } diff --git a/Mastodon/Scene/Settings/SettingsViewController.swift b/Mastodon/Scene/Settings/SettingsViewController.swift index 04c343647..4cf20cd09 100644 --- a/Mastodon/Scene/Settings/SettingsViewController.swift +++ b/Mastodon/Scene/Settings/SettingsViewController.swift @@ -14,6 +14,8 @@ import MastodonSDK import MetaTextKit import MastodonMeta import AuthenticationServices +import MastodonAsset +import MastodonLocalization class SettingsViewController: UIViewController, NeedsDependency { @@ -98,15 +100,7 @@ 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 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) + let tableView = UITableView(frame: CGRect(x: 0, y: 0, width: 320, height: 320), style: .insetGrouped) tableView.translatesAutoresizingMaskIntoConstraints = false tableView.delegate = self tableView.rowHeight = UITableView.automaticDimension @@ -133,6 +127,15 @@ class SettingsViewController: UIViewController, NeedsDependency { return view }() + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension SettingsViewController { + override func viewDidLoad() { super.viewDidLoad() @@ -203,8 +206,7 @@ class SettingsViewController: UIViewController, NeedsDependency { } .store(in: &disposeBag) - - let footer = "Mastodon v\(UIApplication.appVersion()) (\(UIApplication.appBuild()))" + let footer = "Mastodon for iOS v\(UIApplication.appVersion()) (\(UIApplication.appBuild()))" let metaContent = PlaintextMetaContent(string: footer) tableFooterLabel.configure(content: metaContent) } @@ -212,7 +214,7 @@ class SettingsViewController: UIViewController, NeedsDependency { private func setupView() { setupBackgroundColor(theme: ThemeService.shared.currentTheme.value) ThemeService.shared.currentTheme - .receive(on: RunLoop.main) + .receive(on: DispatchQueue.main) .sink { [weak self] theme in guard let self = self else { return } self.setupBackgroundColor(theme: theme) @@ -285,35 +287,18 @@ class SettingsViewController: UIViewController, NeedsDependency { } func signOut() { - guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { + guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } // clear badge before sign-out context.notificationService.clearNotificationCountForActiveUser() - context.authenticationService.signOutMastodonUser( - domain: activeMastodonAuthenticationBox.domain, - userID: activeMastodonAuthenticationBox.userID - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] result in - guard let self = self else { return } - switch result { - case .failure(let error): - assertionFailure(error.localizedDescription) - case .success(let isSignOut): - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: sign out %s", ((#file as NSString).lastPathComponent), #line, #function, isSignOut ? "success" : "fail") - guard isSignOut else { return } - self.coordinator.setup() - self.coordinator.setupOnboardingIfNeeds(animated: true) - } + Task { @MainActor in + try await context.authenticationService.signOutMastodonUser(authenticationBox: authenticationBox) + self.coordinator.setup() + self.coordinator.setupOnboardingIfNeeds(animated: true) } - .store(in: &disposeBag) - } - - deinit { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function) } } @@ -325,7 +310,9 @@ extension SettingsViewController { } } +// MARK: - UITableViewDelegate extension SettingsViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { let sections = viewModel.dataSource.snapshot().sectionIdentifiers guard section < sections.count else { return nil } @@ -334,6 +321,10 @@ extension SettingsViewController: UITableViewDelegate { let header: SettingsSectionHeader switch sectionIdentifier { + case .appearancePreference: + return UIView() + case .preference: + return UIView() case .notifications: header = SettingsSectionHeader( frame: CGRect(x: 0, y: 0, width: 375, height: 66), @@ -366,6 +357,9 @@ extension SettingsViewController: UITableViewDelegate { case .appearance: // do nothing break + case .appearancePreference: + // do nothing + break case .notification: // do nothing break @@ -447,24 +441,30 @@ extension SettingsViewController { // MARK: - SettingsAppearanceTableViewCellDelegate extension SettingsViewController: SettingsAppearanceTableViewCellDelegate { - func settingsAppearanceCell(_ cell: SettingsAppearanceTableViewCell, didSelectAppearanceMode appearanceMode: SettingsItem.AppearanceMode) { + func settingsAppearanceTableViewCell( + _ cell: SettingsAppearanceTableViewCell, + didSelectAppearanceMode appearanceMode: SettingsItem.AppearanceMode + ) { guard let dataSource = viewModel.dataSource else { return } guard let indexPath = tableView.indexPath(for: cell) else { return } let item = dataSource.itemIdentifier(for: indexPath) - guard case .appearance = item else { return } - - switch appearanceMode { - case .automatic: - UserDefaults.shared.customUserInterfaceStyle = .unspecified - case .light: - UserDefaults.shared.customUserInterfaceStyle = .light - case .dark: - UserDefaults.shared.customUserInterfaceStyle = .dark - } + guard case let .appearance(record) = item else { return } - let feedbackGenerator = UIImpactFeedbackGenerator(style: .light) - feedbackGenerator.impactOccurred() + Task { @MainActor in + switch appearanceMode { + case .system: + UserDefaults.shared.customUserInterfaceStyle = .unspecified + case .dark: + UserDefaults.shared.customUserInterfaceStyle = .dark + case .light: + UserDefaults.shared.customUserInterfaceStyle = .light + } + + let feedbackGenerator = UIImpactFeedbackGenerator(style: .light) + feedbackGenerator.impactOccurred() + } // end Task } + } extension SettingsViewController: SettingsToggleCellDelegate { @@ -476,10 +476,10 @@ extension SettingsViewController: SettingsToggleCellDelegate { let item = dataSource.itemIdentifier(for: indexPath) switch item { - case .notification(let settingObjectID, let switchMode): + case .notification(let record, let switchMode): let managedObjectContext = context.backgroundManagedObjectContext managedObjectContext.performChanges { - let setting = managedObjectContext.object(with: settingObjectID) as! Setting + guard let setting = record.object(in: managedObjectContext) else { return } guard let subscription = setting.activeSubscription else { return } let alert = subscription.alert switch switchMode { @@ -495,13 +495,23 @@ extension SettingsViewController: SettingsToggleCellDelegate { // do nothing } .store(in: &disposeBag) - case .preference(let settingObjectID, let preferenceType): + case .appearancePreference(let record, let appearanceType): + switch appearanceType { + case .preferredTrueDarkMode: + Task { + let managedObjectContext = context.managedObjectContext + try await managedObjectContext.performChanges { + guard let setting = record.object(in: managedObjectContext) else { return } + setting.update(preferredTrueBlackDarkMode: isOn) + } + ThemeService.shared.set(themeName: isOn ? .system : .mastodon) + } // end Task + } + case .preference(let record, let preferenceType): let managedObjectContext = context.backgroundManagedObjectContext managedObjectContext.performChanges { - let setting = managedObjectContext.object(with: settingObjectID) as! Setting + guard let setting = record.object(in: managedObjectContext) else { return } switch preferenceType { - case .darkMode: - setting.update(preferredTrueBlackDarkMode: isOn) case .disableAvatarAnimation: setting.update(preferredStaticAvatar: isOn) case .disableEmojiAnimation: @@ -514,8 +524,6 @@ extension SettingsViewController: SettingsToggleCellDelegate { switch result { case .success: switch preferenceType { - case .darkMode: - ThemeService.shared.set(themeName: isOn ? .system : .mastodon) case .disableAvatarAnimation: UserDefaults.shared.preferredStaticAvatar = isOn case .disableEmojiAnimation: diff --git a/Mastodon/Scene/Settings/SettingsViewModel.swift b/Mastodon/Scene/Settings/SettingsViewModel.swift index 9158e8169..1eb9a4094 100644 --- a/Mastodon/Scene/Settings/SettingsViewModel.swift +++ b/Mastodon/Scene/Settings/SettingsViewModel.swift @@ -108,24 +108,30 @@ extension SettingsViewModel { var snapshot = NSDiffableDataSourceSnapshot<SettingsSection, SettingsItem>() // appearance - let appearanceItems = [SettingsItem.appearance(settingObjectID: setting.objectID)] + let appearanceItems = [ + SettingsItem.appearance(record: .init(objectID: setting.objectID)) + ] snapshot.appendSections([.appearance]) snapshot.appendItems(appearanceItems, toSection: .appearance) - - // notification - let notificationItems = SettingsItem.NotificationSwitchMode.allCases.map { mode in - SettingsItem.notification(settingObjectID: setting.objectID, switchMode: mode) - } - snapshot.appendSections([.notifications]) - snapshot.appendItems(notificationItems, toSection: .notifications) - + + // appearancePreference + snapshot.appendSections([.appearancePreference]) + snapshot.appendItems([SettingsItem.appearancePreference(record: .init(objectID: setting.objectID), appearanceType: .preferredTrueDarkMode)], toSection: .appearancePreference) + // preference snapshot.appendSections([.preference]) let preferenceItems: [SettingsItem] = SettingsItem.PreferenceType.allCases.map { preferenceType in - SettingsItem.preference(settingObjectID: setting.objectID, preferenceType: preferenceType) + SettingsItem.preference(settingRecord: .init(objectID: setting.objectID), preferenceType: preferenceType) } snapshot.appendItems(preferenceItems,toSection: .preference) + // notification + let notificationItems = SettingsItem.NotificationSwitchMode.allCases.map { mode in + SettingsItem.notification(settingRecord: .init(objectID: setting.objectID), switchMode: mode) + } + snapshot.appendSections([.notifications]) + snapshot.appendItems(notificationItems, toSection: .notifications) + // boring zone let boringZoneSettingsItems: [SettingsItem] = { let links: [SettingsItem.Link] = [ diff --git a/Mastodon/Scene/Settings/View/AppearanceView.swift b/Mastodon/Scene/Settings/View/AppearanceView.swift index fd08fd434..cdc29100b 100644 --- a/Mastodon/Scene/Settings/View/AppearanceView.swift +++ b/Mastodon/Scene/Settings/View/AppearanceView.swift @@ -6,17 +6,24 @@ // import UIKit +import MastodonAsset +import MastodonLocalization +import MastodonUI class AppearanceView: UIView { + + let imageViewShadowBackgroundContainer = ShadowBackgroundContainer() lazy var imageView: UIImageView = { let view = UIImageView() + view.contentMode = .scaleAspectFill view.layer.masksToBounds = true - view.layer.cornerRadius = 14 + view.layer.cornerRadius = 4 view.layer.cornerCurve = .continuous // accessibility view.accessibilityIgnoresInvertColors = true return view }() + lazy var titleLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 12, weight: .regular) @@ -24,35 +31,30 @@ class AppearanceView: UIView { label.textAlignment = .center return label }() - lazy var checkBox: UIButton = { + + lazy var checkmarkButton: UIButton = { let button = UIButton() button.isUserInteractionEnabled = false button.setImage(UIImage(systemName: "circle"), for: .normal) button.setImage(UIImage(systemName: "checkmark.circle.fill"), for: .selected) button.imageView?.preferredSymbolConfiguration = UIImage.SymbolConfiguration(textStyle: .body) - button.imageView?.tintColor = Asset.Colors.Label.secondary.color + button.imageView?.tintColor = Asset.Colors.Label.primary.color button.imageView?.contentMode = .scaleAspectFill return button }() + lazy var stackView: UIStackView = { let view = UIStackView() view.axis = .vertical - view.spacing = 10 + view.spacing = 8 view.distribution = .equalSpacing return view }() var selected: Bool = false { - didSet { - checkBox.isSelected = selected - if selected { - checkBox.imageView?.tintColor = Asset.Colors.brandBlue.color - } else { - checkBox.imageView?.tintColor = Asset.Colors.Label.secondary.color - } - } + didSet { setNeedsLayout() } } - + // MARK: - Methods init(image: UIImage?, title: String) { super.init(frame: .zero) @@ -68,23 +70,32 @@ class AppearanceView: UIView { } override var accessibilityLabel: String? { - get { - return [titleLabel.text, checkBox.accessibilityLabel] - .compactMap { $0 } - .joined(separator: ", ") - } + get { titleLabel.text } set { } } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + +} + +extension AppearanceView { - // MARK: - Private methods private func setupUI() { - stackView.addArrangedSubview(imageView) + imageView.translatesAutoresizingMaskIntoConstraints = false + imageViewShadowBackgroundContainer.addSubview(imageView) + NSLayoutConstraint.activate([ + imageView.topAnchor.constraint(equalTo: imageViewShadowBackgroundContainer.topAnchor), + imageView.leadingAnchor.constraint(equalTo: imageViewShadowBackgroundContainer.leadingAnchor), + imageView.trailingAnchor.constraint(equalTo: imageViewShadowBackgroundContainer.trailingAnchor), + imageView.bottomAnchor.constraint(equalTo: imageViewShadowBackgroundContainer.bottomAnchor), + ]) + imageViewShadowBackgroundContainer.cornerRadius = 4 + + stackView.addArrangedSubview(imageViewShadowBackgroundContainer) stackView.addArrangedSubview(titleLabel) - stackView.addArrangedSubview(checkBox) + stackView.addArrangedSubview(checkmarkButton) addSubview(stackView) translatesAutoresizingMaskIntoConstraints = false @@ -94,10 +105,35 @@ class AppearanceView: UIView { stackView.leadingAnchor.constraint(equalTo: self.leadingAnchor), stackView.bottomAnchor.constraint(equalTo: self.bottomAnchor), stackView.trailingAnchor.constraint(equalTo: self.trailingAnchor), - imageView.heightAnchor.constraint(equalTo: imageView.widthAnchor, multiplier: 218.0 / 100.0), + imageView.heightAnchor.constraint(equalTo: imageView.widthAnchor, multiplier: 121.0 / 100.0), // height / width ]) } + + private func configureForSelection() { + if selected { + accessibilityTraits.insert(.selected) + } else { + accessibilityTraits.remove(.selected) + } + + checkmarkButton.isSelected = selected + } + + override func layoutSubviews() { + super.layoutSubviews() + + configureForSelection() + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + setNeedsLayout() + } + +} +extension AppearanceView { override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) { super.touchesBegan(touches, with: event) self.alpha = 0.5 diff --git a/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift b/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift deleted file mode 100644 index a4904136b..000000000 --- a/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift +++ /dev/null @@ -1,171 +0,0 @@ -// -// SettingsAppearanceTableViewCell.swift -// Mastodon -// -// Created by ihugo on 2021/4/8. -// - -import UIKit -import Combine - -protocol SettingsAppearanceTableViewCellDelegate: AnyObject { - func settingsAppearanceCell(_ cell: SettingsAppearanceTableViewCell, didSelectAppearanceMode appearanceMode: SettingsItem.AppearanceMode) -} - -class SettingsAppearanceTableViewCell: UITableViewCell { - - var disposeBag = Set<AnyCancellable>() - var observations = Set<NSKeyValueObservation>() - - static let spacing: CGFloat = 18 - - weak var delegate: SettingsAppearanceTableViewCellDelegate? - var appearance: SettingsItem.AppearanceMode = .automatic - - lazy var stackView: UIStackView = { - let view = UIStackView() - view.axis = .horizontal - view.distribution = .fillEqually - view.spacing = SettingsAppearanceTableViewCell.spacing - view.translatesAutoresizingMaskIntoConstraints = false - return view - }() - - let automatic = AppearanceView(image: Asset.Settings.darkAuto.image, - title: L10n.Scene.Settings.Section.Appearance.automatic) - let light = AppearanceView(image: Asset.Settings.light.image, - title: L10n.Scene.Settings.Section.Appearance.light) - let dark = AppearanceView(image: Asset.Settings.dark.image, - title: L10n.Scene.Settings.Section.Appearance.dark) - - lazy var automaticTap: UITapGestureRecognizer = { - let tapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer - tapGestureRecognizer.addTarget(self, action: #selector(appearanceDidTap(sender:))) - return tapGestureRecognizer - }() - - lazy var lightTap: UITapGestureRecognizer = { - let tapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer - tapGestureRecognizer.addTarget(self, action: #selector(appearanceDidTap(sender:))) - return tapGestureRecognizer - }() - - lazy var darkTap: UITapGestureRecognizer = { - let tapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer - tapGestureRecognizer.addTarget(self, action: #selector(appearanceDidTap(sender:))) - return tapGestureRecognizer - }() - - override func prepareForReuse() { - super.prepareForReuse() - - disposeBag.removeAll() - observations.removeAll() - } - - // MARK: - Methods - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - setupUI() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func layoutSubviews() { - super.layoutSubviews() - - // remove separator line in section of group tableview - for subview in self.subviews { - if subview != self.contentView && subview.frame.width == self.frame.width { - subview.removeFromSuperview() - } - } - - setupAsset(theme: ThemeService.shared.currentTheme.value) - } - - func update(with data: SettingsItem.AppearanceMode) { - appearance = data - - automatic.selected = false - light.selected = false - dark.selected = false - - switch data { - case .automatic: - automatic.selected = true - case .light: - light.selected = true - case .dark: - dark.selected = true - } - } - - // MARK: Private methods - private func setupUI() { - backgroundColor = .clear - selectionStyle = .none - contentView.addSubview(stackView) - - stackView.addArrangedSubview(automatic) - stackView.addArrangedSubview(light) - stackView.addArrangedSubview(dark) - - automatic.addGestureRecognizer(automaticTap) - light.addGestureRecognizer(lightTap) - dark.addGestureRecognizer(darkTap) - - NSLayoutConstraint.activate([ - stackView.topAnchor.constraint(equalTo: contentView.topAnchor), - stackView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), - stackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - stackView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), - ]) - - setupAsset(theme: ThemeService.shared.currentTheme.value) - ThemeService.shared.currentTheme - .receive(on: DispatchQueue.main) - .sink { [weak self] theme in - guard let self = self else { return } - self.setupAsset(theme: theme) - } - .store(in: &disposeBag) - } - - private func setupAsset(theme: Theme) { - let aspectRatio = Asset.Settings.light.image.size - let width = floor(frame.width - 2 * SettingsAppearanceTableViewCell.spacing) / 3 - let height = width / aspectRatio.width * aspectRatio.height - let size = CGSize(width: width, height: height) - - light.imageView.image = Asset.Settings.light.image.af.imageAspectScaled(toFill: size, scale: UIScreen.main.scale) - switch theme.themeName { - case .mastodon: - automatic.imageView.image = Asset.Settings.darkAuto.image.af.imageAspectScaled(toFill: size, scale: UIScreen.main.scale) - dark.imageView.image = Asset.Settings.dark.image.af.imageAspectScaled(toFill: size, scale: UIScreen.main.scale) - case .system: - automatic.imageView.image = Asset.Settings.blackAuto.image.af.imageAspectScaled(toFill: size, scale: UIScreen.main.scale) - dark.imageView.image = Asset.Settings.black.image.af.imageAspectScaled(toFill: size, scale: UIScreen.main.scale) - } - } - - // MARK: - Actions - @objc func appearanceDidTap(sender: UIGestureRecognizer) { - if sender == automaticTap { - appearance = .automatic - } - - if sender == lightTap { - appearance = .light - } - - if sender == darkTap { - appearance = .dark - } - - guard let delegate = self.delegate else { return } - delegate.settingsAppearanceCell(self, didSelectAppearanceMode: appearance) - } -} diff --git a/Mastodon/Scene/Settings/View/SettingsSectionHeader.swift b/Mastodon/Scene/Settings/View/SettingsSectionHeader.swift index 0ce451019..817dbf371 100644 --- a/Mastodon/Scene/Settings/View/SettingsSectionHeader.swift +++ b/Mastodon/Scene/Settings/View/SettingsSectionHeader.swift @@ -6,6 +6,8 @@ // import UIKit +import MastodonAsset +import MastodonLocalization struct GroupedTableViewConstraints { static let topMargin: CGFloat = 40 diff --git a/Mastodon/Scene/Share/ContextMenu/ImagePreview/ContextMenuImagePreviewViewController.swift b/Mastodon/Scene/Share/ContextMenu/ImagePreview/ContextMenuImagePreviewViewController.swift index fb2d282af..0d3c4f574 100644 --- a/Mastodon/Scene/Share/ContextMenu/ImagePreview/ContextMenuImagePreviewViewController.swift +++ b/Mastodon/Scene/Share/ContextMenu/ImagePreview/ContextMenuImagePreviewViewController.swift @@ -43,19 +43,13 @@ extension ContextMenuImagePreviewViewController { let frame = AVMakeRect(aspectRatio: viewModel.aspectRatio, insideRect: view.bounds) preferredContentSize = frame.size - viewModel.url - .sink { [weak self] url in - guard let self = self else { return } - guard let url = url else { return } - self.imageView.af.setImage( - withURL: url, - placeholderImage: self.viewModel.thumbnail, - imageTransition: .crossDissolve(0.2), - runImageTransitionIfCached: true, - completion: nil - ) - } - .store(in: &disposeBag) + imageView.af.setImage( + withURL: viewModel.assetURL, + placeholderImage: viewModel.thumbnail, + imageTransition: .crossDissolve(0.2), + runImageTransitionIfCached: false, + completion: nil + ) } } diff --git a/Mastodon/Scene/Share/ContextMenu/ImagePreview/ContextMenuImagePreviewViewModel.swift b/Mastodon/Scene/Share/ContextMenu/ImagePreview/ContextMenuImagePreviewViewModel.swift index f56ff060c..1122ba33f 100644 --- a/Mastodon/Scene/Share/ContextMenu/ImagePreview/ContextMenuImagePreviewViewModel.swift +++ b/Mastodon/Scene/Share/ContextMenu/ImagePreview/ContextMenuImagePreviewViewModel.swift @@ -6,18 +6,20 @@ // import UIKit -import Combine final class ContextMenuImagePreviewViewModel { - - var disposeBag = Set<AnyCancellable>() - + // input - let aspectRatio: CGSize + let assetURL: URL let thumbnail: UIImage? - let url = CurrentValueSubject<URL?, Never>(nil) + let aspectRatio: CGSize - init(aspectRatio: CGSize, thumbnail: UIImage?) { + init( + assetURL: URL, + thumbnail: UIImage?, + aspectRatio: CGSize + ) { + self.assetURL = assetURL self.aspectRatio = aspectRatio self.thumbnail = thumbnail } diff --git a/Mastodon/Scene/Share/NavigationController/AdaptiveStatusBarStyleNavigationController.swift b/Mastodon/Scene/Share/NavigationController/AdaptiveStatusBarStyleNavigationController.swift index eb260853c..aac23285b 100644 --- a/Mastodon/Scene/Share/NavigationController/AdaptiveStatusBarStyleNavigationController.swift +++ b/Mastodon/Scene/Share/NavigationController/AdaptiveStatusBarStyleNavigationController.swift @@ -9,7 +9,7 @@ import UIKit // Make status bar style adaptive for child view controller // SeeAlso: `modalPresentationCapturesStatusBarAppearance` -final class AdaptiveStatusBarStyleNavigationController: UINavigationController { +class AdaptiveStatusBarStyleNavigationController: UINavigationController { override var childForStatusBarStyle: UIViewController? { visibleViewController } diff --git a/Mastodon/Scene/Share/View/Button/AvatarStackContainerButton.swift b/Mastodon/Scene/Share/View/Button/AvatarStackContainerButton.swift deleted file mode 100644 index 6c2d00e3c..000000000 --- a/Mastodon/Scene/Share/View/Button/AvatarStackContainerButton.swift +++ /dev/null @@ -1,177 +0,0 @@ -// -// AvatarStackContainerButton.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-3-10. -// - -import os.log -import UIKit -import FLAnimatedImage - -final class AvatarStackedImageView: AvatarImageView { } - -// MARK: - AvatarConfigurableView -extension AvatarStackedImageView: AvatarConfigurableView { - static var configurableAvatarImageSize: CGSize { CGSize(width: 28, height: 28) } - static var configurableAvatarImageCornerRadius: CGFloat { 4 } - var configurableAvatarImageView: FLAnimatedImageView? { self } -} - -final class AvatarStackContainerButton: UIControl { - - static let containerSize = CGSize(width: 42, height: 42) - static let avatarImageViewSize = CGSize(width: 28, height: 28) - static let avatarImageViewCornerRadius: CGFloat = 4 - static let maskOffset: CGFloat = 2 - - // UIControl.Event - Application: 0x0F000000 - static let primaryAction = UIControl.Event(rawValue: 1 << 25) // 0x01000000 - var primaryActionState: UIControl.State = .normal - - let topLeadingAvatarStackedImageView = AvatarStackedImageView() - let bottomTrailingAvatarStackedImageView = AvatarStackedImageView() - - override init(frame: CGRect) { - super.init(frame: frame) - _init() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - _init() - } - -} - -extension AvatarStackContainerButton { - - private func _init() { - topLeadingAvatarStackedImageView.translatesAutoresizingMaskIntoConstraints = false - addSubview(topLeadingAvatarStackedImageView) - NSLayoutConstraint.activate([ - topLeadingAvatarStackedImageView.topAnchor.constraint(equalTo: topAnchor), - topLeadingAvatarStackedImageView.leadingAnchor.constraint(equalTo: leadingAnchor), - topLeadingAvatarStackedImageView.widthAnchor.constraint(equalToConstant: AvatarStackedImageView.configurableAvatarImageSize.width).priority(.defaultHigh), - topLeadingAvatarStackedImageView.heightAnchor.constraint(equalToConstant: AvatarStackedImageView.configurableAvatarImageSize.height).priority(.defaultHigh), - ]) - - bottomTrailingAvatarStackedImageView.translatesAutoresizingMaskIntoConstraints = false - addSubview(bottomTrailingAvatarStackedImageView) - NSLayoutConstraint.activate([ - bottomTrailingAvatarStackedImageView.bottomAnchor.constraint(equalTo: bottomAnchor), - bottomTrailingAvatarStackedImageView.trailingAnchor.constraint(equalTo: trailingAnchor), - bottomTrailingAvatarStackedImageView.widthAnchor.constraint(equalToConstant: AvatarStackedImageView.configurableAvatarImageSize.width).priority(.defaultHigh), - bottomTrailingAvatarStackedImageView.heightAnchor.constraint(equalToConstant: AvatarStackedImageView.configurableAvatarImageSize.height).priority(.defaultHigh), - ]) - - // mask topLeadingAvatarStackedImageView - let offset: CGFloat = 2 - let path: CGPath = { - let path = CGMutablePath() - path.addRect(CGRect(origin: .zero, size: AvatarStackContainerButton.avatarImageViewSize)) - let mirrorScale: CGFloat = UIApplication.shared.userInterfaceLayoutDirection == .rightToLeft ? -1 : 1 - path.addPath(UIBezierPath( - roundedRect: CGRect( - x: mirrorScale * (AvatarStackContainerButton.containerSize.width - AvatarStackContainerButton.avatarImageViewSize.width - offset), - y: AvatarStackContainerButton.containerSize.height - AvatarStackContainerButton.avatarImageViewSize.height - offset, - width: AvatarStackContainerButton.avatarImageViewSize.width, - height: AvatarStackContainerButton.avatarImageViewSize.height - ), - cornerRadius: AvatarStackedImageView.configurableAvatarImageCornerRadius + 1 // 1pt overshoot - ).cgPath) - return path - }() - let maskShapeLayer = CAShapeLayer() - maskShapeLayer.backgroundColor = UIColor.black.cgColor - maskShapeLayer.fillRule = .evenOdd - maskShapeLayer.path = path - topLeadingAvatarStackedImageView.layer.mask = maskShapeLayer - } - - override var intrinsicContentSize: CGSize { - return AvatarStackContainerButton.containerSize - } - - override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { - defer { updateAppearance() } - - updateState(touch: touch, event: event) - return super.beginTracking(touch, with: event) - } - - override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { - defer { updateAppearance() } - - updateState(touch: touch, event: event) - return super.continueTracking(touch, with: event) - } - - override func endTracking(_ touch: UITouch?, with event: UIEvent?) { - defer { updateAppearance() } - resetState() - - if let touch = touch { - if AvatarStackContainerButton.isTouching(touch, view: self, event: event) { - sendActions(for: AvatarStackContainerButton.primaryAction) - } else { - // do nothing - } - } - - super.endTracking(touch, with: event) - } - - override func cancelTracking(with event: UIEvent?) { - defer { updateAppearance() } - - resetState() - super.cancelTracking(with: event) - } - -} - -extension AvatarStackContainerButton { - - private func updateAppearance() { - topLeadingAvatarStackedImageView.alpha = primaryActionState.contains(.highlighted) ? 0.6 : 1.0 - bottomTrailingAvatarStackedImageView.alpha = primaryActionState.contains(.highlighted) ? 0.6 : 1.0 - } - - private static func isTouching(_ touch: UITouch, view: UIView, event: UIEvent?) -> Bool { - let location = touch.location(in: view) - return view.point(inside: location, with: event) - } - - private func resetState() { - primaryActionState = .normal - } - - private func updateState(touch: UITouch, event: UIEvent?) { - primaryActionState = AvatarStackContainerButton.isTouching(touch, view: self, event: event) ? .highlighted : .normal - } - -} - -#if canImport(SwiftUI) && DEBUG -import SwiftUI - -struct AvatarStackContainerButton_Previews: PreviewProvider { - - static var previews: some View { - UIViewPreview(width: 42) { - let avatarStackContainerButton = AvatarStackContainerButton() - avatarStackContainerButton.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - avatarStackContainerButton.widthAnchor.constraint(equalToConstant: 42), - avatarStackContainerButton.heightAnchor.constraint(equalToConstant: 42), - ]) - return avatarStackContainerButton - } - .previewLayout(.fixed(width: 42, height: 42)) - } - -} - -#endif - diff --git a/Mastodon/Scene/Share/View/Button/HitTestExpandedButton.swift b/Mastodon/Scene/Share/View/Button/HitTestExpandedButton.swift deleted file mode 100644 index f56e7e7ee..000000000 --- a/Mastodon/Scene/Share/View/Button/HitTestExpandedButton.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// HitTestExpandedButton.swift -// Mastodon -// -// Created by sxiaojian on 2021/2/1. -// - -import UIKit - -final class HitTestExpandedButton: UIButton { - - var expandEdgeInsets = UIEdgeInsets(top: -10, left: -10, bottom: -10, right: -10) - - override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { - return bounds.inset(by: expandEdgeInsets).contains(point) - } - -} diff --git a/Mastodon/Scene/Share/View/Button/PrimaryActionButton.swift b/Mastodon/Scene/Share/View/Button/PrimaryActionButton.swift index 326dfa122..657573db8 100644 --- a/Mastodon/Scene/Share/View/Button/PrimaryActionButton.swift +++ b/Mastodon/Scene/Share/View/Button/PrimaryActionButton.swift @@ -6,11 +6,13 @@ // import UIKit +import MastodonAsset +import MastodonLocalization class PrimaryActionButton: UIButton { - var isLoading: Bool = false - + private var originalButtonTitle: String? + lazy var activityIndicator: UIActivityIndicatorView = { let indicator = UIActivityIndicatorView(style: .medium) indicator.color = .white @@ -18,10 +20,13 @@ class PrimaryActionButton: UIButton { indicator.translatesAutoresizingMaskIntoConstraints = false return indicator }() - - private var originalButtonTitle: String? - var adjustsBackgroundImageWhenUserInterfaceStyleChanges = true + var action: Action = .next { + didSet { + setupAppearance(action: action) + } + } + var isLoading: Bool = false override init(frame: CGRect) { super.init(frame: frame) @@ -35,26 +40,44 @@ class PrimaryActionButton: UIButton { } +extension PrimaryActionButton { + + public enum Action { + case back + case next + } + +} + extension PrimaryActionButton { private func _init() { titleLabel?.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold)) setTitleColor(.white, for: .normal) - setupBackgroundAppearance() + setupAppearance(action: action) applyCornerRadius(radius: 10) } - func setupBackgroundAppearance() { - setBackgroundImage(UIImage.placeholder(color: Asset.Colors.brandBlue.color), for: .normal) - setBackgroundImage(UIImage.placeholder(color: Asset.Colors.brandBlueDarken20.color), for: .highlighted) - setBackgroundImage(UIImage.placeholder(color: Asset.Colors.disabled.color), for: .disabled) + func setupAppearance(action: Action) { + switch action { + case .back: + setTitleColor(Asset.Colors.Label.primary.color, for: .normal) + setBackgroundImage(UIImage.placeholder(color: Asset.Scene.Onboarding.navigationBackButtonBackground.color), for: .normal) + setBackgroundImage(UIImage.placeholder(color: Asset.Scene.Onboarding.navigationBackButtonBackgroundHighlighted.color), for: .highlighted) + setBackgroundImage(UIImage.placeholder(color: Asset.Colors.disabled.color), for: .disabled) + case .next: + setTitleColor(Asset.Colors.Label.primaryReverse.color, for: .normal) + setBackgroundImage(UIImage.placeholder(color: Asset.Scene.Onboarding.navigationNextButtonBackground.color), for: .normal) + setBackgroundImage(UIImage.placeholder(color: Asset.Scene.Onboarding.navigationNextButtonBackgroundHighlighted.color), for: .highlighted) + setBackgroundImage(UIImage.placeholder(color: Asset.Colors.disabled.color), for: .disabled) + } } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) if adjustsBackgroundImageWhenUserInterfaceStyleChanges { - setupBackgroundAppearance() + setupAppearance(action: action) } } diff --git a/Mastodon/Scene/Share/View/Container/AudioContainerView.swift b/Mastodon/Scene/Share/View/Container/AudioContainerView.swift deleted file mode 100644 index 8516db569..000000000 --- a/Mastodon/Scene/Share/View/Container/AudioContainerView.swift +++ /dev/null @@ -1,129 +0,0 @@ -// -// AudioViewContainer.swift -// Mastodon -// -// Created by sxiaojian on 2021/3/8. -// - -import CoreDataStack -import os.log -import UIKit - -final class AudioContainerView: UIView { - static let cornerRadius: CGFloat = 22 - - let container: UIStackView = { - let stackView = UIStackView() - stackView.axis = .horizontal - stackView.distribution = .fill - stackView.alignment = .center - stackView.spacing = 11 - stackView.layoutMargins = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8) - stackView.isLayoutMarginsRelativeArrangement = true - stackView.layer.cornerRadius = AudioContainerView.cornerRadius - stackView.clipsToBounds = true - stackView.backgroundColor = Asset.Colors.brandBlue.color - stackView.translatesAutoresizingMaskIntoConstraints = false - return stackView - }() - - let playButtonBackgroundView: UIView = { - let view = UIView() - view.layer.cornerRadius = 16 - view.clipsToBounds = true - view.backgroundColor = Asset.Colors.brandBlue.color - view.translatesAutoresizingMaskIntoConstraints = false - return view - }() - - let playButton: UIButton = { - let button = HighlightDimmableButton(type: .custom) - let image = UIImage(systemName: "play.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 32, weight: .bold))! - button.setImage(image.withRenderingMode(.alwaysTemplate), for: .normal) - - let pauseImage = UIImage(systemName: "pause.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 32, weight: .bold))! - button.setImage(pauseImage.withRenderingMode(.alwaysTemplate), for: .selected) - - button.tintColor = .white - button.translatesAutoresizingMaskIntoConstraints = false - button.isEnabled = true - return button - }() - - let slider: UISlider = { - let slider = UISlider() - slider.isContinuous = true - slider.translatesAutoresizingMaskIntoConstraints = false - slider.minimumTrackTintColor = Asset.Colors.Slider.track.color - slider.maximumTrackTintColor = Asset.Colors.Slider.track.color - if let image = UIImage.placeholder(size: CGSize(width: 22, height: 22), color: .white).withRoundedCorners(radius: 11) { - slider.setThumbImage(image, for: .normal) - } - return slider - }() - - let timeLabel: UILabel = { - let label = UILabel() - label.translatesAutoresizingMaskIntoConstraints = false - label.font = .systemFont(ofSize: 13, weight: .regular) - label.textColor = .white - label.textAlignment = UIApplication.shared.userInterfaceLayoutDirection == .leftToRight ? .right : .left - return label - }() - - override init(frame: CGRect) { - super.init(frame: frame) - _init() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - _init() - } -} - -extension AudioContainerView { - private func _init() { - addSubview(container) - NSLayoutConstraint.activate([ - container.topAnchor.constraint(equalTo: topAnchor), - container.leadingAnchor.constraint(equalTo: leadingAnchor), - trailingAnchor.constraint(equalTo: container.trailingAnchor), - bottomAnchor.constraint(equalTo: container.bottomAnchor), - ]) - - // checkmark - playButtonBackgroundView.addSubview(playButton) - container.addArrangedSubview(playButtonBackgroundView) - NSLayoutConstraint.activate([ - playButton.centerXAnchor.constraint(equalTo: playButtonBackgroundView.centerXAnchor), - playButton.centerYAnchor.constraint(equalTo: playButtonBackgroundView.centerYAnchor), - playButtonBackgroundView.heightAnchor.constraint(equalToConstant: 32).priority(.required - 1), - playButtonBackgroundView.widthAnchor.constraint(equalToConstant: 32).priority(.required - 1), - ]) - - container.addArrangedSubview(slider) - - container.addArrangedSubview(timeLabel) - NSLayoutConstraint.activate([ - timeLabel.widthAnchor.constraint(equalToConstant: 40).priority(.required - 1), - ]) - } -} - -#if canImport(SwiftUI) && DEBUG -import SwiftUI - -struct AudioContainerView_Previews: PreviewProvider { - - static var previews: some View { - UIViewPreview(width: 375) { - AudioContainerView() - } - .previewLayout(.fixed(width: 375, height: 100)) - } - -} - -#endif - diff --git a/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift b/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift deleted file mode 100644 index a62230441..000000000 --- a/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift +++ /dev/null @@ -1,497 +0,0 @@ -// -// MosaicImageViewContainer.swift -// Mastodon -// -// Created by Cirno MainasuK on 2021-2-23. -// - -import os.log -import func AVFoundation.AVMakeRect -import UIKit - -protocol MosaicImageViewContainerPresentable: AnyObject { - var mosaicImageViewContainer: MosaicImageViewContainer { get } - var isRevealing: Bool { get } -} - -protocol MosaicImageViewContainerDelegate: AnyObject { - func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) - func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) -} - -final class MosaicImageViewContainer: UIView { - - weak var delegate: MosaicImageViewContainerDelegate? - - let container = UIStackView() - private(set) lazy var imageViews: [UIImageView] = { - (0..<4).map { _ -> UIImageView in - let imageView = UIImageView() - imageView.isUserInteractionEnabled = true - let tapGesture = UITapGestureRecognizer.singleTapGestureRecognizer - tapGesture.addTarget(self, action: #selector(MosaicImageViewContainer.photoTapGestureRecognizerHandler(_:))) - imageView.addGestureRecognizer(tapGesture) - imageView.isAccessibilityElement = true - imageView.backgroundColor = .systemFill - return imageView - } - }() - let blurhashOverlayImageViews: [UIImageView] = { - (0..<4).map { _ in UIImageView() } - }() - - let contentWarningOverlayView: ContentWarningOverlayView = { - let contentWarningOverlayView = ContentWarningOverlayView() - contentWarningOverlayView.configure(style: .media) - return contentWarningOverlayView - }() - - private var containerHeightLayoutConstraint: NSLayoutConstraint! - - override init(frame: CGRect) { - super.init(frame: frame) - _init() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - _init() - } - -} - -extension MosaicImageViewContainer: ContentWarningOverlayViewDelegate { - func contentWarningOverlayViewDidPressed(_ contentWarningOverlayView: ContentWarningOverlayView) { - self.delegate?.mosaicImageViewContainer(self, contentWarningOverlayViewDidPressed: contentWarningOverlayView) - } -} - -extension MosaicImageViewContainer { - - private func _init() { - // accessibility - accessibilityIgnoresInvertColors = true - - container.translatesAutoresizingMaskIntoConstraints = false - container.axis = .horizontal - container.distribution = .fillEqually - addSubview(container) - containerHeightLayoutConstraint = container.heightAnchor.constraint(equalToConstant: 162).priority(.required - 1) - NSLayoutConstraint.activate([ - container.topAnchor.constraint(equalTo: topAnchor), - container.leadingAnchor.constraint(equalTo: leadingAnchor), - trailingAnchor.constraint(equalTo: container.trailingAnchor), - bottomAnchor.constraint(equalTo: container.bottomAnchor), - containerHeightLayoutConstraint - ]) - - contentWarningOverlayView.delegate = self - } - -} - -extension MosaicImageViewContainer { - - func resetImageTask() { - imageViews.forEach { imageView in - imageView.af.cancelImageRequest() - imageView.image = nil - } - } - - func reset() { - resetImageTask() - - container.arrangedSubviews.forEach { subview in - container.removeArrangedSubview(subview) - subview.removeFromSuperview() - } - container.subviews.forEach { subview in - subview.removeFromSuperview() - } - imageViews.forEach { imageView in - imageView.constraints.forEach { imageView.removeConstraint($0) } - imageView.removeFromSuperview() - imageView.layer.maskedCorners = [ - .layerMinXMinYCorner, .layerMaxXMinYCorner, - .layerMinXMaxYCorner, .layerMaxXMaxYCorner - ] - imageView.image = nil - } - blurhashOverlayImageViews.forEach { imageView in - imageView.constraints.forEach { imageView.removeConstraint($0) } - imageView.removeFromSuperview() - imageView.layer.maskedCorners = [ - .layerMinXMinYCorner, .layerMaxXMinYCorner, - .layerMinXMaxYCorner, .layerMaxXMaxYCorner - ] - imageView.image = nil - } - - contentWarningOverlayView.removeFromSuperview() - contentWarningOverlayView.blurVisualEffectView.effect = ContentWarningOverlayView.blurVisualEffect - contentWarningOverlayView.vibrancyVisualEffectView.alpha = 1.0 - contentWarningOverlayView.isUserInteractionEnabled = true - - container.spacing = UIView.separatorLineHeight(of: self) * 2 // 2px - } - - struct ConfigurableMosaic { - let imageView: UIImageView - let blurhashOverlayImageView: UIImageView - let imageViewSize: CGSize - } - - func setupImageView(aspectRatio: CGSize, maxSize: CGSize) -> ConfigurableMosaic { - reset() - - let contentView = UIView() - contentView.translatesAutoresizingMaskIntoConstraints = false - container.addArrangedSubview(contentView) - - let imageViewSize: CGSize = { - let rect = AVMakeRect( - aspectRatio: aspectRatio, - insideRect: CGRect(origin: .zero, size: maxSize) - ).integral - return rect.size - }() - let imageViewFrame = CGRect(origin: .zero, size: imageViewSize) - - let imageView = imageViews[0] - imageView.layer.masksToBounds = true - imageView.layer.cornerRadius = ContentWarningOverlayView.cornerRadius - imageView.layer.cornerCurve = .continuous - imageView.contentMode = .scaleAspectFill - - imageView.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(imageView) - NSLayoutConstraint.activate([ - imageView.topAnchor.constraint(equalTo: contentView.topAnchor), - imageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - imageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - imageView.widthAnchor.constraint(equalToConstant: imageViewFrame.width).priority(.required - 1), - ]) - containerHeightLayoutConstraint.constant = imageViewFrame.height - containerHeightLayoutConstraint.isActive = true - - let blurhashOverlayImageView = blurhashOverlayImageViews[0] - blurhashOverlayImageView.layer.masksToBounds = true - blurhashOverlayImageView.layer.cornerRadius = ContentWarningOverlayView.cornerRadius - blurhashOverlayImageView.layer.cornerCurve = .continuous - blurhashOverlayImageView.contentMode = .scaleAspectFill - blurhashOverlayImageView.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(blurhashOverlayImageView) - NSLayoutConstraint.activate([ - blurhashOverlayImageView.topAnchor.constraint(equalTo: imageView.topAnchor), - blurhashOverlayImageView.leadingAnchor.constraint(equalTo: imageView.leadingAnchor), - blurhashOverlayImageView.trailingAnchor.constraint(equalTo: imageView.trailingAnchor), - blurhashOverlayImageView.bottomAnchor.constraint(equalTo: imageView.bottomAnchor), - ]) - - contentWarningOverlayView.translatesAutoresizingMaskIntoConstraints = false - addSubview(contentWarningOverlayView) - NSLayoutConstraint.activate([ - contentWarningOverlayView.topAnchor.constraint(equalTo: imageView.topAnchor), - contentWarningOverlayView.leadingAnchor.constraint(equalTo: imageView.leadingAnchor), - contentWarningOverlayView.trailingAnchor.constraint(equalTo: imageView.trailingAnchor), - contentWarningOverlayView.bottomAnchor.constraint(equalTo: imageView.bottomAnchor), - ]) - - return ConfigurableMosaic( - imageView: imageView, - blurhashOverlayImageView: blurhashOverlayImageView, - imageViewSize: imageViewSize - ) - } - - func setupImageViews(count: Int, maxSize: CGSize) -> [ConfigurableMosaic] { - reset() - let count = min(4, max(0, count)) - guard count > 1 else { - return [] - } - - let maxHeight = maxSize.height - let spacing: CGFloat = 1 - - containerHeightLayoutConstraint.constant = maxHeight - containerHeightLayoutConstraint.isActive = true - - let contentLeftStackView = UIStackView() - let contentRightStackView = UIStackView() - [contentLeftStackView, contentRightStackView].forEach { stackView in - stackView.axis = .vertical - stackView.distribution = .fillEqually - stackView.spacing = spacing - } - container.addArrangedSubview(contentLeftStackView) - container.addArrangedSubview(contentRightStackView) - - let imageViews: [UIImageView] = (0..<count).map { i in self.imageViews[i] } - let blurhashOverlayImageViews: [UIImageView] = (0..<count).map { i in self.blurhashOverlayImageViews[i] } - - imageViews.forEach { imageView in - imageView.layer.masksToBounds = true - imageView.layer.cornerRadius = ContentWarningOverlayView.cornerRadius - imageView.layer.cornerCurve = .continuous - imageView.contentMode = .scaleAspectFill - } - blurhashOverlayImageViews.forEach { imageView in - imageView.layer.masksToBounds = true - imageView.layer.cornerRadius = ContentWarningOverlayView.cornerRadius - imageView.layer.cornerCurve = .continuous - imageView.contentMode = .scaleAspectFill - } - if count == 2 { - contentLeftStackView.addArrangedSubview(imageViews[0]) - contentRightStackView.addArrangedSubview(imageViews[1]) - switch UIApplication.shared.userInterfaceLayoutDirection { - case .rightToLeft: - imageViews[1].layer.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner] - imageViews[0].layer.maskedCorners = [.layerMaxXMinYCorner, .layerMaxXMaxYCorner] - - blurhashOverlayImageViews[1].layer.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner] - blurhashOverlayImageViews[0].layer.maskedCorners = [.layerMaxXMinYCorner, .layerMaxXMaxYCorner] - - default: - imageViews[0].layer.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner] - imageViews[1].layer.maskedCorners = [.layerMaxXMinYCorner, .layerMaxXMaxYCorner] - - blurhashOverlayImageViews[0].layer.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner] - blurhashOverlayImageViews[1].layer.maskedCorners = [.layerMaxXMinYCorner, .layerMaxXMaxYCorner] - } - - } else if count == 3 { - contentLeftStackView.addArrangedSubview(imageViews[0]) - contentRightStackView.addArrangedSubview(imageViews[1]) - contentRightStackView.addArrangedSubview(imageViews[2]) - switch UIApplication.shared.userInterfaceLayoutDirection { - case .rightToLeft: - imageViews[0].layer.maskedCorners = [.layerMaxXMinYCorner, .layerMaxXMaxYCorner] - imageViews[1].layer.maskedCorners = [.layerMinXMinYCorner] - imageViews[2].layer.maskedCorners = [.layerMinXMaxYCorner] - - blurhashOverlayImageViews[0].layer.maskedCorners = [.layerMaxXMinYCorner, .layerMaxXMaxYCorner] - blurhashOverlayImageViews[1].layer.maskedCorners = [.layerMinXMinYCorner] - blurhashOverlayImageViews[2].layer.maskedCorners = [.layerMinXMaxYCorner] - default: - imageViews[0].layer.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner] - imageViews[1].layer.maskedCorners = [.layerMaxXMinYCorner] - imageViews[2].layer.maskedCorners = [.layerMaxXMaxYCorner] - - blurhashOverlayImageViews[0].layer.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner] - blurhashOverlayImageViews[1].layer.maskedCorners = [.layerMaxXMinYCorner] - blurhashOverlayImageViews[2].layer.maskedCorners = [.layerMaxXMaxYCorner] - } - } else if count == 4 { - contentLeftStackView.addArrangedSubview(imageViews[0]) - contentRightStackView.addArrangedSubview(imageViews[1]) - contentLeftStackView.addArrangedSubview(imageViews[2]) - contentRightStackView.addArrangedSubview(imageViews[3]) - switch UIApplication.shared.userInterfaceLayoutDirection { - case .rightToLeft: - imageViews[0].layer.maskedCorners = [.layerMaxXMinYCorner] - imageViews[1].layer.maskedCorners = [.layerMinXMinYCorner] - imageViews[2].layer.maskedCorners = [.layerMaxXMaxYCorner] - imageViews[3].layer.maskedCorners = [.layerMinXMaxYCorner] - - blurhashOverlayImageViews[0].layer.maskedCorners = [.layerMaxXMinYCorner] - blurhashOverlayImageViews[1].layer.maskedCorners = [.layerMinXMinYCorner] - blurhashOverlayImageViews[2].layer.maskedCorners = [.layerMaxXMaxYCorner] - blurhashOverlayImageViews[3].layer.maskedCorners = [.layerMinXMaxYCorner] - default: - imageViews[0].layer.maskedCorners = [.layerMinXMinYCorner] - imageViews[1].layer.maskedCorners = [.layerMaxXMinYCorner] - imageViews[2].layer.maskedCorners = [.layerMinXMaxYCorner] - imageViews[3].layer.maskedCorners = [.layerMaxXMaxYCorner] - - blurhashOverlayImageViews[0].layer.maskedCorners = [.layerMinXMinYCorner] - blurhashOverlayImageViews[1].layer.maskedCorners = [.layerMaxXMinYCorner] - blurhashOverlayImageViews[2].layer.maskedCorners = [.layerMinXMaxYCorner] - blurhashOverlayImageViews[3].layer.maskedCorners = [.layerMaxXMaxYCorner] - } - } - - for (imageView, blurhashOverlayImageView) in zip(imageViews, blurhashOverlayImageViews) { - blurhashOverlayImageView.translatesAutoresizingMaskIntoConstraints = false - container.addSubview(blurhashOverlayImageView) - NSLayoutConstraint.activate([ - blurhashOverlayImageView.topAnchor.constraint(equalTo: imageView.topAnchor), - blurhashOverlayImageView.leadingAnchor.constraint(equalTo: imageView.leadingAnchor), - blurhashOverlayImageView.trailingAnchor.constraint(equalTo: imageView.trailingAnchor), - blurhashOverlayImageView.bottomAnchor.constraint(equalTo: imageView.bottomAnchor), - ]) - } - - contentWarningOverlayView.translatesAutoresizingMaskIntoConstraints = false - addSubview(contentWarningOverlayView) - NSLayoutConstraint.activate([ - contentWarningOverlayView.topAnchor.constraint(equalTo: container.topAnchor), - contentWarningOverlayView.leadingAnchor.constraint(equalTo: container.leadingAnchor), - contentWarningOverlayView.trailingAnchor.constraint(equalTo: container.trailingAnchor), - contentWarningOverlayView.bottomAnchor.constraint(equalTo: container.bottomAnchor), - ]) - - var mosaics: [ConfigurableMosaic] = [] - for (i, (imageView, blurhashOverlayImageView)) in zip(imageViews, blurhashOverlayImageViews).enumerated() { - let imageViewSize: CGSize = { - switch (i, count) { - case (_, 4): - return CGSize(width: maxSize.width * 0.5 - spacing, height: maxSize.height * 0.5 - spacing) - case (i, 3): - let width = maxSize.width * 0.5 - spacing - if i == 0 { - return CGSize(width: width, height: maxSize.height) - } else { - return CGSize(width: width, height: maxSize.height * 0.5 - spacing) - } - case (_, 2): - let width = maxSize.width * 0.5 - spacing - return CGSize(width: width, height: maxSize.height) - default: - assertionFailure() - return maxSize - } - }() - imageView.frame.size = imageViewSize - let mosaic = ConfigurableMosaic( - imageView: imageView, - blurhashOverlayImageView: blurhashOverlayImageView, - imageViewSize: imageViewSize - ) - mosaics.append(mosaic) - } - return mosaics - } - -} - -// FIXME: refactor blurhash image and preview image -extension MosaicImageViewContainer { - - func setImageViews(alpha: CGFloat) { - // blurhashOverlayImageViews.forEach { $0.alpha = alpha } - imageViews.forEach { $0.alpha = alpha } - } - - func setImageView(alpha: CGFloat, index: Int) { - // if index < blurhashOverlayImageViews.count { - // blurhashOverlayImageViews[index].alpha = alpha - // } - if index < imageViews.count { - imageViews[index].alpha = alpha - } - } - - func thumbnail(at index: Int) -> UIImage? { - guard blurhashOverlayImageViews.count == imageViews.count else { return nil } - let tuples = Array(zip(blurhashOverlayImageViews, imageViews)) - guard index < tuples.count else { return nil } - let tuple = tuples[index] - return tuple.1.image ?? tuple.0.image - } - - func thumbnails() -> [UIImage?] { - guard blurhashOverlayImageViews.count == imageViews.count else { return [] } - let tuples = Array(zip(blurhashOverlayImageViews, imageViews)) - return tuples.map { blurhashOverlayImageView, imageView -> UIImage? in - return imageView.image ?? blurhashOverlayImageView.image - } - } - -} - -extension MosaicImageViewContainer { - - @objc private func visualEffectViewTapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - delegate?.mosaicImageViewContainer(self, contentWarningOverlayViewDidPressed: contentWarningOverlayView) - } - - @objc private func photoTapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) { - guard let imageView = sender.view as? UIImageView else { return } - guard let index = imageViews.firstIndex(of: imageView) else { return } - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: tap photo at index: %ld", ((#file as NSString).lastPathComponent), #line, #function, index) - delegate?.mosaicImageViewContainer(self, didTapImageView: imageView, atIndex: index) - } - -} - -#if DEBUG && canImport(SwiftUI) -import SwiftUI - -struct MosaicImageView_Previews: PreviewProvider { - - static var images: [UIImage] { - return ["bradley-dunn", "mrdongok", "lucas-ludwig", "markus-spiske"] - .map { UIImage(named: $0)! } - } - - static var previews: some View { - Group { - UIViewPreview(width: 375) { - let view = MosaicImageViewContainer() - let image = images[3] - let mosaic = view.setupImageView( - aspectRatio: image.size, - maxSize: CGSize(width: 375, height: 400) - ) - mosaic.imageView.image = image - return view - } - .previewLayout(.fixed(width: 375, height: 400)) - .previewDisplayName("Portrait - one image") - UIViewPreview(width: 375) { - let view = MosaicImageViewContainer() - let image = images[1] - let mosaic = view.setupImageView( - aspectRatio: image.size, - maxSize: CGSize(width: 375, height: 400) - ) - mosaic.imageView.layer.masksToBounds = true - mosaic.imageView.layer.cornerRadius = 8 - mosaic.imageView.contentMode = .scaleAspectFill - mosaic.imageView.image = image - return view - } - .previewLayout(.fixed(width: 375, height: 400)) - .previewDisplayName("Landscape - one image") - UIViewPreview(width: 375) { - let view = MosaicImageViewContainer() - let images = self.images.prefix(2) - let mosaics = view.setupImageViews(count: images.count, maxSize: CGSize(width: 375, height: 162)) - for (i, mosaic) in mosaics.enumerated() { - mosaic.imageView.image = images[i] - } - return view - } - .previewLayout(.fixed(width: 375, height: 200)) - .previewDisplayName("two image") - UIViewPreview(width: 375) { - let view = MosaicImageViewContainer() - let images = self.images.prefix(3) - let mosaics = view.setupImageViews(count: images.count, maxSize: CGSize(width: 375, height: 162)) - for (i, mosaic) in mosaics.enumerated() { - mosaic.imageView.image = images[i] - } - return view - } - .previewLayout(.fixed(width: 375, height: 200)) - .previewDisplayName("three image") - UIViewPreview(width: 375) { - let view = MosaicImageViewContainer() - let images = self.images.prefix(4) - let mosaics = view.setupImageViews(count: images.count, maxSize: CGSize(width: 375, height: 162)) - for (i, mosaic) in mosaics.enumerated() { - mosaic.imageView.image = images[i] - } - return view - } - .previewLayout(.fixed(width: 375, height: 200)) - .previewDisplayName("four image") - } - } - -} -#endif diff --git a/Mastodon/Scene/Share/View/Container/PlayerContainerView+MediaTypeIndicotorView.swift b/Mastodon/Scene/Share/View/Container/PlayerContainerView+MediaTypeIndicotorView.swift deleted file mode 100644 index 2c0298146..000000000 --- a/Mastodon/Scene/Share/View/Container/PlayerContainerView+MediaTypeIndicotorView.swift +++ /dev/null @@ -1,123 +0,0 @@ -// -// PlayerContainerView+MediaTypeIndicotorView.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-3-15. -// - -import UIKit - -extension PlayerContainerView { - - final class MediaTypeIndicatorView: UIView { - - static let indicatorViewSize = CGSize(width: 47, height: 25) - - let maskLayer = CAShapeLayer() - - let label: UILabel = { - let label = UILabel() - label.textColor = .white - label.textAlignment = .right - label.adjustsFontSizeToFitWidth = true - label.translatesAutoresizingMaskIntoConstraints = false - return label - }() - - override init(frame: CGRect) { - super.init(frame: frame) - _init() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - _init() - } - - override func layoutSubviews() { - super.layoutSubviews() - - let path = UIBezierPath() - path.move(to: CGPoint(x: bounds.width, y: bounds.height)) - path.addLine(to: CGPoint(x: bounds.width, y: 0)) - path.addLine(to: CGPoint(x: bounds.width * 0.5, y: 0)) - path.addCurve( - to: CGPoint(x: 0, y: bounds.height), - controlPoint1: CGPoint(x: bounds.width * 0.2, y: 0), - controlPoint2: CGPoint(x: 0, y: bounds.height * 0.3) - ) - path.close() - - maskLayer.frame = bounds - maskLayer.path = path.cgPath - layer.mask = maskLayer - - layer.cornerRadius = PlayerContainerView.cornerRadius - layer.maskedCorners = [.layerMaxXMaxYCorner] - layer.cornerCurve = .continuous - } - } - -} - -extension PlayerContainerView.MediaTypeIndicatorView { - - private func _init() { - backgroundColor = Asset.Colors.mediaTypeIndicotor.color - layoutMargins = UIEdgeInsets(top: 3, left: 13, bottom: 0, right: 6) - - addSubview(label) - NSLayoutConstraint.activate([ - label.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor), - label.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), - label.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), - label.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor), - ]) - } - - private static func roundedFont(weight: UIFont.Weight,fontSize: CGFloat) -> UIFont { - let systemFont = UIFont.systemFont(ofSize: fontSize, weight: weight) - guard let descriptor = systemFont.fontDescriptor.withDesign(.rounded) else { return systemFont } - let roundedFont = UIFont(descriptor: descriptor, size: fontSize) - return roundedFont - } - - func setMediaKind(kind: VideoPlayerViewModel.Kind) { - let fontSize: CGFloat = 18 - - switch kind { - case .gif: - label.font = PlayerContainerView.MediaTypeIndicatorView.roundedFont(weight: .heavy, fontSize: fontSize) - label.text = "GIF" - case .video: - label.text = " " - } - } - -} - -#if canImport(SwiftUI) && DEBUG -import SwiftUI - -struct PlayerContainerViewMediaTypeIndicatorView_Previews: PreviewProvider { - - static var previews: some View { - Group { - UIViewPreview(width: 47) { - let view = PlayerContainerView.MediaTypeIndicatorView() - view.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - view.heightAnchor.constraint(equalToConstant: 25), - view.widthAnchor.constraint(equalToConstant: 47), - ]) - view.setMediaKind(kind: .gif) - return view - } - .previewLayout(.fixed(width: 47, height: 25)) - } - } - -} - -#endif - diff --git a/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift b/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift deleted file mode 100644 index 2d398536f..000000000 --- a/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift +++ /dev/null @@ -1,179 +0,0 @@ -// -// PlayerContainerView.swift -// Mastodon -// -// Created by xiaojian sun on 2021/3/10. -// - -import os.log -import AVKit -import UIKit -import Combine - -protocol PlayerContainerViewDelegate: AnyObject { - func playerContainerView(_ playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) -} - -final class PlayerContainerView: UIView { - static let cornerRadius: CGFloat = ContentWarningOverlayView.cornerRadius - - private let container = UIView() - private let touchBlockingView = TouchBlockingView() - private var containerHeightLayoutConstraint: NSLayoutConstraint! - - let contentWarningOverlayView: ContentWarningOverlayView = { - let contentWarningOverlayView = ContentWarningOverlayView() - contentWarningOverlayView.update(cornerRadius: PlayerContainerView.cornerRadius) - return contentWarningOverlayView - }() - - let playerViewController = AVPlayerViewController() - - let blurhashOverlayImageView = UIImageView() - let mediaTypeIndicatorView = MediaTypeIndicatorView() - - weak var delegate: PlayerContainerViewDelegate? - - private var isReadyForDisplayObservation: NSKeyValueObservation? - let isReadyForDisplay = CurrentValueSubject<Bool, Never>(false) - - override init(frame: CGRect) { - super.init(frame: frame) - _init() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - _init() - } -} - -extension PlayerContainerView { - private func _init() { - // accessibility - accessibilityIgnoresInvertColors = true - - container.translatesAutoresizingMaskIntoConstraints = false - addSubview(container) - containerHeightLayoutConstraint = container.heightAnchor.constraint(equalToConstant: 162).priority(.required - 1) - NSLayoutConstraint.activate([ - container.topAnchor.constraint(equalTo: topAnchor), - container.leadingAnchor.constraint(equalTo: leadingAnchor), - trailingAnchor.constraint(equalTo: container.trailingAnchor), - bottomAnchor.constraint(equalTo: container.bottomAnchor), - containerHeightLayoutConstraint, - ]) - - // will not influence full-screen playback - playerViewController.view.layer.masksToBounds = true - playerViewController.view.layer.cornerRadius = PlayerContainerView.cornerRadius - playerViewController.view.layer.cornerCurve = .continuous - - blurhashOverlayImageView.translatesAutoresizingMaskIntoConstraints = false - playerViewController.contentOverlayView!.addSubview(blurhashOverlayImageView) - NSLayoutConstraint.activate([ - blurhashOverlayImageView.topAnchor.constraint(equalTo: playerViewController.contentOverlayView!.topAnchor), - blurhashOverlayImageView.leadingAnchor.constraint(equalTo: playerViewController.contentOverlayView!.leadingAnchor), - blurhashOverlayImageView.trailingAnchor.constraint(equalTo: playerViewController.contentOverlayView!.trailingAnchor), - blurhashOverlayImageView.bottomAnchor.constraint(equalTo: playerViewController.contentOverlayView!.bottomAnchor), - ]) - - // mediaType - mediaTypeIndicatorView.translatesAutoresizingMaskIntoConstraints = false - playerViewController.contentOverlayView!.addSubview(mediaTypeIndicatorView) - NSLayoutConstraint.activate([ - mediaTypeIndicatorView.bottomAnchor.constraint(equalTo: playerViewController.contentOverlayView!.bottomAnchor), - mediaTypeIndicatorView.rightAnchor.constraint(equalTo: playerViewController.contentOverlayView!.rightAnchor), - mediaTypeIndicatorView.heightAnchor.constraint(equalToConstant: MediaTypeIndicatorView.indicatorViewSize.height).priority(.required - 1), - mediaTypeIndicatorView.widthAnchor.constraint(equalToConstant: MediaTypeIndicatorView.indicatorViewSize.width).priority(.required - 1), - ]) - - isReadyForDisplayObservation = playerViewController.observe(\.isReadyForDisplay, options: [.initial, .new]) { [weak self] playerViewController, _ in - guard let self = self else { return } - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: isReadyForDisplay: %s", (#file as NSString).lastPathComponent, #line, #function, playerViewController.isReadyForDisplay.description) - self.isReadyForDisplay.value = playerViewController.isReadyForDisplay - } - - contentWarningOverlayView.delegate = self - } -} - -// MARK: - ContentWarningOverlayViewDelegate -extension PlayerContainerView: ContentWarningOverlayViewDelegate { - func contentWarningOverlayViewDidPressed(_ contentWarningOverlayView: ContentWarningOverlayView) { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - delegate?.playerContainerView(self, contentWarningOverlayViewDidPressed: contentWarningOverlayView) - } -} - -extension PlayerContainerView { - func reset() { - // note: set playerViewController.player pause() and nil in data source configuration process make reloadData not break playing - - playerViewController.willMove(toParent: nil) - playerViewController.view.removeFromSuperview() - playerViewController.removeFromParent() - - blurhashOverlayImageView.image = nil - - container.subviews.forEach { subview in - subview.removeFromSuperview() - } - } - - func setupPlayer(aspectRatio: CGSize, maxSize: CGSize, parent: UIViewController?) -> AVPlayerViewController { - reset() - - touchBlockingView.translatesAutoresizingMaskIntoConstraints = false - container.addSubview(touchBlockingView) - NSLayoutConstraint.activate([ - touchBlockingView.topAnchor.constraint(equalTo: container.topAnchor), - touchBlockingView.leadingAnchor.constraint(equalTo: container.leadingAnchor), - touchBlockingView.bottomAnchor.constraint(equalTo: container.bottomAnchor), - ]) - - let rect = AVMakeRect( - aspectRatio: aspectRatio, - insideRect: CGRect(origin: .zero, size: maxSize) - ).integral - - parent?.addChild(playerViewController) - playerViewController.view.translatesAutoresizingMaskIntoConstraints = false - touchBlockingView.addSubview(playerViewController.view) - parent.flatMap { playerViewController.didMove(toParent: $0) } - NSLayoutConstraint.activate([ - playerViewController.view.topAnchor.constraint(equalTo: touchBlockingView.topAnchor), - playerViewController.view.leadingAnchor.constraint(equalTo: touchBlockingView.leadingAnchor), - playerViewController.view.trailingAnchor.constraint(equalTo: touchBlockingView.trailingAnchor), - playerViewController.view.bottomAnchor.constraint(equalTo: touchBlockingView.bottomAnchor), - touchBlockingView.widthAnchor.constraint(equalToConstant: rect.width).priority(.required - 1), - ]) - containerHeightLayoutConstraint.constant = rect.height - containerHeightLayoutConstraint.isActive = true - - playerViewController.view.frame.size = rect.size - - contentWarningOverlayView.removeFromSuperview() - contentWarningOverlayView.translatesAutoresizingMaskIntoConstraints = false - addSubview(contentWarningOverlayView) - NSLayoutConstraint.activate([ - contentWarningOverlayView.topAnchor.constraint(equalTo: touchBlockingView.topAnchor), - contentWarningOverlayView.leadingAnchor.constraint(equalTo: touchBlockingView.leadingAnchor), - contentWarningOverlayView.trailingAnchor.constraint(equalTo: touchBlockingView.trailingAnchor), - contentWarningOverlayView.bottomAnchor.constraint(equalTo: touchBlockingView.bottomAnchor) - ]) - - bringSubviewToFront(mediaTypeIndicatorView) - - return playerViewController - } - - func setMediaKind(kind: VideoPlayerViewModel.Kind) { - mediaTypeIndicatorView.setMediaKind(kind: kind) - } - - func setMediaIndicator(isHidden: Bool) { - mediaTypeIndicatorView.alpha = isHidden ? 0 : 1 - } - -} diff --git a/Mastodon/Scene/Share/View/Content/ContentWarningOverlayView.swift b/Mastodon/Scene/Share/View/Content/ContentWarningOverlayView.swift index d5a457a26..78c5462f5 100644 --- a/Mastodon/Scene/Share/View/Content/ContentWarningOverlayView.swift +++ b/Mastodon/Scene/Share/View/Content/ContentWarningOverlayView.swift @@ -9,6 +9,8 @@ import os.log import Foundation import Combine import UIKit +import MastodonAsset +import MastodonLocalization protocol ContentWarningOverlayViewDelegate: AnyObject { func contentWarningOverlayViewDidPressed(_ contentWarningOverlayView: ContentWarningOverlayView) diff --git a/Mastodon/Scene/Share/View/Content/DoubleTitleLabelNavigationBarTitleView.swift b/Mastodon/Scene/Share/View/Content/DoubleTitleLabelNavigationBarTitleView.swift index d900307ab..b6a36f0e0 100644 --- a/Mastodon/Scene/Share/View/Content/DoubleTitleLabelNavigationBarTitleView.swift +++ b/Mastodon/Scene/Share/View/Content/DoubleTitleLabelNavigationBarTitleView.swift @@ -8,6 +8,8 @@ import UIKit import Meta import MetaTextKit +import MastodonAsset +import MastodonLocalization final class DoubleTitleLabelNavigationBarTitleView: UIView { diff --git a/Mastodon/Scene/Share/View/Content/MediaView+Configuration.swift b/Mastodon/Scene/Share/View/Content/MediaView+Configuration.swift new file mode 100644 index 000000000..ad2fa398d --- /dev/null +++ b/Mastodon/Scene/Share/View/Content/MediaView+Configuration.swift @@ -0,0 +1,94 @@ +// +// MediaView+Configuration.swift +// Mastodon +// +// Created by MainasuK on 2022-1-12. +// + +import UIKit +import Combine +import CoreDataStack +import MastodonUI +import AlamofireImage + +extension MediaView { + public static func configuration(status: Status) -> [MediaView.Configuration] { + func videoInfo(from attachment: MastodonAttachment) -> MediaView.Configuration.VideoInfo { + MediaView.Configuration.VideoInfo( + aspectRadio: attachment.size, + assetURL: attachment.assetURL, + previewURL: attachment.previewURL, + durationMS: attachment.durationMS + ) + } + + let status = status.reblog ?? status + let attachments = status.attachments + let configurations = attachments.map { attachment -> MediaView.Configuration in + let configuration: MediaView.Configuration = { + switch attachment.kind { + case .image: + let info = MediaView.Configuration.ImageInfo( + aspectRadio: attachment.size, + assetURL: attachment.assetURL + ) + return .init( + info: .image(info: info), + blurhash: attachment.blurhash + ) + case .video: + let info = videoInfo(from: attachment) + return .init( + info: .video(info: info), + blurhash: attachment.blurhash + ) + case .gifv: + let info = videoInfo(from: attachment) + return .init( + info: .gif(info: info), + blurhash: attachment.blurhash + ) + case .audio: + let info = videoInfo(from: attachment) + return .init( + info: .video(info: info), + blurhash: attachment.blurhash + ) + } // end switch + }() + + if let previewURL = configuration.previewURL, + let url = URL(string: previewURL) + { + let placeholder = UIImage.placeholder(color: .systemGray6) + let request = URLRequest(url: url) + ImageDownloader.default.download(request) { response in + switch response.result { + case .success(let image): + configuration.previewImage = image + case .failure(let error): + configuration.previewImage = placeholder + } + } + } + + if let assetURL = configuration.assetURL, + let blurhash = configuration.blurhash + { + AppContext.shared.blurhashImageCacheService.image( + blurhash: blurhash, + size: configuration.aspectRadio, + url: assetURL + ) + .assign(to: \.blurhashImage, on: configuration) + .store(in: &configuration.blurhashImageDisposeBag) + } + + configuration.isReveal = status.sensitive ? status.isMediaSensitiveToggled : true + + return configuration + } + + return configurations + } +} diff --git a/Mastodon/Scene/Share/View/Content/NavigationBarProgressView.swift b/Mastodon/Scene/Share/View/Content/NavigationBarProgressView.swift index 3cb1d1d9d..efa8b53a5 100644 --- a/Mastodon/Scene/Share/View/Content/NavigationBarProgressView.swift +++ b/Mastodon/Scene/Share/View/Content/NavigationBarProgressView.swift @@ -6,6 +6,8 @@ // import UIKit +import MastodonAsset +import MastodonLocalization class NavigationBarProgressView: UIView { diff --git a/Mastodon/Scene/Share/View/Content/NotificationView+Configuration.swift b/Mastodon/Scene/Share/View/Content/NotificationView+Configuration.swift new file mode 100644 index 000000000..052dc44c2 --- /dev/null +++ b/Mastodon/Scene/Share/View/Content/NotificationView+Configuration.swift @@ -0,0 +1,199 @@ +// +// NotificationView+Configuration.swift +// Mastodon +// +// Created by MainasuK on 2022-1-21. +// + +import UIKit +import Combine +import MastodonUI +import CoreDataStack +import MetaTextKit +import MastodonMeta +import Meta +import MastodonAsset +import MastodonLocalization +import class CoreDataStack.Notification + +extension NotificationView { + public func configure(feed: Feed) { + guard let notification = feed.notification else { + assertionFailure() + return + } + + configure(notification: notification) + } +} + +extension NotificationView { + public func configure(notification: Notification) { + configureAuthor(notification: notification) + + guard let type = MastodonNotificationType(rawValue: notification.typeRaw) else { + assertionFailure() + return + } + + if let status = notification.status { + switch type { + case .follow, .followRequest: + setAuthorContainerBottomPaddingViewDisplay() + case .mention, .status: + statusView.configure(status: status) + setStatusViewDisplay() + case .reblog, .favourite, .poll: + quoteStatusView.configure(status: status) + setQuoteStatusViewDisplay() + case ._other: + setAuthorContainerBottomPaddingViewDisplay() + assertionFailure() + } + } else { + setAuthorContainerBottomPaddingViewDisplay() + } + } +} + +extension NotificationView { + private func configureAuthor(notification: Notification) { + let author = notification.account + // author avatar + + Publishers.CombineLatest( + author.publisher(for: \.avatar), + UserDefaults.shared.publisher(for: \.preferredStaticAvatar) + ) + .map { _ in author.avatarImageURL() } + .assign(to: \.authorAvatarImageURL, on: viewModel) + .store(in: &disposeBag) + + // author name + Publishers.CombineLatest( + author.publisher(for: \.displayName), + author.publisher(for: \.emojis) + ) + .map { _, emojis in + do { + let content = MastodonContent(content: author.displayNameWithFallback, emojis: emojis.asDictionary) + let metaContent = try MastodonMetaContent.convert(document: content) + return metaContent + } catch { + assertionFailure(error.localizedDescription) + return PlaintextMetaContent(string: author.displayNameWithFallback) + } + } + .assign(to: \.authorName, on: viewModel) + .store(in: &disposeBag) + // author username + author.publisher(for: \.acct) + .map { $0 as String? } + .assign(to: \.authorUsername, on: viewModel) + .store(in: &disposeBag) + // timestamp + viewModel.timestamp = notification.createAt + // notification type indicator + Publishers.CombineLatest3( + notification.publisher(for: \.typeRaw), + author.publisher(for: \.displayName), + author.publisher(for: \.emojis) + ) + .sink { [weak self] typeRaw, _, emojis in + guard let self = self else { return } + guard let type = MastodonNotificationType(rawValue: typeRaw) else { + self.viewModel.notificationIndicatorText = nil + return + } + + func createMetaContent(text: String, emojis: MastodonContent.Emojis) -> MetaContent { + let content = MastodonContent(content: text, emojis: emojis) + guard let metaContent = try? MastodonMetaContent.convert(document: content) else { + return PlaintextMetaContent(string: text) + } + return metaContent + } + + // TODO: fix the i18n. The subject should assert place at the string beginning + switch type { + case .follow: + self.viewModel.notificationIndicatorText = createMetaContent( + text: L10n.Scene.Notification.NotificationDescription.followedYou, + emojis: emojis.asDictionary + ) + case .followRequest: + self.viewModel.notificationIndicatorText = createMetaContent( + text: L10n.Scene.Notification.NotificationDescription.requestToFollowYou, + emojis: emojis.asDictionary + ) + case .mention: + self.viewModel.notificationIndicatorText = createMetaContent( + text: L10n.Scene.Notification.NotificationDescription.mentionedYou, + emojis: emojis.asDictionary + ) + case .reblog: + self.viewModel.notificationIndicatorText = createMetaContent( + text: L10n.Scene.Notification.NotificationDescription.rebloggedYourPost, + emojis: emojis.asDictionary + ) + case .favourite: + self.viewModel.notificationIndicatorText = createMetaContent( + text: L10n.Scene.Notification.NotificationDescription.favoritedYourPost, + emojis: emojis.asDictionary + ) + case .poll: + self.viewModel.notificationIndicatorText = createMetaContent( + text: L10n.Scene.Notification.NotificationDescription.pollHasEnded, + emojis: emojis.asDictionary + ) + case .status: + self.viewModel.notificationIndicatorText = createMetaContent( + text: L10n.Scene.Notification.NotificationDescription.mentionedYou, + emojis: emojis.asDictionary + ) + case ._other: + self.viewModel.notificationIndicatorText = nil + } + } + .store(in: &disposeBag) + // isMuting + Publishers.CombineLatest( + viewModel.$userIdentifier, + author.publisher(for: \.mutingBy) + ) + .map { userIdentifier, mutingBy in + guard let userIdentifier = userIdentifier else { return false } + return mutingBy.contains(where: { + $0.id == userIdentifier.userID && $0.domain == userIdentifier.domain + }) + } + .assign(to: \.isMuting, on: viewModel) + .store(in: &disposeBag) + // isBlocking + Publishers.CombineLatest( + viewModel.$userIdentifier, + author.publisher(for: \.blockingBy) + ) + .map { userIdentifier, blockingBy in + guard let userIdentifier = userIdentifier else { return false } + return blockingBy.contains(where: { + $0.id == userIdentifier.userID && $0.domain == userIdentifier.domain + }) + } + .assign(to: \.isBlocking, on: viewModel) + .store(in: &disposeBag) + // isMyself + Publishers.CombineLatest3( + viewModel.$userIdentifier, + author.publisher(for: \.domain), + author.publisher(for: \.id) + ) + .map { userIdentifier, domain, id in + guard let userIdentifier = userIdentifier else { return false } + return userIdentifier.domain == domain + && userIdentifier.userID == id + } + .assign(to: \.isMyself, on: viewModel) + .store(in: &disposeBag) + } +} diff --git a/Mastodon/Scene/Share/View/Content/PollOptionView+Configuration.swift b/Mastodon/Scene/Share/View/Content/PollOptionView+Configuration.swift new file mode 100644 index 000000000..717c35f82 --- /dev/null +++ b/Mastodon/Scene/Share/View/Content/PollOptionView+Configuration.swift @@ -0,0 +1,108 @@ +// +// PollOptionView+Configuration.swift +// +// +// Created by MainasuK on 2022-1-12. +// + +import UIKit +import Combine +import CoreDataStack +import MetaTextKit +import MastodonUI + +extension PollOptionView { + public func configure(pollOption option: PollOption) { + viewModel.objects.insert(option) + + // background + ThemeService.shared.currentTheme + .receive(on: DispatchQueue.main) + .sink { [weak self] theme in + guard let self = self else { return } + self.viewModel.roundedBackgroundViewColor = theme.systemElevatedBackgroundColor + } + .store(in: &disposeBag) + // metaContent + option.publisher(for: \.title) + .map { title -> MetaContent? in + return PlaintextMetaContent(string: title) + } + .assign(to: \.metaContent, on: viewModel) + .store(in: &disposeBag) + // percentage + Publishers.CombineLatest( + option.poll.publisher(for: \.votesCount), + option.publisher(for: \.votesCount) + ) + .map { pollVotesCount, optionVotesCount -> Double? in + guard pollVotesCount > 0, optionVotesCount >= 0 else { return 0 } + return Double(optionVotesCount) / Double(pollVotesCount) + } + .assign(to: \.percentage, on: viewModel) + .store(in: &disposeBag) + // $isExpire + option.poll.publisher(for: \.expired) + .assign(to: \.isExpire, on: viewModel) + .store(in: &disposeBag) + // isMultiple + viewModel.isMultiple = option.poll.multiple + + let optionIndex = option.index + let authorDomain = option.poll.status.author.domain + let authorID = option.poll.status.author.id + // isSelect, isPollVoted, isMyPoll + Publishers.CombineLatest4( + option.publisher(for: \.poll), + option.publisher(for: \.votedBy), + option.publisher(for: \.isSelected), + viewModel.$userIdentifier + ) + .sink { [weak self] poll, optionVotedBy, isSelected, userIdentifier in + guard let self = self else { return } + + let domain = userIdentifier?.domain ?? "" + let userID = userIdentifier?.userID ?? "" + + let options = poll.options + let pollVoteBy = poll.votedBy ?? Set() + + let isMyPoll = authorDomain == domain + && authorID == userID + + let votedOptions = options.filter { option in + let votedBy = option.votedBy ?? Set() + return votedBy.contains(where: { $0.id == userID && $0.domain == domain }) + } + let isRemoteVotedOption = votedOptions.contains(where: { $0.index == optionIndex }) + let isRemoteVotedPoll = pollVoteBy.contains(where: { $0.id == userID && $0.domain == domain }) + + let isLocalVotedOption = isSelected + + let isSelect: Bool? = { + if isLocalVotedOption { + return true + } else if !votedOptions.isEmpty { + return isRemoteVotedOption ? true : false + } else if isRemoteVotedPoll, votedOptions.isEmpty { + // the poll voted. But server not mark voted options + return nil + } else { + return false + } + }() + self.viewModel.isSelect = isSelect + self.viewModel.isPollVoted = isRemoteVotedPoll + self.viewModel.isMyPoll = isMyPoll + } + .store(in: &disposeBag) + // appearance + ThemeService.shared.currentTheme + .receive(on: DispatchQueue.main) + .sink { [weak self] theme in + guard let self = self else { return } + self.checkmarkBackgroundView.backgroundColor = theme.tertiarySystemBackgroundColor + } + .store(in: &disposeBag) + } +} diff --git a/Mastodon/Scene/Share/View/Content/StatusView+Configuration.swift b/Mastodon/Scene/Share/View/Content/StatusView+Configuration.swift new file mode 100644 index 000000000..1a90c69af --- /dev/null +++ b/Mastodon/Scene/Share/View/Content/StatusView+Configuration.swift @@ -0,0 +1,460 @@ +// +// StatusView+Configuration.swift +// Mastodon +// +// Created by MainasuK on 2022-1-12. +// + +import UIKit +import Combine +import MastodonUI +import CoreDataStack +import MastodonSDK +import MastodonLocalization +import MastodonMeta +import Meta +import NaturalLanguage + +extension StatusView { + + static let statusFilterWorkingQueue = DispatchQueue(label: "StatusFilterWorkingQueue") + + public func configure(feed: Feed) { + switch feed.kind { + case .home: + guard let status = feed.status else { + assertionFailure() + return + } + configure(status: status) + case .notificationAll: + assertionFailure("TODO") + case .notificationMentions: + assertionFailure("TODO") + case .none: + break + } + + } +} + +extension StatusView { + public func configure(status: Status) { + viewModel.objects.insert(status) + if let reblog = status.reblog { + viewModel.objects.insert(reblog) + } + + configureHeader(status: status) + let author = (status.reblog ?? status).author + configureAuthor(author: author) + let timestamp = (status.reblog ?? status).publisher(for: \.createdAt) + configureTimestamp(timestamp: timestamp.eraseToAnyPublisher()) + configureContent(status: status) + configureMedia(status: status) + configurePoll(status: status) + configureToolbar(status: status) + configureFilter(status: status) + } +} + +extension StatusView { + private func configureHeader(status: Status) { + if let _ = status.reblog { + Publishers.CombineLatest( + status.author.publisher(for: \.displayName), + status.author.publisher(for: \.emojis) + ) + .map { name, emojis -> StatusView.ViewModel.Header in + let text = L10n.Common.Controls.Status.userReblogged(status.author.displayNameWithFallback) + let content = MastodonContent(content: text, emojis: emojis.asDictionary) + do { + let metaContent = try MastodonMetaContent.convert(document: content) + return .repost(info: .init(header: metaContent)) + } catch { + let metaContent = PlaintextMetaContent(string: name) + return .repost(info: .init(header: metaContent)) + } + + } + .assign(to: \.header, on: viewModel) + .store(in: &disposeBag) + } else if let _ = status.inReplyToID, + let inReplyToAccountID = status.inReplyToAccountID + { + func createHeader( + name: String?, + emojis: MastodonContent.Emojis? + ) -> ViewModel.Header { + let fallbackMetaContent = PlaintextMetaContent(string: L10n.Common.Controls.Status.userRepliedTo("-")) + let fallbackReplyHeader = ViewModel.Header.reply(info: .init(header: fallbackMetaContent)) + guard let name = name, + let emojis = emojis + else { + return fallbackReplyHeader + } + + let content = MastodonContent(content: L10n.Common.Controls.Status.userRepliedTo(name), emojis: emojis) + guard let metaContent = try? MastodonMetaContent.convert(document: content) else { + return fallbackReplyHeader + } + let header = ViewModel.Header.reply(info: .init(header: metaContent)) + return header + } + + if let replyTo = status.replyTo { + // A. replyTo status exist + let header = createHeader(name: replyTo.author.displayNameWithFallback, emojis: replyTo.author.emojis.asDictionary) + viewModel.header = header + } else { + // B. replyTo status not exist + + let request = MastodonUser.sortedFetchRequest + request.predicate = MastodonUser.predicate(domain: status.domain, id: inReplyToAccountID) + if let user = status.managedObjectContext?.safeFetch(request).first { + // B1. replyTo user exist + let header = createHeader(name: user.displayNameWithFallback, emojis: user.emojis.asDictionary) + viewModel.header = header + } else { + // B2. replyTo user not exist + let header = createHeader(name: nil, emojis: nil) + viewModel.header = header + + if let authenticationBox = AppContext.shared.authenticationService.activeMastodonAuthenticationBox.value { + Just(inReplyToAccountID) + .asyncMap { userID in + return try await AppContext.shared.apiService.accountInfo( + domain: authenticationBox.domain, + userID: userID, + authorization: authenticationBox.userAuthorization + ) + } + .sink { completion in + // do nothing + } receiveValue: { [weak self] response in + guard let self = self else { return } + let user = response.value + let header = createHeader(name: user.displayNameWithFallback, emojis: user.emojiMeta) + self.viewModel.header = header + } + .store(in: &disposeBag) + } // end if let + } // end else B2. + } // end else B. + + } else { + viewModel.header = .none + } + } + + public func configureAuthor(author: MastodonUser) { + // author avatar + Publishers.CombineLatest( + author.publisher(for: \.avatar), + UserDefaults.shared.publisher(for: \.preferredStaticAvatar) + ) + .map { _ in author.avatarImageURL() } + .assign(to: \.authorAvatarImageURL, on: viewModel) + .store(in: &disposeBag) + + // author name + Publishers.CombineLatest( + author.publisher(for: \.displayName), + author.publisher(for: \.emojis) + ) + .map { _, emojis in + do { + let content = MastodonContent(content: author.displayNameWithFallback, emojis: emojis.asDictionary) + let metaContent = try MastodonMetaContent.convert(document: content) + return metaContent + } catch { + assertionFailure(error.localizedDescription) + return PlaintextMetaContent(string: author.displayNameWithFallback) + } + } + .assign(to: \.authorName, on: viewModel) + .store(in: &disposeBag) + // author username + author.publisher(for: \.acct) + .map { $0 as String? } + .assign(to: \.authorUsername, on: viewModel) + .store(in: &disposeBag) + // locked + author.publisher(for: \.locked) + .assign(to: \.locked, on: viewModel) + .store(in: &disposeBag) + // isMuting + Publishers.CombineLatest( + viewModel.$userIdentifier, + author.publisher(for: \.mutingBy) + ) + .map { userIdentifier, mutingBy in + guard let userIdentifier = userIdentifier else { return false } + return mutingBy.contains(where: { + $0.id == userIdentifier.userID && $0.domain == userIdentifier.domain + }) + } + .assign(to: \.isMuting, on: viewModel) + .store(in: &disposeBag) + // isBlocking + Publishers.CombineLatest( + viewModel.$userIdentifier, + author.publisher(for: \.blockingBy) + ) + .map { userIdentifier, blockingBy in + guard let userIdentifier = userIdentifier else { return false } + return blockingBy.contains(where: { + $0.id == userIdentifier.userID && $0.domain == userIdentifier.domain + }) + } + .assign(to: \.isBlocking, on: viewModel) + .store(in: &disposeBag) + // isMyself + Publishers.CombineLatest3( + viewModel.$userIdentifier, + author.publisher(for: \.domain), + author.publisher(for: \.id) + ) + .map { userIdentifier, domain, id in + guard let userIdentifier = userIdentifier else { return false } + return userIdentifier.domain == domain + && userIdentifier.userID == id + } + .assign(to: \.isMyself, on: viewModel) + .store(in: &disposeBag) + } + + private func configureTimestamp(timestamp: AnyPublisher<Date, Never>) { + // timestamp + viewModel.timestampFormatter = { (date: Date) in + date.localizedSlowedTimeAgoSinceNow + } + timestamp + .map { $0 as Date? } + .assign(to: \.timestamp, on: viewModel) + .store(in: &disposeBag) + } + + private func configureContent(status: Status) { + let status = status.reblog ?? status + + // spoilerText + if let spoilerText = status.spoilerText, !spoilerText.isEmpty { + do { + let content = MastodonContent(content: spoilerText, emojis: status.emojis.asDictionary) + let metaContent = try MastodonMetaContent.convert(document: content) + viewModel.spoilerContent = metaContent + } catch { + assertionFailure(error.localizedDescription) + viewModel.spoilerContent = PlaintextMetaContent(string: "") + } + } else { + viewModel.spoilerContent = nil + } + // language + viewModel.language = (status.reblog ?? status).language + // content + do { + let content = MastodonContent(content: status.content, emojis: status.emojis.asDictionary) + let metaContent = try MastodonMetaContent.convert(document: content) + viewModel.content = metaContent + } catch { + assertionFailure(error.localizedDescription) + viewModel.content = PlaintextMetaContent(string: "") + } + // visibility + status.publisher(for: \.visibilityRaw) + .compactMap { MastodonVisibility(rawValue: $0) } + .assign(to: \.visibility, on: viewModel) + .store(in: &disposeBag) + // sensitive + status.publisher(for: \.isContentSensitiveToggled) + .assign(to: \.isContentSensitiveToggled, on: viewModel) + .store(in: &disposeBag) + + +// viewModel.source = status.source + } + + private func configureMedia(status: Status) { + let status = status.reblog ?? status + + viewModel.isMediaSensitive = status.sensitive && !status.attachments.isEmpty // some servers set media sensitive even empty attachments + + let configurations = MediaView.configuration(status: status) + viewModel.mediaViewConfigurations = configurations + + status.publisher(for: \.isMediaSensitiveToggled) + .assign(to: \.isMediaSensitiveToggled, on: viewModel) + .store(in: &disposeBag) + } + + private func configurePoll(status: Status) { + let status = status.reblog ?? status + + if let poll = status.poll { + viewModel.objects.insert(poll) + } + + // pollItems + status.publisher(for: \.poll) + .sink { [weak self] poll in + guard let self = self else { return } + guard let poll = poll else { + self.viewModel.pollItems = [] + return + } + + let options = poll.options.sorted(by: { $0.index < $1.index }) + let items: [PollItem] = options.map { .option(record: .init(objectID: $0.objectID)) } + self.viewModel.pollItems = items + } + .store(in: &disposeBag) + // isVoteButtonEnabled + status.poll?.publisher(for: \.updatedAt) + .sink { [weak self] _ in + guard let self = self else { return } + guard let poll = status.poll else { return } + let options = poll.options + let hasSelectedOption = options.contains(where: { $0.isSelected }) + self.viewModel.isVoteButtonEnabled = hasSelectedOption + } + .store(in: &disposeBag) + // isVotable + if let poll = status.poll { + Publishers.CombineLatest3( + poll.publisher(for: \.votedBy), + poll.publisher(for: \.expired), + viewModel.$userIdentifier + ) + .map { votedBy, expired, userIdentifier in + guard let userIdentifier = userIdentifier else { return false } + let domain = userIdentifier.domain + let userID = userIdentifier.userID + let isVoted = votedBy?.contains(where: { $0.domain == domain && $0.id == userID }) ?? false + return !isVoted && !expired + } + .assign(to: &viewModel.$isVotable) + } + // votesCount + status.poll?.publisher(for: \.votesCount) + .map { Int($0) } + .assign(to: \.voteCount, on: viewModel) + .store(in: &disposeBag) + // voterCount + status.poll?.publisher(for: \.votersCount) + .map { Int($0) } + .assign(to: \.voterCount, on: viewModel) + .store(in: &disposeBag) + // expireAt + status.poll?.publisher(for: \.expiresAt) + .assign(to: \.expireAt, on: viewModel) + .store(in: &disposeBag) + // expired + status.poll?.publisher(for: \.expired) + .assign(to: \.expired, on: viewModel) + .store(in: &disposeBag) + // isVoting + status.poll?.publisher(for: \.isVoting) + .assign(to: \.isVoting, on: viewModel) + .store(in: &disposeBag) + } + + private func configureToolbar(status: Status) { + let status = status.reblog ?? status + + status.publisher(for: \.repliesCount) + .map(Int.init) + .assign(to: \.replyCount, on: viewModel) + .store(in: &disposeBag) + status.publisher(for: \.reblogsCount) + .map(Int.init) + .assign(to: \.reblogCount, on: viewModel) + .store(in: &disposeBag) + status.publisher(for: \.favouritesCount) + .map(Int.init) + .assign(to: \.favoriteCount, on: viewModel) + .store(in: &disposeBag) + + // relationship + Publishers.CombineLatest( + viewModel.$userIdentifier, + status.publisher(for: \.rebloggedBy) + ) + .map { userIdentifier, rebloggedBy in + guard let userIdentifier = userIdentifier else { return false } + return rebloggedBy.contains(where: { + $0.id == userIdentifier.userID && $0.domain == userIdentifier.domain + }) + } + .assign(to: \.isReblog, on: viewModel) + .store(in: &disposeBag) + + Publishers.CombineLatest( + viewModel.$userIdentifier, + status.publisher(for: \.favouritedBy) + ) + .map { userIdentifier, favouritedBy in + guard let userIdentifier = userIdentifier else { return false } + return favouritedBy.contains(where: { + $0.id == userIdentifier.userID && $0.domain == userIdentifier.domain + }) + } + .assign(to: \.isFavorite, on: viewModel) + .store(in: &disposeBag) + } + + private func configureFilter(status: Status) { + let status = status.reblog ?? status + + let content = status.content.lowercased() + + Publishers.CombineLatest( + viewModel.$activeFilters, + viewModel.$filterContext + ) + .receive(on: StatusView.statusFilterWorkingQueue) + .map { filters, filterContext in + var wordFilters: [Mastodon.Entity.Filter] = [] + var nonWordFilters: [Mastodon.Entity.Filter] = [] + for filter in filters { + guard filter.context.contains(where: { $0 == filterContext }) else { continue } + if filter.wholeWord { + wordFilters.append(filter) + } else { + nonWordFilters.append(filter) + } + } + + var needsFilter = false + for filter in nonWordFilters { + guard content.contains(filter.phrase.lowercased()) else { continue } + needsFilter = true + break + } + + if needsFilter { + return true + } + + let tokenizer = NLTokenizer(unit: .word) + tokenizer.string = content + let phraseWords = wordFilters.map { $0.phrase.lowercased() } + tokenizer.enumerateTokens(in: content.startIndex..<content.endIndex) { range, _ in + let word = String(content[range]) + if phraseWords.contains(word) { + needsFilter = true + return false + } else { + return true + } + } + + return needsFilter + } + .receive(on: DispatchQueue.main) + .assign(to: \.isFiltered, on: viewModel) + .store(in: &disposeBag) + } + +} diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift deleted file mode 100644 index 62eb3d6b0..000000000 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ /dev/null @@ -1,725 +0,0 @@ -// -// StatusView.swift -// Mastodon -// -// Created by sxiaojian on 2021/1/28. -// - -import os.log -import UIKit -import Combine -import AVKit -import AlamofireImage -import FLAnimatedImage -import MetaTextKit -import Meta -import MastodonSDK - -// TODO: -// import LinkPresentation - -protocol StatusViewDelegate: AnyObject { - func statusView(_ statusView: StatusView, headerInfoLabelDidPressed label: UILabel) - func statusView(_ statusView: StatusView, avatarImageViewDidPressed imageView: UIImageView) - func statusView(_ statusView: StatusView, revealContentWarningButtonDidPressed button: UIButton) - func statusView(_ statusView: StatusView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) - func statusView(_ statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) - func statusView(_ statusView: StatusView, pollVoteButtonPressed button: UIButton) - func statusView(_ statusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta) -} - -final class StatusView: UIView { - - let logger = Logger(subsystem: "StatusView", category: "logic") - - var statusPollTableViewHeightObservation: NSKeyValueObservation? - var pollCountdownSubscription: AnyCancellable? - - static let avatarImageSize = CGSize(width: 42, height: 42) - static let avatarImageCornerRadius: CGFloat = 4 - static let avatarToLabelSpacing: CGFloat = 5 - static let contentWarningBlurRadius: CGFloat = 12 - static let containerStackViewSpacing: CGFloat = 10 - - weak var delegate: StatusViewDelegate? - - var pollTableViewDataSource: UITableViewDiffableDataSource<PollSection, PollItem>? - var pollTableViewHeightLayoutConstraint: NSLayoutConstraint! - - let containerStackView = UIStackView() - let headerContainerView = UIView() - let authorContainerView = UIView() - - static let reblogIconImage: UIImage = { - let font = UIFont.systemFont(ofSize: 13, weight: .medium) - let configuration = UIImage.SymbolConfiguration(font: font) - let image = UIImage(systemName: "arrow.2.squarepath", withConfiguration: configuration)!.withTintColor(Asset.Colors.Label.secondary.color) - return image - }() - - static let replyIconImage: UIImage = { - let font = UIFont.systemFont(ofSize: 13, weight: .medium) - let configuration = UIImage.SymbolConfiguration(font: font) - let image = UIImage(systemName: "arrowshape.turn.up.left.fill", withConfiguration: configuration)!.withTintColor(Asset.Colors.Label.secondary.color) - return image - }() - - static func iconAttributedString(image: UIImage) -> NSAttributedString { - let attributedString = NSMutableAttributedString() - let imageTextAttachment = NSTextAttachment() - let imageAttribute = NSAttributedString(attachment: imageTextAttachment) - imageTextAttachment.image = image - attributedString.append(imageAttribute) - return attributedString - } - - let headerIconLabel: MetaLabel = { - let label = MetaLabel(style: .statusHeader) - let attributedString = StatusView.iconAttributedString(image: StatusView.reblogIconImage) - label.configure(attributedString: attributedString) - return label - }() - - let headerInfoLabel = MetaLabel(style: .statusHeader) - - let avatarView: UIView = { - let view = UIView() - view.isAccessibilityElement = true - view.accessibilityTraits = .button - view.accessibilityLabel = L10n.Common.Controls.Status.showUserProfile - return view - }() - let avatarButton = AvatarButton() - let avatarStackedContainerButton: AvatarStackContainerButton = AvatarStackContainerButton() - - let nameMetaLabel: MetaLabel = { - let label = MetaLabel(style: .statusName) - return label - }() - - let nameTrialingDotLabel: UILabel = { - let label = UILabel() - label.textColor = Asset.Colors.Label.secondary.color - label.font = .systemFont(ofSize: 17) - label.text = "·" - label.isAccessibilityElement = false - return label - }() - - let usernameLabel: UILabel = { - let label = UILabel() - label.font = .systemFont(ofSize: 15, weight: .regular) - label.textColor = Asset.Colors.Label.secondary.color - label.text = "@alice" - label.isAccessibilityElement = false - return label - }() - - let dateLabel: UILabel = { - let label = UILabel() - label.font = .systemFont(ofSize: 13, weight: .regular) - label.textColor = Asset.Colors.Label.secondary.color - label.text = "1d" - return label - }() - - 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 - return button - }() - - let visibilityImageView: UIImageView = { - let imageView = UIImageView() - imageView.tintColor = Asset.Colors.Label.secondary.color - imageView.contentMode = .scaleAspectFit - return imageView - }() - - let statusContainerStackView = UIStackView() - let statusMosaicImageViewContainer = MosaicImageViewContainer() - - let pollTableView: PollTableView = { - let tableView = PollTableView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) - tableView.register(PollOptionTableViewCell.self, forCellReuseIdentifier: String(describing: PollOptionTableViewCell.self)) - tableView.rowHeight = PollOptionView.height - tableView.isScrollEnabled = false - tableView.separatorStyle = .none - tableView.backgroundColor = .clear - return tableView - }() - - let pollStatusStackView = UIStackView() - let pollVoteCountLabel: UILabel = { - let label = UILabel() - label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 12, weight: .regular)) - label.textColor = Asset.Colors.Label.secondary.color - label.text = L10n.Plural.Count.vote(0) - return label - }() - let pollStatusDotLabel: UILabel = { - let label = UILabel() - label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 12, weight: .regular)) - label.textColor = Asset.Colors.Label.secondary.color - label.text = " · " - return label - }() - let pollCountdownLabel: UILabel = { - let label = UILabel() - label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 12, weight: .regular)) - label.textColor = Asset.Colors.Label.secondary.color - label.text = "1 day left" - return label - }() - let pollVoteButton: UIButton = { - let button = HitTestExpandedButton() - button.titleLabel?.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 14, weight: .semibold)) - button.setTitle(L10n.Common.Controls.Status.Poll.vote, for: .normal) - button.setTitleColor(Asset.Colors.brandBlue.color, for: .normal) - button.setTitleColor(Asset.Colors.brandBlue.color.withAlphaComponent(0.8), for: .highlighted) - button.setTitleColor(Asset.Colors.Button.disabled.color, for: .disabled) - button.isEnabled = false - return button - }() - - // do not use visual effect view due to we blur text only without background - let contentWarningOverlayView: ContentWarningOverlayView = { - let contentWarningOverlayView = ContentWarningOverlayView() - contentWarningOverlayView.configure(style: .contentWarning) - contentWarningOverlayView.layer.masksToBounds = true - return contentWarningOverlayView - }() - - let playerContainerView = PlayerContainerView() - - let audioView: AudioContainerView = { - let audioView = AudioContainerView() - return audioView - }() - let actionToolbarContainer: ActionToolbarContainer = { - let actionToolbarContainer = ActionToolbarContainer() - actionToolbarContainer.configure(for: .inline) - return actionToolbarContainer - }() - - // set display when needs bottom padding - let actionToolbarPlaceholderPaddingView = UIView() - - let contentMetaText: MetaText = { - let metaText = MetaText() - metaText.textView.backgroundColor = .clear - metaText.textView.isEditable = false - metaText.textView.isSelectable = false - metaText.textView.isScrollEnabled = false - metaText.textView.textContainer.lineFragmentPadding = 0 - metaText.textView.textContainerInset = .zero - metaText.textView.layer.masksToBounds = false - metaText.textView.textDragInteraction?.isEnabled = false // disable drag for link and attachment - - metaText.paragraphStyle = { - let style = NSMutableParagraphStyle() - style.lineSpacing = 5 - style.paragraphSpacing = 8 - style.alignment = .natural - return style - }() - metaText.textAttributes = [ - .font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .regular)), - .foregroundColor: Asset.Colors.Label.primary.color, - ] - metaText.linkAttributes = [ - .font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold)), - .foregroundColor: Asset.Colors.brandBlue.color, - ] - return metaText - }() - - private let headerInfoLabelTapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer - - var isRevealing = true - - // TODO: - // let linkPreview = LPLinkView() - - override init(frame: CGRect) { - super.init(frame: frame) - _init() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - _init() - } - - deinit { - statusPollTableViewHeightObservation = nil - } - -} - -extension StatusView { - - func _init() { - // container: [reblog | author | status | action toolbar] - // note: do not set spacing for nested stackView to avoid SDK layout conflict issue - containerStackView.axis = .vertical - // containerStackView.spacing = 10 - containerStackView.translatesAutoresizingMaskIntoConstraints = false - addSubview(containerStackView) - NSLayoutConstraint.activate([ - containerStackView.topAnchor.constraint(equalTo: topAnchor), - containerStackView.leadingAnchor.constraint(equalTo: leadingAnchor), - trailingAnchor.constraint(equalTo: containerStackView.trailingAnchor), - bottomAnchor.constraint(equalTo: containerStackView.bottomAnchor), - ]) - containerStackView.setContentHuggingPriority(.required - 1, for: .vertical) - containerStackView.setContentCompressionResistancePriority(.required - 1, for: .vertical) - - // header container: [icon | info] - let headerContainerStackView = UIStackView() - headerContainerStackView.axis = .horizontal - headerContainerStackView.spacing = 4 - headerContainerStackView.addArrangedSubview(headerIconLabel) - headerContainerStackView.addArrangedSubview(headerInfoLabel) - headerIconLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) - - headerContainerStackView.translatesAutoresizingMaskIntoConstraints = false - headerContainerView.addSubview(headerContainerStackView) - NSLayoutConstraint.activate([ - headerContainerStackView.topAnchor.constraint(equalTo: headerContainerView.topAnchor), - headerContainerStackView.leadingAnchor.constraint(equalTo: headerContainerView.leadingAnchor), - headerContainerStackView.trailingAnchor.constraint(equalTo: headerContainerView.trailingAnchor), - headerContainerView.bottomAnchor.constraint(equalTo: headerContainerStackView.bottomAnchor, constant: StatusView.containerStackViewSpacing).priority(.defaultHigh), - ]) - headerContainerStackView.setContentCompressionResistancePriority(.required - 5, for: .vertical) - containerStackView.addArrangedSubview(headerContainerView) - defer { - containerStackView.bringSubviewToFront(headerContainerView) - } - - // author container: [avatar | author meta container | reveal button] - let authorContainerStackView = UIStackView() - authorContainerStackView.axis = .horizontal - authorContainerStackView.spacing = StatusView.avatarToLabelSpacing - authorContainerStackView.distribution = .fill - - // avatar - avatarView.translatesAutoresizingMaskIntoConstraints = false - authorContainerStackView.addArrangedSubview(avatarView) - NSLayoutConstraint.activate([ - avatarView.widthAnchor.constraint(equalToConstant: StatusView.avatarImageSize.width).priority(.required - 1), - avatarView.heightAnchor.constraint(equalToConstant: StatusView.avatarImageSize.height).priority(.required - 1), - ]) - avatarButton.translatesAutoresizingMaskIntoConstraints = false - avatarView.addSubview(avatarButton) - NSLayoutConstraint.activate([ - avatarButton.topAnchor.constraint(equalTo: avatarView.topAnchor), - avatarButton.leadingAnchor.constraint(equalTo: avatarView.leadingAnchor), - avatarButton.trailingAnchor.constraint(equalTo: avatarView.trailingAnchor), - avatarButton.bottomAnchor.constraint(equalTo: avatarView.bottomAnchor), - ]) - avatarStackedContainerButton.translatesAutoresizingMaskIntoConstraints = false - avatarView.addSubview(avatarStackedContainerButton) - NSLayoutConstraint.activate([ - avatarStackedContainerButton.topAnchor.constraint(equalTo: avatarView.topAnchor), - avatarStackedContainerButton.leadingAnchor.constraint(equalTo: avatarView.leadingAnchor), - avatarStackedContainerButton.trailingAnchor.constraint(equalTo: avatarView.trailingAnchor), - avatarStackedContainerButton.bottomAnchor.constraint(equalTo: avatarView.bottomAnchor), - ]) - - // author meta container: [title container | subtitle container] - let authorMetaContainerStackView = UIStackView() - authorContainerStackView.addArrangedSubview(authorMetaContainerStackView) - authorMetaContainerStackView.axis = .vertical - authorMetaContainerStackView.spacing = 4 - - // title container: [display name | "·" | date | padding | visibility] - let titleContainerStackView = UIStackView() - authorMetaContainerStackView.addArrangedSubview(titleContainerStackView) - titleContainerStackView.axis = .horizontal - titleContainerStackView.alignment = .center - titleContainerStackView.spacing = 4 - nameMetaLabel.translatesAutoresizingMaskIntoConstraints = false - titleContainerStackView.addArrangedSubview(nameMetaLabel) - NSLayoutConstraint.activate([ - nameMetaLabel.heightAnchor.constraint(equalToConstant: 22).priority(.defaultHigh), - ]) - titleContainerStackView.addArrangedSubview(nameTrialingDotLabel) - titleContainerStackView.addArrangedSubview(dateLabel) - let padding = UIView() - padding.translatesAutoresizingMaskIntoConstraints = false - titleContainerStackView.addArrangedSubview(padding) // padding - titleContainerStackView.addArrangedSubview(visibilityImageView) - nameMetaLabel.setContentHuggingPriority(.defaultHigh + 1, for: .horizontal) - nameTrialingDotLabel.setContentHuggingPriority(.defaultHigh + 2, for: .horizontal) - nameTrialingDotLabel.setContentCompressionResistancePriority(.required - 2, for: .horizontal) - dateLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) - dateLabel.setContentCompressionResistancePriority(.required - 10, for: .horizontal) - padding.setContentHuggingPriority(.defaultLow, for: .horizontal) - padding.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - visibilityImageView.setContentHuggingPriority(.required - 9, for: .horizontal) - visibilityImageView.setContentCompressionResistancePriority(.required - 9, for: .horizontal) - visibilityImageView.setContentHuggingPriority(.required - 1, for: .vertical) - - // subtitle container: [username] - let subtitleContainerStackView = UIStackView() - authorMetaContainerStackView.addArrangedSubview(subtitleContainerStackView) - subtitleContainerStackView.axis = .horizontal - subtitleContainerStackView.addArrangedSubview(usernameLabel) - - // reveal button - authorContainerStackView.addArrangedSubview(revealContentWarningButton) - revealContentWarningButton.setContentHuggingPriority(.required - 2, for: .horizontal) - - authorContainerStackView.translatesAutoresizingMaskIntoConstraints = false - authorContainerView.addSubview(authorContainerStackView) - NSLayoutConstraint.activate([ - authorContainerStackView.topAnchor.constraint(equalTo: authorContainerView.topAnchor), - authorContainerStackView.leadingAnchor.constraint(equalTo: authorContainerView.leadingAnchor), - authorContainerStackView.trailingAnchor.constraint(equalTo: authorContainerView.trailingAnchor), - authorContainerView.bottomAnchor.constraint(equalTo: authorContainerStackView.bottomAnchor, constant: StatusView.containerStackViewSpacing).priority(.required - 1), - ]) - containerStackView.addArrangedSubview(authorContainerView) - - // status container: [status | image / video | audio | poll | poll status] (overlay with content warning) - containerStackView.addArrangedSubview(statusContainerStackView) - statusContainerStackView.axis = .vertical - statusContainerStackView.spacing = 10 - - // content warning overlay - contentWarningOverlayView.translatesAutoresizingMaskIntoConstraints = false - containerStackView.addSubview(contentWarningOverlayView) - NSLayoutConstraint.activate([ - statusContainerStackView.topAnchor.constraint(equalTo: contentWarningOverlayView.topAnchor).priority(.required - 10), - statusContainerStackView.leftAnchor.constraint(equalTo: contentWarningOverlayView.leftAnchor).priority(.required - 1), - contentWarningOverlayView.rightAnchor.constraint(equalTo: statusContainerStackView.rightAnchor).priority(.required - 1), - contentWarningOverlayView.bottomAnchor.constraint(equalTo: statusContainerStackView.bottomAnchor).priority(.required - 1), - ]) - // avoid overlay behind other views - defer { - containerStackView.bringSubviewToFront(authorContainerView) - } - - // status - statusContainerStackView.addArrangedSubview(contentMetaText.textView) - contentMetaText.textView.setContentCompressionResistancePriority(.required - 1, for: .vertical) - - // image - statusContainerStackView.addArrangedSubview(statusMosaicImageViewContainer) - - // audio - audioView.translatesAutoresizingMaskIntoConstraints = false - statusContainerStackView.addArrangedSubview(audioView) - NSLayoutConstraint.activate([ - audioView.heightAnchor.constraint(equalToConstant: 44).priority(.defaultHigh) - ]) - - // video & gifv - statusContainerStackView.addArrangedSubview(playerContainerView) - - pollTableView.translatesAutoresizingMaskIntoConstraints = false - statusContainerStackView.addArrangedSubview(pollTableView) - pollTableViewHeightLayoutConstraint = pollTableView.heightAnchor.constraint(equalToConstant: 44.0).priority(.required - 1) - NSLayoutConstraint.activate([ - pollTableViewHeightLayoutConstraint, - ]) - - // statusPollTableViewHeightObservation = pollTableView.observe(\.contentSize, options: .new, changeHandler: { [weak self] tableView, _ in - // guard let self = self else { return } - // guard self.pollTableView.contentSize.height != .zero else { - // self.pollTableViewHeightLayoutConstraint.constant = 44 - // return - // } - // self.pollTableViewHeightLayoutConstraint.constant = self.pollTableView.contentSize.height - // }) - - pollStatusStackView.translatesAutoresizingMaskIntoConstraints = false - statusContainerStackView.addArrangedSubview(pollStatusStackView) - NSLayoutConstraint.activate([ - pollStatusStackView.heightAnchor.constraint(equalToConstant: 30).priority(.required - 10) - ]) - pollStatusStackView.axis = .horizontal - pollStatusStackView.addArrangedSubview(pollVoteCountLabel) - pollStatusStackView.addArrangedSubview(pollStatusDotLabel) - pollStatusStackView.addArrangedSubview(pollCountdownLabel) - pollStatusStackView.addArrangedSubview(pollVoteButton) - pollVoteCountLabel.setContentHuggingPriority(.defaultHigh + 2, for: .horizontal) - pollStatusDotLabel.setContentHuggingPriority(.defaultHigh + 1, for: .horizontal) - pollCountdownLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) - pollVoteButton.setContentHuggingPriority(.defaultHigh + 3, for: .horizontal) - - // action toolbar container - containerStackView.addArrangedSubview(actionToolbarContainer) - 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 - pollTableView.isHidden = true - pollStatusStackView.isHidden = true - audioView.isHidden = true - playerContainerView.isHidden = true - - avatarStackedContainerButton.isHidden = true - contentWarningOverlayView.isHidden = true - - contentMetaText.textView.delegate = self - contentMetaText.textView.linkDelegate = self - playerContainerView.delegate = self - contentWarningOverlayView.delegate = self - - headerInfoLabelTapGestureRecognizer.addTarget(self, action: #selector(StatusView.headerInfoLabelTapGestureRecognizerHandler(_:))) - headerInfoLabel.isUserInteractionEnabled = true - headerInfoLabel.addGestureRecognizer(headerInfoLabelTapGestureRecognizer) - - avatarButton.addTarget(self, action: #selector(StatusView.avatarButtonDidPressed(_:)), for: .touchUpInside) - avatarStackedContainerButton.addTarget(self, action: #selector(StatusView.avatarStackedContainerButtonDidPressed(_:)), for: .touchUpInside) - revealContentWarningButton.addTarget(self, action: #selector(StatusView.revealContentWarningButtonDidPressed(_:)), for: .touchUpInside) - pollVoteButton.addTarget(self, action: #selector(StatusView.pollVoteButtonPressed(_:)), for: .touchUpInside) - } - -} - -extension StatusView { - - func updateContentWarningDisplay(isHidden: Bool, animated: Bool, completion: (() -> Void)? = nil) { - func updateOverlayView() { - contentWarningOverlayView.contentOverlayView.alpha = isHidden ? 0 : 1 - contentWarningOverlayView.isUserInteractionEnabled = !isHidden - } - - contentWarningOverlayView.blurContentWarningTitleLabel.isHidden = isHidden - - if animated { - UIView.animate(withDuration: 0.33, delay: 0, options: .curveEaseInOut) { - updateOverlayView() - } completion: { _ in - completion!() - } - } else { - updateOverlayView() - completion?() - } - } - - func updateRevealContentWarningButton(isRevealing: Bool) { - self.isRevealing = isRevealing - - if !isRevealing { - let image = traitCollection.userInterfaceStyle == .light ? UIImage(systemName: "eye")! : UIImage(systemName: "eye.fill") - revealContentWarningButton.setImage(image, for: .normal) - } else { - let image = traitCollection.userInterfaceStyle == .light ? UIImage(systemName: "eye.slash")! : UIImage(systemName: "eye.slash.fill") - revealContentWarningButton.setImage(image, for: .normal) - } - // TODO: a11y - } - - func updateVisibility(visibility: Mastodon.Entity.Status.Visibility) { - switch visibility { - case .public: - visibilityImageView.image = UIImage(systemName: "globe", withConfiguration: UIImage.SymbolConfiguration(pointSize: 13, weight: .regular)) - case .private: - visibilityImageView.image = UIImage(systemName: "person.3", withConfiguration: UIImage.SymbolConfiguration(pointSize: 13, weight: .regular)) - case .unlisted: - visibilityImageView.image = UIImage(systemName: "eye.slash", withConfiguration: UIImage.SymbolConfiguration(pointSize: 13, weight: .regular)) - case .direct: - visibilityImageView.image = UIImage(systemName: "at", withConfiguration: UIImage.SymbolConfiguration(pointSize: 13, weight: .regular)) - case ._other: - visibilityImageView.image = nil - } - } - -} - -extension StatusView { - - @objc private func headerInfoLabelTapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - delegate?.statusView(self, headerInfoLabelDidPressed: headerInfoLabel) - } - - @objc private func avatarButtonDidPressed(_ sender: UIButton) { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - delegate?.statusView(self, avatarImageViewDidPressed: avatarButton.avatarImageView) - } - - @objc private func avatarStackedContainerButtonDidPressed(_ sender: UIButton) { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - delegate?.statusView(self, avatarImageViewDidPressed: avatarStackedContainerButton.topLeadingAvatarStackedImageView) - } - - @objc private func revealContentWarningButtonDidPressed(_ sender: UIButton) { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - delegate?.statusView(self, revealContentWarningButtonDidPressed: sender) - } - - @objc private func pollVoteButtonPressed(_ sender: UIButton) { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - delegate?.statusView(self, pollVoteButtonPressed: sender) - } - -} - -// MARK: - MetaTextViewDelegate -extension StatusView: MetaTextViewDelegate { - func metaTextView(_ metaTextView: MetaTextView, didSelectMeta meta: Meta) { - logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") - switch metaTextView { - case contentMetaText.textView: - delegate?.statusView(self, metaText: contentMetaText, didSelectMeta: meta) - default: - assertionFailure() - break - } - } -} - -// MARK: - UITextViewDelegate -extension StatusView: UITextViewDelegate { - - func textView(_ textView: UITextView, shouldInteractWith textAttachment: NSTextAttachment, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { - switch textView { - case contentMetaText.textView: - return false - default: - assertionFailure() - return true - } - } - - func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { - switch textView { - case contentMetaText.textView: - return false - default: - assertionFailure() - return true - } - } -} - -// MARK: - ContentWarningOverlayViewDelegate -extension StatusView: ContentWarningOverlayViewDelegate { - func contentWarningOverlayViewDidPressed(_ contentWarningOverlayView: ContentWarningOverlayView) { - assert(contentWarningOverlayView === self.contentWarningOverlayView) - delegate?.statusView(self, contentWarningOverlayViewDidPressed: contentWarningOverlayView) - } - -} - -// MARK: - PlayerContainerViewDelegate -extension StatusView: PlayerContainerViewDelegate { - func playerContainerView(_ playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { - delegate?.statusView(self, playerContainerView: playerContainerView, contentWarningOverlayViewDidPressed: contentWarningOverlayView) - } -} - -// MARK: - AvatarConfigurableView -extension StatusView: AvatarConfigurableView { - static var configurableAvatarImageSize: CGSize { return Self.avatarImageSize } - static var configurableAvatarImageCornerRadius: CGFloat { return 4 } - var configurableAvatarImageView: FLAnimatedImageView? { avatarButton.avatarImageView } -} - -#if canImport(SwiftUI) && DEBUG -import SwiftUI - -struct StatusView_Previews: PreviewProvider { - - static let avatarFlora = UIImage(named: "tiraya-adam") - static let avatarMarkus = UIImage(named: "markus-spiske") - - static var previews: some View { - Group { - UIViewPreview(width: 375) { - let statusView = StatusView() - statusView.configure( - with: AvatarConfigurableViewConfiguration( - avatarImageURL: nil, - placeholderImage: avatarFlora - ) - ) - return statusView - } - .previewLayout(.fixed(width: 375, height: 200)) - .previewDisplayName("Normal") - UIViewPreview(width: 375) { - let statusView = StatusView() - statusView.headerContainerView.isHidden = false - statusView.avatarButton.isHidden = true - statusView.avatarStackedContainerButton.isHidden = false - statusView.avatarStackedContainerButton.topLeadingAvatarStackedImageView.configure( - with: AvatarConfigurableViewConfiguration( - avatarImageURL: nil, - placeholderImage: avatarFlora - ) - ) - statusView.avatarStackedContainerButton.bottomTrailingAvatarStackedImageView.configure( - with: AvatarConfigurableViewConfiguration( - avatarImageURL: nil, - placeholderImage: avatarMarkus - ) - ) - return statusView - } - .previewLayout(.fixed(width: 375, height: 200)) - .previewDisplayName("Reblog") - UIViewPreview(width: 375) { - let statusView = StatusView(frame: CGRect(x: 0, y: 0, width: 375, height: 500)) - statusView.configure( - with: AvatarConfigurableViewConfiguration( - avatarImageURL: nil, - placeholderImage: avatarFlora - ) - ) - statusView.headerContainerView.isHidden = false - let images = MosaicImageView_Previews.images - let mosaics = statusView.statusMosaicImageViewContainer.setupImageViews(count: 4, maxSize: CGSize(width: 375, height: 162)) - for (i, mosaic) in mosaics.enumerated() { - mosaic.imageView.image = images[i] - } - statusView.statusMosaicImageViewContainer.isHidden = false - statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isHidden = true - return statusView - } - .previewLayout(.fixed(width: 375, height: 380)) - .previewDisplayName("Image Meida") - UIViewPreview(width: 375) { - let statusView = StatusView(frame: CGRect(x: 0, y: 0, width: 375, height: 500)) - statusView.configure( - with: AvatarConfigurableViewConfiguration( - avatarImageURL: nil, - placeholderImage: avatarFlora - ) - ) - statusView.headerContainerView.isHidden = false - statusView.setNeedsLayout() - statusView.layoutIfNeeded() - statusView.updateContentWarningDisplay(isHidden: false, animated: false) - let images = MosaicImageView_Previews.images - let mosaics = statusView.statusMosaicImageViewContainer.setupImageViews(count: 4, maxSize: CGSize(width: 375, height: 162)) - for (i, mosaic) in mosaics.enumerated() { - mosaic.imageView.image = images[i] - } - statusView.statusMosaicImageViewContainer.isHidden = false - return statusView - } - .previewLayout(.fixed(width: 375, height: 380)) - .previewDisplayName("Content Sensitive") - } - } - -} - -#endif - diff --git a/Mastodon/Scene/Share/View/Content/TimelineHeaderView.swift b/Mastodon/Scene/Share/View/Content/TimelineHeaderView.swift index 2948af4cf..e26604dca 100644 --- a/Mastodon/Scene/Share/View/Content/TimelineHeaderView.swift +++ b/Mastodon/Scene/Share/View/Content/TimelineHeaderView.swift @@ -6,6 +6,8 @@ // import UIKit +import MastodonAsset +import MastodonLocalization final class TimelineHeaderView: UIView { @@ -83,56 +85,56 @@ extension TimelineHeaderView { } -extension Item.EmptyStateHeaderAttribute.Reason { - var iconImage: UIImage? { - switch self { - case .noStatusFound, .blocking, .blocked: - return UIImage(systemName: "nosign", withConfiguration: UIImage.SymbolConfiguration(pointSize: 64, weight: .bold))! - case .suspended: - return UIImage(systemName: "person.crop.circle.badge.xmark", withConfiguration: UIImage.SymbolConfiguration(pointSize: 64, weight: .bold))! - } - } - - var message: String { - switch self { - case .noStatusFound: - return L10n.Common.Controls.Timeline.Header.noStatusFound - case .blocking(let name): - if let name = name { - return L10n.Common.Controls.Timeline.Header.userBlockingWarning(name) - } else { - return L10n.Common.Controls.Timeline.Header.blockingWarning - } - case .blocked(let name): - if let name = name { - return L10n.Common.Controls.Timeline.Header.userBlockedWarning(name) - } else { - return L10n.Common.Controls.Timeline.Header.blockedWarning - } - case .suspended(let name): - if let name = name { - return L10n.Common.Controls.Timeline.Header.userSuspendedWarning(name) - } else { - return L10n.Common.Controls.Timeline.Header.suspendedWarning - } - } - } -} +//extension Item.EmptyStateHeaderAttribute.Reason { +// var iconImage: UIImage? { +// switch self { +// case .noStatusFound, .blocking, .blocked: +// return UIImage(systemName: "nosign", withConfiguration: UIImage.SymbolConfiguration(pointSize: 64, weight: .bold))! +// case .suspended: +// return UIImage(systemName: "person.crop.circle.badge.xmark", withConfiguration: UIImage.SymbolConfiguration(pointSize: 64, weight: .bold))! +// } +// } +// +// var message: String { +// switch self { +// case .noStatusFound: +// return L10n.Common.Controls.Timeline.Header.noStatusFound +// case .blocking(let name): +// if let name = name { +// return L10n.Common.Controls.Timeline.Header.userBlockingWarning(name) +// } else { +// return L10n.Common.Controls.Timeline.Header.blockingWarning +// } +// case .blocked(let name): +// if let name = name { +// return L10n.Common.Controls.Timeline.Header.userBlockedWarning(name) +// } else { +// return L10n.Common.Controls.Timeline.Header.blockedWarning +// } +// case .suspended(let name): +// if let name = name { +// return L10n.Common.Controls.Timeline.Header.userSuspendedWarning(name) +// } else { +// return L10n.Common.Controls.Timeline.Header.suspendedWarning +// } +// } +// } +//} -#if DEBUG && canImport(SwiftUI) -import SwiftUI - -struct TimelineHeaderView_Previews: PreviewProvider { - static var previews: some View { - Group { - UIViewPreview(width: 375) { - let headerView = TimelineHeaderView() - headerView.iconImageView.image = Item.EmptyStateHeaderAttribute.Reason.blocking(name: nil).iconImage - headerView.messageLabel.text = Item.EmptyStateHeaderAttribute.Reason.blocking(name: nil).message - return headerView - } - .previewLayout(.fixed(width: 375, height: 400)) - } - } -} -#endif +//#if DEBUG && canImport(SwiftUI) +//import SwiftUI +// +//struct TimelineHeaderView_Previews: PreviewProvider { +// static var previews: some View { +// Group { +// UIViewPreview(width: 375) { +// let serverSectionHeaderView = TimelineHeaderView() +// serverSectionHeaderView.iconImageView.image = Item.EmptyStateHeaderAttribute.Reason.blocking(name: nil).iconImage +// serverSectionHeaderView.messageLabel.text = Item.EmptyStateHeaderAttribute.Reason.blocking(name: nil).message +// return serverSectionHeaderView +// } +// .previewLayout(.fixed(width: 375, height: 400)) +// } +// } +//} +//#endif diff --git a/Mastodon/Scene/Share/View/Content/UserView+Configuration.swift b/Mastodon/Scene/Share/View/Content/UserView+Configuration.swift new file mode 100644 index 000000000..3d22eedae --- /dev/null +++ b/Mastodon/Scene/Share/View/Content/UserView+Configuration.swift @@ -0,0 +1,49 @@ +// +// UserView+Configuration.swift +// Mastodon +// +// Created by MainasuK on 2022-1-19. +// + +import UIKit +import Combine +import MastodonUI +import CoreDataStack +import MastodonLocalization +import MastodonMeta +import Meta + +extension UserView { + public func configure(user: MastodonUser) { + Publishers.CombineLatest( + user.publisher(for: \.avatar), + UserDefaults.shared.publisher(for: \.preferredStaticAvatar) + ) + .map { _ in user.avatarImageURL() } + .assign(to: \.authorAvatarImageURL, on: viewModel) + .store(in: &disposeBag) + + // author name + Publishers.CombineLatest( + user.publisher(for: \.displayName), + user.publisher(for: \.emojis) + ) + .map { _, emojis in + do { + let content = MastodonContent(content: user.displayNameWithFallback, emojis: emojis.asDictionary) + let metaContent = try MastodonMetaContent.convert(document: content) + return metaContent + } catch { + assertionFailure(error.localizedDescription) + return PlaintextMetaContent(string: user.displayNameWithFallback) + } + } + .assign(to: \.authorName, on: viewModel) + .store(in: &disposeBag) + // author username + user.publisher(for: \.acct) + .map { $0 as String? } + .assign(to: \.authorUsername, on: viewModel) + .store(in: &disposeBag) + } +} diff --git a/Mastodon/Scene/Share/View/ImageView/AvatarImageView.swift b/Mastodon/Scene/Share/View/ImageView/AvatarImageView.swift deleted file mode 100644 index 0b3f2a8f4..000000000 --- a/Mastodon/Scene/Share/View/ImageView/AvatarImageView.swift +++ /dev/null @@ -1,11 +0,0 @@ -// -// AvatarImageView.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-7-21. -// - -import UIKit -import FLAnimatedImage - -class AvatarImageView: FLAnimatedImageView { } diff --git a/Mastodon/Scene/Share/View/Node/ASMetaEditableTextNode.swift b/Mastodon/Scene/Share/View/Node/ASMetaEditableTextNode.swift deleted file mode 100644 index e5037fdf6..000000000 --- a/Mastodon/Scene/Share/View/Node/ASMetaEditableTextNode.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// ASMetaEditableTextNode.swift -// Mastodon -// -// Created by Cirno MainasuK on 2021-6-20. -// - -#if ASDK - -import UIKit -import AsyncDisplayKit - -protocol ASMetaEditableTextNodeDelegate: AnyObject { - func metaEditableTextNode(_ textNode: ASMetaEditableTextNode, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool -} - -final class ASMetaEditableTextNode: ASEditableTextNode, UITextViewDelegate { - weak var metaEditableTextNodeDelegate: ASMetaEditableTextNodeDelegate? - - func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { - return metaEditableTextNodeDelegate?.metaEditableTextNode(self, shouldInteractWith: URL, in: characterRange, interaction: interaction) ?? false - } -} - -#endif diff --git a/Mastodon/Scene/Share/View/Node/Status/StatusNode.swift b/Mastodon/Scene/Share/View/Node/Status/StatusNode.swift deleted file mode 100644 index 170543482..000000000 --- a/Mastodon/Scene/Share/View/Node/Status/StatusNode.swift +++ /dev/null @@ -1,234 +0,0 @@ -// -// StatusNNode.swift -// Mastodon -// -// Created by Cirno MainasuK on 2021-6-19. -// - -#if ASDK - -import UIKit -import Combine -import AsyncDisplayKit -import CoreDataStack -import func AVFoundation.AVMakeRect - -protocol StatusNodeDelegate: AnyObject { - //func statusNode(_ node: StatusNode, statusContentTextNode: ASMetaEditableTextNode, didSelectActiveEntityType type: ActiveEntityType) -} - -final class StatusNode: ASCellNode { - - var disposeBag = Set<AnyCancellable>() - var timestamp: Date - var timestampSubscription: AnyCancellable? - - weak var delegate: StatusNodeDelegate? // needs assign on main queue - - static let avatarImageSize = CGSize(width: 42, height: 42) - static let avatarImageCornerRadius: CGFloat = 4 - -// static let statusContentAppearance: MastodonStatusContent.Appearance = { -// let linkAttributes: [NSAttributedString.Key: Any] = [ -// .font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold)), -// .foregroundColor: Asset.Colors.brandBlue.color -// ] -// return MastodonStatusContent.Appearance( -// attributes: [ -// .font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .regular)), -// .foregroundColor: Asset.Colors.Label.primary.color -// ], -// urlAttributes: linkAttributes, -// hashtagAttributes: linkAttributes, -// mentionAttributes: linkAttributes -// ) -// }() - - let avatarImageNode: ASNetworkImageNode = { - let node = ASNetworkImageNode() - node.contentMode = .scaleAspectFill - node.defaultImage = UIImage.placeholder(color: .systemFill) - node.forcedSize = StatusNode.avatarImageSize - node.cornerRadius = StatusNode.avatarImageCornerRadius - // node.cornerRoundingType = .precomposited - // node.shouldRenderProgressImages = true - return node - }() - let nameTextNode = ASTextNode() - let nameDotTextNode = ASTextNode() - let dateTextNode = ASTextNode() - let usernameTextNode = ASTextNode() - let statusContentTextNode: ASMetaEditableTextNode = { - let node = ASMetaEditableTextNode() - node.scrollEnabled = false - return node - }() - - let mosaicImageViewModel: MosaicImageViewModel - let mediaMultiplexImageNodes: [ASMultiplexImageNode] - - init(status: Status) { - timestamp = (status.reblog ?? status).createdAt - let _mosaicImageViewModel: MosaicImageViewModel = { - let mediaAttachments = Array((status.reblog ?? status).mediaAttachments ?? []).sorted { $0.index.compare($1.index) == .orderedAscending } - return MosaicImageViewModel(mediaAttachments: mediaAttachments) - }() - mosaicImageViewModel = _mosaicImageViewModel - mediaMultiplexImageNodes = { - var imageNodes: [ASMultiplexImageNode] = [] - for _ in 0..<_mosaicImageViewModel.metas.count { - let imageNode = ASMultiplexImageNode() // TODO: adapt downloader - imageNode.downloadsIntermediateImages = true - imageNode.imageIdentifiers = ["url", "previewURL"].map { $0 as NSString } // quality in descending order - imageNodes.append(imageNode) - } - return imageNodes - }() - super.init() - - automaticallyManagesSubnodes = true - - if let url = (status.reblog ?? status).author.avatarImageURL() { - avatarImageNode.url = url - } - - nameTextNode.attributedText = NSAttributedString(string: status.author.displayNameWithFallback, attributes: [ - .foregroundColor: Asset.Colors.Label.primary.color, - .font: UIFont.systemFont(ofSize: 17, weight: .semibold) - ]) - nameDotTextNode.attributedText = NSAttributedString(string: "·", attributes: [ - .foregroundColor: Asset.Colors.Label.secondary.color, - .font: UIFont.systemFont(ofSize: 13, weight: .regular) - ]) - // set date - dateTextNode.attributedText = NSAttributedString(string: timestamp.localizedSlowedTimeAgoSinceNow, attributes: [ - .foregroundColor: Asset.Colors.Label.secondary.color, - .font: UIFont.systemFont(ofSize: 13, weight: .regular) - ]) - - usernameTextNode.attributedText = NSAttributedString(string: "@" + status.author.acct, attributes: [ - .foregroundColor: Asset.Colors.Label.secondary.color, - .font: UIFont.systemFont(ofSize: 15, weight: .regular) - ]) - - // FIXME: - // statusContentTextNode.metaEditableTextNodeDelegate = self -// if let parseResult = try? MastodonStatusContent.parse( -// content: (status.reblog ?? status).content, -// emojiDict: (status.reblog ?? status).emojiDict -// ) { -// statusContentTextNode.attributedText = parseResult.trimmedAttributedString(appearance: StatusNode.statusContentAppearance) -// } - - for imageNode in mediaMultiplexImageNodes { - imageNode.delegate = self - } - } - - override func didEnterDisplayState() { - super.didEnterDisplayState() - - timestampSubscription = AppContext.shared.timestampUpdatePublisher - .sink { [weak self] _ in - guard let self = self else { return } - self.dateTextNode.attributedText = NSAttributedString(string: self.timestamp.localizedSlowedTimeAgoSinceNow, attributes: [ - .foregroundColor: Asset.Colors.Label.secondary.color, - .font: UIFont.systemFont(ofSize: 13, weight: .regular) - ]) - } - - // FIXME: needs move to other only once called callback in life cycle like: `viewDidLoad` - statusContentTextNode.textView.isEditable = false - statusContentTextNode.textView.textDragInteraction?.isEnabled = false - statusContentTextNode.textView.linkTextAttributes = [ - .foregroundColor: Asset.Colors.brandBlue.color - ] - } - - override func didExitVisibleState() { - super.didExitVisibleState() - timestampSubscription = nil - } - - override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec { - let headerStack = ASStackLayoutSpec.horizontal() - headerStack.alignItems = .center - headerStack.spacing = 5 - var headerStackChildren: [ASLayoutElement] = [] - - avatarImageNode.style.preferredSize = StatusNode.avatarImageSize - headerStackChildren.append(avatarImageNode) - - let authorMetaHeaderStack = ASStackLayoutSpec.horizontal() - authorMetaHeaderStack.alignItems = .center - authorMetaHeaderStack.spacing = 4 - authorMetaHeaderStack.children = [ - nameTextNode, - nameDotTextNode, - dateTextNode, - ] - let authorMetaStack = ASStackLayoutSpec.vertical() - authorMetaStack.children = [ - authorMetaHeaderStack, - usernameTextNode, - ] - - headerStackChildren.append(authorMetaStack) - - headerStack.children = headerStackChildren - - let verticalStack = ASStackLayoutSpec.vertical() - verticalStack.spacing = 10 - var verticalStackChildren: [ASLayoutElement] = [ - headerStack, - statusContentTextNode, - ] - if !mediaMultiplexImageNodes.isEmpty { - for (imageNode, meta) in zip(mediaMultiplexImageNodes, mosaicImageViewModel.metas) { - imageNode.style.preferredSize = AVMakeRect(aspectRatio: meta.size, insideRect: CGRect(origin: .zero, size: constrainedSize.max)).size - let layout = ASRatioLayoutSpec(ratio: meta.size.height / meta.size.width, child: imageNode) - verticalStackChildren.append(layout) - } - } - verticalStack.children = verticalStackChildren - - return ASInsetLayoutSpec( - insets: UIEdgeInsets(top: 12, left: 16, bottom: 12, right: 16), - child: verticalStack - ) - } - -} - -// MARK: - ASEditableTextNodeDelegate -//extension StatusNode: ASMetaEditableTextNodeDelegate { -// func metaEditableTextNode(_ textNode: ASMetaEditableTextNode, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { -// guard let activityEntityType = ActiveEntityType(url: URL) else { -// return false -// } -// defer { -// delegate?.statusNode(self, statusContentTextNode: textNode, didSelectActiveEntityType: activityEntityType) -// } -// return false -// } -//} - -// MARK: - ASMultiplexImageNodeDataSource -extension StatusNode: ASMultiplexImageNodeDataSource { - func multiplexImageNode(_ imageNode: ASMultiplexImageNode, urlForImageIdentifier imageIdentifier: ASImageIdentifier) -> URL? { - guard let imageNodeIndex = mediaMultiplexImageNodes.firstIndex(of: imageNode) else { return nil } - guard imageNodeIndex < mosaicImageViewModel.metas.count else { return nil } - let meta = mosaicImageViewModel.metas[imageNodeIndex] - switch imageIdentifier { - case "url" as NSString: - return meta.url - case "previewURL" as NSString: - return meta.previewURL - default: - assertionFailure() - return nil - } - } -} - -#endif diff --git a/Mastodon/Scene/Share/View/Node/Status/TimelineBottomLoaderNode.swift b/Mastodon/Scene/Share/View/Node/Status/TimelineBottomLoaderNode.swift deleted file mode 100644 index 0ec83dfef..000000000 --- a/Mastodon/Scene/Share/View/Node/Status/TimelineBottomLoaderNode.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// TimelineBottomLoaderNode.swift -// Mastodon -// -// Created by Cirno MainasuK on 2021-6-19. -// - -#if ASDK - -import UIKit -import AsyncDisplayKit - -final class TimelineBottomLoaderNode: ASCellNode { - - let activityIndicatorNode = ActivityIndicatorNode() - - override init() { - super.init() - - automaticallyManagesSubnodes = true - activityIndicatorNode.bounds = CGRect(x: 0, y: 0, width: 40, height: 40) - } - - override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec { - let contentStack = ASStackLayoutSpec.horizontal() - contentStack.alignItems = .center - contentStack.spacing = 7 - - contentStack.children = [activityIndicatorNode] - - return contentStack - } - - override func didEnterDisplayState() { - super.didEnterDisplayState() - activityIndicatorNode.animating = true - } - -} - -#endif diff --git a/Mastodon/Scene/Share/View/Node/Status/TimelineMiddleLoaderNode.swift b/Mastodon/Scene/Share/View/Node/Status/TimelineMiddleLoaderNode.swift deleted file mode 100644 index bd662ad70..000000000 --- a/Mastodon/Scene/Share/View/Node/Status/TimelineMiddleLoaderNode.swift +++ /dev/null @@ -1,54 +0,0 @@ -// -// TimelineMiddleLoaderNode.swift -// Mastodon -// -// Created by Cirno MainasuK on 2021-6-19. -// - -#if ASDK - -import UIKit -import AsyncDisplayKit - -final class TimelineMiddleLoaderNode: ASCellNode { - - static let loadButtonFont = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .medium)) - - let activityIndicatorNode = ASDisplayNode(viewBlock: { - let view = UIActivityIndicatorView(style: .medium) - view.hidesWhenStopped = true - return view - }) - - let loadButtonNode = ASButtonNode() - - override init() { - super.init() - - automaticallyManagesSubnodes = true - - loadButtonNode.setAttributedTitle( - NSAttributedString( - string: L10n.Common.Controls.Timeline.Loader.loadMissingPosts, - attributes: [ - .foregroundColor: Asset.Colors.brandBlue.color, - .font: TimelineMiddleLoaderNode.loadButtonFont - ]), - for: .normal - ) - } - - override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec { - let contentStack = ASStackLayoutSpec.horizontal() - contentStack.alignItems = .center - contentStack.spacing = 7 - - contentStack.children = [loadButtonNode] - - - return contentStack - } - -} - -#endif diff --git a/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift deleted file mode 100644 index 16b39feb5..000000000 --- a/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift +++ /dev/null @@ -1,151 +0,0 @@ -// -// PollOptionTableViewCell.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-2-25. -// - -import UIKit -import Combine - -final class PollOptionTableViewCell: UITableViewCell { - - static let height: CGFloat = PollOptionView.height - - var disposeBag = Set<AnyCancellable>() - - let pollOptionView = PollOptionView() - var attribute: PollItem.Attribute? - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - _init() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - _init() - } - - override func setSelected(_ selected: Bool, animated: Bool) { - super.setSelected(selected, animated: animated) - - guard let voteState = attribute?.voteState else { return } - switch voteState { - case .hidden: - let color = ThemeService.shared.currentTheme.value.secondarySystemBackgroundColor - pollOptionView.roundedBackgroundView.backgroundColor = isHighlighted ? color.withAlphaComponent(0.8) : color - case .reveal: - break - } - } - - override func setHighlighted(_ highlighted: Bool, animated: Bool) { - super.setHighlighted(highlighted, animated: animated) - - guard let voteState = attribute?.voteState else { return } - switch voteState { - case .hidden: - let color = ThemeService.shared.currentTheme.value.secondarySystemBackgroundColor - pollOptionView.roundedBackgroundView.backgroundColor = isHighlighted ? color.withAlphaComponent(0.8) : color - case .reveal: - break - } - } - -} - -extension PollOptionTableViewCell { - - private func _init() { - selectionStyle = .none - backgroundColor = .clear - pollOptionView.optionTextField.isUserInteractionEnabled = false - - pollOptionView.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(pollOptionView) - NSLayoutConstraint.activate([ - pollOptionView.topAnchor.constraint(equalTo: contentView.topAnchor), - pollOptionView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - pollOptionView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - pollOptionView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - ]) - } - - func updateTextAppearance() { - guard let voteState = attribute?.voteState else { - pollOptionView.optionTextField.textColor = Asset.Colors.Label.primary.color - pollOptionView.optionTextField.layer.removeShadow() - return - } - - switch voteState { - case .hidden: - pollOptionView.optionTextField.textColor = Asset.Colors.Label.primary.color - pollOptionView.optionTextField.layer.removeShadow() - case .reveal(_, let percentage, _): - if CGFloat(percentage) * pollOptionView.voteProgressStripView.frame.width > pollOptionView.optionLabelMiddlePaddingView.frame.minX { - pollOptionView.optionTextField.textColor = .white - pollOptionView.optionTextField.layer.setupShadow(x: 0, y: 0, blur: 4, spread: 0) - } else { - pollOptionView.optionTextField.textColor = Asset.Colors.Label.primary.color - pollOptionView.optionTextField.layer.removeShadow() - } - - if CGFloat(percentage) * pollOptionView.voteProgressStripView.frame.width > pollOptionView.optionLabelMiddlePaddingView.frame.maxX { - pollOptionView.optionPercentageLabel.textColor = .white - pollOptionView.optionPercentageLabel.layer.setupShadow(x: 0, y: 0, blur: 4, spread: 0) - } else { - pollOptionView.optionPercentageLabel.textColor = Asset.Colors.Label.primary.color - pollOptionView.optionPercentageLabel.layer.removeShadow() - } - } - } - - override func layoutSubviews() { - super.layoutSubviews() - updateTextAppearance() - } - -} - -#if canImport(SwiftUI) && DEBUG -import SwiftUI - -struct PollTableViewCell_Previews: PreviewProvider { - - static var controls: some View { - Group { - UIViewPreview() { - PollOptionTableViewCell() - } - .previewLayout(.fixed(width: 375, height: 44 + 10)) - UIViewPreview() { - let cell = PollOptionTableViewCell() - PollSection.configure(cell: cell, selectState: .off) - return cell - } - .previewLayout(.fixed(width: 375, height: 44 + 10)) - UIViewPreview() { - let cell = PollOptionTableViewCell() - PollSection.configure(cell: cell, selectState: .on) - return cell - } - .previewLayout(.fixed(width: 375, height: 44 + 10)) - } - .background(Color(.systemBackground)) - } - - static var previews: some View { - Group { - controls - .colorScheme(.light) - controls - .colorScheme(.dark) - } - } - -} - -#endif - diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell+ViewModel.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell+ViewModel.swift new file mode 100644 index 000000000..85184d406 --- /dev/null +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell+ViewModel.swift @@ -0,0 +1,72 @@ +// +// StatusTableViewCell+ViewModel.swift +// Mastodon +// +// Created by MainasuK on 2022-1-12. +// + +import UIKit +import CoreDataStack + +extension StatusTableViewCell { + final class ViewModel { + let value: Value + + init(value: Value) { + self.value = value + } + + enum Value { + case feed(Feed) + case status(Status) + } + } +} + +extension StatusTableViewCell { + + func configure( + tableView: UITableView, + viewModel: ViewModel, + delegate: StatusTableViewCellDelegate? + ) { + if statusView.frame == .zero { + // set status view width + statusView.frame.size.width = tableView.frame.width - containerViewHorizontalMargin + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): did layout for new cell") + } + + switch viewModel.value { + case .feed(let feed): + statusView.configure(feed: feed) + + feed.publisher(for: \.hasMore) + .sink { [weak self] hasMore in + guard let self = self else { return } + self.separatorLine.isHidden = hasMore + } + .store(in: &disposeBag) + + case .status(let status): + statusView.configure(status: status) + } + + self.delegate = delegate + + statusView.viewModel.$isContentReveal + .removeDuplicates() + .dropFirst() + .receive(on: DispatchQueue.main) + .sink { [weak tableView, weak self] _ in + guard let tableView = tableView else { return } + guard let _ = self else { return } + + UIView.performWithoutAnimation { + tableView.beginUpdates() + tableView.endUpdates() + } + } + .store(in: &disposeBag) + } + +} diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index 38c86c112..a1033f052 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -7,98 +7,32 @@ import os.log import UIKit -import AVKit import Combine -import CoreData -import CoreDataStack -import Meta -import MetaTextKit +import MastodonAsset +import MastodonLocalization +import MastodonUI -protocol StatusTableViewCellDelegate: AnyObject { - var context: AppContext! { get } - var managedObjectContext: NSManagedObjectContext { get } +final class StatusTableViewCell: UITableViewCell { - func parent() -> UIViewController - var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { get } + static let marginForRegularHorizontalSizeClass: CGFloat = 64 - func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, headerInfoLabelDidPressed label: UILabel) - func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, avatarImageViewDidPressed imageView: UIImageView) - func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, revealContentWarningButtonDidPressed button: UIButton) - func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) - func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, pollVoteButtonPressed button: UIButton) - func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta) - - func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) - func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) - - func statusTableViewCell(_ cell: StatusTableViewCell, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) - func statusTableViewCell(_ cell: StatusTableViewCell, playerViewControllerDidPressed playerViewController: AVPlayerViewController) - - func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, replyButtonDidPressed sender: UIButton) - func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, reblogButtonDidPressed sender: UIButton) - func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, likeButtonDidPressed sender: UIButton) - - func statusTableViewCell(_ cell: StatusTableViewCell, pollTableView: PollTableView, didSelectRowAt indexPath: IndexPath) -} - -extension StatusTableViewCellDelegate { - func statusTableViewCell(_ cell: StatusTableViewCell, playerViewControllerDidPressed playerViewController: AVPlayerViewController) { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - playerViewController.showsPlaybackControls.toggle() - } -} - -final class StatusTableViewCell: UITableViewCell, StatusCell { + let logger = Logger(subsystem: "StatusTableViewCell", category: "View") - static let bottomPaddingHeight: CGFloat = 10 - weak var delegate: StatusTableViewCellDelegate? - var disposeBag = Set<AnyCancellable>() - var pollCountdownSubscription: AnyCancellable? - var observations = Set<NSKeyValueObservation>() - + var _disposeBag = Set<AnyCancellable>() + let statusView = StatusView() - let threadMetaStackView = UIStackView() - let threadMetaView = ThreadMetaView() let separatorLine = UIView.separatorLine - - var separatorLineToEdgeLeadingLayoutConstraint: NSLayoutConstraint! - var separatorLineToEdgeTrailingLayoutConstraint: NSLayoutConstraint! - var separatorLineToMarginLeadingLayoutConstraint: NSLayoutConstraint! - var separatorLineToMarginTrailingLayoutConstraint: NSLayoutConstraint! - - var isFiltered: Bool = false { - didSet { - configure(isFiltered: isFiltered) - } - } - - let filteredLabel: UILabel = { - let label = UILabel() - label.textColor = Asset.Colors.Label.secondary.color - label.text = L10n.Common.Controls.Timeline.filtered - label.font = .preferredFont(forTextStyle: .body) - return label - }() + var containerViewLeadingLayoutConstraint: NSLayoutConstraint! + var containerViewTrailingLayoutConstraint: NSLayoutConstraint! override func prepareForReuse() { super.prepareForReuse() - selectionStyle = .default - isFiltered = false - statusView.statusMosaicImageViewContainer.resetImageTask() - statusView.contentMetaText.textView.isSelectable = false - statusView.updateContentWarningDisplay(isHidden: true, animated: false) - statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isUserInteractionEnabled = true - statusView.pollTableView.dataSource = nil - statusView.playerContainerView.reset() - statusView.playerContainerView.contentWarningOverlayView.isUserInteractionEnabled = true - statusView.playerContainerView.isHidden = true - threadMetaView.isHidden = true + disposeBag.removeAll() - observations.removeAll() - isAccessibilityElement = false // reset behavior + statusView.prepareForReuse() } override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { @@ -122,254 +56,60 @@ extension StatusTableViewCell { private func _init() { statusView.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(statusView) + setupContainerViewMarginConstraints() NSLayoutConstraint.activate([ - statusView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 20), - statusView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), - contentView.readableContentGuide.trailingAnchor.constraint(equalTo: statusView.trailingAnchor), + statusView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 16), + containerViewLeadingLayoutConstraint, + containerViewTrailingLayoutConstraint, + statusView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), ]) - - threadMetaStackView.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(threadMetaStackView) - NSLayoutConstraint.activate([ - threadMetaStackView.topAnchor.constraint(equalTo: statusView.bottomAnchor), - threadMetaStackView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), - threadMetaStackView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), - threadMetaStackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - ]) - threadMetaStackView.addArrangedSubview(threadMetaView) + statusView.setup(style: .inline) + updateContainerViewMarginConstraints() separatorLine.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(separatorLine) - separatorLineToEdgeLeadingLayoutConstraint = separatorLine.leadingAnchor.constraint(equalTo: contentView.leadingAnchor) - separatorLineToEdgeTrailingLayoutConstraint = separatorLine.trailingAnchor.constraint(equalTo: contentView.trailingAnchor) - separatorLineToMarginLeadingLayoutConstraint = separatorLine.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor) - separatorLineToMarginTrailingLayoutConstraint = separatorLine.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor) 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)), + separatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView)).priority(.required - 1), ]) - resetSeparatorLineLayout() - - filteredLabel.translatesAutoresizingMaskIntoConstraints = false - addSubview(filteredLabel) - NSLayoutConstraint.activate([ - filteredLabel.centerXAnchor.constraint(equalTo: centerXAnchor), - filteredLabel.centerYAnchor.constraint(equalTo: centerYAnchor), - ]) - filteredLabel.isHidden = true - - statusView.delegate = self - statusView.pollTableView.delegate = self - statusView.statusMosaicImageViewContainer.delegate = self - statusView.actionToolbarContainer.delegate = self - // default hidden - threadMetaView.isHidden = true + statusView.delegate = self + + isAccessibilityElement = true + accessibilityElements = [statusView] + statusView.viewModel.$groupedAccessibilityLabel + .receive(on: DispatchQueue.main) + .sink { [weak self] accessibilityLabel in + guard let self = self else { return } + self.accessibilityLabel = accessibilityLabel + } + .store(in: &_disposeBag) } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) - - resetSeparatorLineLayout() - } - private func configure(isFiltered: Bool) { - statusView.alpha = isFiltered ? 0 : 1 - threadMetaView.alpha = isFiltered ? 0 : 1 - filteredLabel.isHidden = !isFiltered - isUserInteractionEnabled = !isFiltered + updateContainerViewMarginConstraints() } -} - -extension StatusTableViewCell { - - private func resetSeparatorLineLayout() { - separatorLineToEdgeLeadingLayoutConstraint.isActive = false - separatorLineToEdgeTrailingLayoutConstraint.isActive = false - separatorLineToMarginLeadingLayoutConstraint.isActive = false - separatorLineToMarginTrailingLayoutConstraint.isActive = false - - if traitCollection.userInterfaceIdiom == .phone { - // to edge - NSLayoutConstraint.activate([ - separatorLineToEdgeLeadingLayoutConstraint, - separatorLineToEdgeTrailingLayoutConstraint, - ]) - } else { - if traitCollection.horizontalSizeClass == .compact { - // to edge - NSLayoutConstraint.activate([ - separatorLineToEdgeLeadingLayoutConstraint, - separatorLineToEdgeTrailingLayoutConstraint, - ]) - } else { - // to margin - NSLayoutConstraint.activate([ - separatorLineToMarginLeadingLayoutConstraint, - separatorLineToMarginTrailingLayoutConstraint, - ]) - } - } + override func accessibilityActivate() -> Bool { + delegate?.tableViewCell(self, statusView: statusView, accessibilityActivate: Void()) + return true } } -// MARK: - MosaicImageViewContainerPresentable -extension StatusTableViewCell: MosaicImageViewContainerPresentable { - - var mosaicImageViewContainer: MosaicImageViewContainer { - return statusView.statusMosaicImageViewContainer +// MARK: - AdaptiveContainerMarginTableViewCell +extension StatusTableViewCell: AdaptiveContainerMarginTableViewCell { + var containerView: StatusView { + statusView } - - var isRevealing: Bool { - return statusView.isRevealing - } - } -// MARK: - UITableViewDelegate -extension StatusTableViewCell: UITableViewDelegate { - - func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool { - if tableView === statusView.pollTableView, let diffableDataSource = statusView.pollTableViewDataSource { - var pollID: String? - defer { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: indexPath: %s. PollID: %s", ((#file as NSString).lastPathComponent), #line, #function, indexPath.debugDescription, pollID ?? "<nil>") - } - guard let item = diffableDataSource.itemIdentifier(for: indexPath), - case let .option(objectID, _) = item, - let option = delegate?.managedObjectContext.object(with: objectID) as? PollOption else { - return false - } - pollID = option.poll.id - return !option.poll.expired - } else { - assertionFailure() - return true - } - } - - func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { - if tableView === statusView.pollTableView, let diffableDataSource = statusView.pollTableViewDataSource { - var pollID: String? - defer { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: indexPath: %s. PollID: %s", ((#file as NSString).lastPathComponent), #line, #function, indexPath.debugDescription, pollID ?? "<nil>") - } - - guard let context = delegate?.context else { return nil } - guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return nil } - guard let item = diffableDataSource.itemIdentifier(for: indexPath), - case let .option(objectID, _) = item, - let option = delegate?.managedObjectContext.object(with: objectID) as? PollOption else { - return nil - } - let poll = option.poll - pollID = poll.id - - // disallow select when: poll expired OR user voted remote OR user voted local - let userID = activeMastodonAuthenticationBox.userID - let didVotedRemote = (option.poll.votedBy ?? Set()).contains(where: { $0.id == userID }) - let votedOptions = poll.options.filter { option in - (option.votedBy ?? Set()).map { $0.id }.contains(userID) - } - let didVotedLocal = !votedOptions.isEmpty - - if poll.multiple { - guard !option.poll.expired, !didVotedRemote else { - return nil - } - } else { - guard !option.poll.expired, !didVotedRemote, !didVotedLocal else { - return nil - } - } - - return indexPath - } else { - assertionFailure() - return indexPath - } - } - - - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - if tableView === statusView.pollTableView { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: indexPath: %s", ((#file as NSString).lastPathComponent), #line, #function, indexPath.debugDescription) - delegate?.statusTableViewCell(self, pollTableView: statusView.pollTableView, didSelectRowAt: indexPath) - } else { - assertionFailure() - } - } - -} +// MARK: - StatusViewContainerTableViewCell +extension StatusTableViewCell: StatusViewContainerTableViewCell { } // MARK: - StatusViewDelegate -extension StatusTableViewCell: StatusViewDelegate { - - func statusView(_ statusView: StatusView, headerInfoLabelDidPressed label: UILabel) { - delegate?.statusTableViewCell(self, statusView: statusView, headerInfoLabelDidPressed: label) - } - - func statusView(_ statusView: StatusView, avatarImageViewDidPressed imageView: UIImageView) { - delegate?.statusTableViewCell(self, statusView: statusView, avatarImageViewDidPressed: imageView) - } - - func statusView(_ statusView: StatusView, revealContentWarningButtonDidPressed button: UIButton) { - delegate?.statusTableViewCell(self, statusView: statusView, revealContentWarningButtonDidPressed: button) - } - - func statusView(_ statusView: StatusView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { - delegate?.statusTableViewCell(self, statusView: statusView, contentWarningOverlayViewDidPressed: contentWarningOverlayView) - } - - func statusView(_ statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { - delegate?.statusTableViewCell(self, playerContainerView: playerContainerView, contentWarningOverlayViewDidPressed: contentWarningOverlayView) - } - - func statusView(_ statusView: StatusView, pollVoteButtonPressed button: UIButton) { - delegate?.statusTableViewCell(self, statusView: statusView, pollVoteButtonPressed: button) - } - - func statusView(_ statusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta) { - delegate?.statusTableViewCell(self, statusView: statusView, metaText: metaText, didSelectMeta: meta) - } - -} - -// MARK: - MosaicImageViewDelegate -extension StatusTableViewCell: MosaicImageViewContainerDelegate { - - func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) { - delegate?.statusTableViewCell(self, mosaicImageViewContainer: mosaicImageViewContainer, didTapImageView: imageView, atIndex: index) - } - - func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { - delegate?.statusTableViewCell(self, mosaicImageViewContainer: mosaicImageViewContainer, contentWarningOverlayViewDidPressed: contentWarningOverlayView) - } - -} - -// MARK: - ActionToolbarContainerDelegate -extension StatusTableViewCell: ActionToolbarContainerDelegate { - - func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, replayButtonDidPressed sender: UIButton) { - delegate?.statusTableViewCell(self, actionToolbarContainer: actionToolbarContainer, replyButtonDidPressed: sender) - } - - func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, reblogButtonDidPressed sender: UIButton) { - delegate?.statusTableViewCell(self, actionToolbarContainer: actionToolbarContainer, reblogButtonDidPressed: sender) - } - - func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, starButtonDidPressed sender: UIButton) { - delegate?.statusTableViewCell(self, actionToolbarContainer: actionToolbarContainer, likeButtonDidPressed: sender) - } - -} - -extension StatusTableViewCell { - override var accessibilityActivationPoint: CGPoint { - get { return .zero } - set { } - } -} +extension StatusTableViewCell: StatusViewDelegate { } diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCellDelegate.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCellDelegate.swift new file mode 100644 index 000000000..b4dbef431 --- /dev/null +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCellDelegate.swift @@ -0,0 +1,94 @@ +// +// StatusViewTableViewCellDelegate.swift +// Mastodon +// +// Created by MainasuK on 2022-1-13. +// + +import UIKit +import MetaTextKit +import MastodonUI + +// sourcery: protocolName = "StatusViewDelegate" +// sourcery: replaceOf = "statusView(statusView" +// sourcery: replaceWith = "delegate?.tableViewCell(self, statusView: statusView" +protocol StatusViewContainerTableViewCell: UITableViewCell, AutoGenerateProtocolRelayDelegate { + var delegate: StatusTableViewCellDelegate? { get } + var statusView: StatusView { get } +} + +// MARK: - AutoGenerateProtocolDelegate +// sourcery: protocolName = "StatusViewDelegate" +// sourcery: replaceOf = "statusView(_" +// sourcery: replaceWith = "func tableViewCell(_ cell: UITableViewCell," +protocol StatusTableViewCellDelegate: AnyObject, AutoGenerateProtocolDelegate { + // sourcery:inline:StatusTableViewCellDelegate.AutoGenerateProtocolDelegate + func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, headerDidPressed header: UIView) + func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, authorAvatarButtonDidPressed button: AvatarButton) + func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, contentSensitiveeToggleButtonDidPressed button: UIButton) + func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta) + func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, mediaGridContainerView: MediaGridContainerView, mediaView: MediaView, didSelectMediaViewAt index: Int) + func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, pollTableView tableView: UITableView, didSelectRowAt indexPath: IndexPath) + func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, pollVoteButtonPressed button: UIButton) + func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, actionToolbarContainer: ActionToolbarContainer, buttonDidPressed button: UIButton, action: ActionToolbarContainer.Action) + func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, menuButton button: UIButton, didSelectAction action: MastodonMenu.Action) + func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, spoilerOverlayViewDidPressed overlayView: SpoilerOverlayView) + func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, mediaGridContainerView: MediaGridContainerView, mediaSensitiveButtonDidPressed button: UIButton) + func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, accessibilityActivate: Void) + // sourcery:end +} + + +// MARK: - AutoGenerateProtocolDelegate +// Protocol Extension +extension StatusViewDelegate where Self: StatusViewContainerTableViewCell { + // sourcery:inline:StatusViewContainerTableViewCell.AutoGenerateProtocolRelayDelegate + func statusView(_ statusView: StatusView, headerDidPressed header: UIView) { + delegate?.tableViewCell(self, statusView: statusView, headerDidPressed: header) + } + + func statusView(_ statusView: StatusView, authorAvatarButtonDidPressed button: AvatarButton) { + delegate?.tableViewCell(self, statusView: statusView, authorAvatarButtonDidPressed: button) + } + + func statusView(_ statusView: StatusView, contentSensitiveeToggleButtonDidPressed button: UIButton) { + delegate?.tableViewCell(self, statusView: statusView, contentSensitiveeToggleButtonDidPressed: button) + } + + func statusView(_ statusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta) { + delegate?.tableViewCell(self, statusView: statusView, metaText: metaText, didSelectMeta: meta) + } + + func statusView(_ statusView: StatusView, mediaGridContainerView: MediaGridContainerView, mediaView: MediaView, didSelectMediaViewAt index: Int) { + delegate?.tableViewCell(self, statusView: statusView, mediaGridContainerView: mediaGridContainerView, mediaView: mediaView, didSelectMediaViewAt: index) + } + + func statusView(_ statusView: StatusView, pollTableView tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + delegate?.tableViewCell(self, statusView: statusView, pollTableView: tableView, didSelectRowAt: indexPath) + } + + func statusView(_ statusView: StatusView, pollVoteButtonPressed button: UIButton) { + delegate?.tableViewCell(self, statusView: statusView, pollVoteButtonPressed: button) + } + + func statusView(_ statusView: StatusView, actionToolbarContainer: ActionToolbarContainer, buttonDidPressed button: UIButton, action: ActionToolbarContainer.Action) { + delegate?.tableViewCell(self, statusView: statusView, actionToolbarContainer: actionToolbarContainer, buttonDidPressed: button, action: action) + } + + func statusView(_ statusView: StatusView, menuButton button: UIButton, didSelectAction action: MastodonMenu.Action) { + delegate?.tableViewCell(self, statusView: statusView, menuButton: button, didSelectAction: action) + } + + func statusView(_ statusView: StatusView, spoilerOverlayViewDidPressed overlayView: SpoilerOverlayView) { + delegate?.tableViewCell(self, statusView: statusView, spoilerOverlayViewDidPressed: overlayView) + } + + func statusView(_ statusView: StatusView, mediaGridContainerView: MediaGridContainerView, mediaSensitiveButtonDidPressed button: UIButton) { + delegate?.tableViewCell(self, statusView: statusView, mediaGridContainerView: mediaGridContainerView, mediaSensitiveButtonDidPressed: button) + } + + func statusView(_ statusView: StatusView, accessibilityActivate: Void) { + delegate?.tableViewCell(self, statusView: statusView, accessibilityActivate: accessibilityActivate) + } + // sourcery:end +} diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusThreadRootTableViewCell+ViewModel.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusThreadRootTableViewCell+ViewModel.swift new file mode 100644 index 000000000..9568aa80e --- /dev/null +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusThreadRootTableViewCell+ViewModel.swift @@ -0,0 +1,61 @@ +// +// StatusThreadRootTableViewCell+ViewModel.swift +// Mastodon +// +// Created by MainasuK on 2022-1-17. +// + +import UIKit +import CoreDataStack + +extension StatusThreadRootTableViewCell { + final class ViewModel { + let value: Value + + init(value: Value) { + self.value = value + } + + enum Value { + case status(Status) + } + } +} + +extension StatusThreadRootTableViewCell { + + func configure( + tableView: UITableView, + viewModel: ViewModel, + delegate: StatusTableViewCellDelegate? + ) { + if statusView.frame == .zero { + // set status view width + statusView.frame.size.width = tableView.frame.width - containerViewHorizontalMargin + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): did layout for new cell") + } + + switch viewModel.value { + case .status(let status): + statusView.configure(status: status) + } + + self.delegate = delegate + + statusView.viewModel.$isContentReveal + .removeDuplicates() + .dropFirst() + .receive(on: DispatchQueue.main) + .sink { [weak tableView, weak self] isContentReveal in + guard let tableView = tableView else { return } + guard let self = self else { return } + + guard self.contentView.window != nil else { return } + + tableView.beginUpdates() + tableView.endUpdates() + } + .store(in: &disposeBag) + } + +} diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusThreadRootTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusThreadRootTableViewCell.swift new file mode 100644 index 000000000..e27cc2dd3 --- /dev/null +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusThreadRootTableViewCell.swift @@ -0,0 +1,148 @@ +// +// StatusThreadRootTableViewCell.swift +// Mastodon +// +// Created by MainasuK on 2022-1-17. +// + +import os.log +import UIKit +import Combine +import MastodonAsset +import MastodonLocalization +import MastodonUI + +final class StatusThreadRootTableViewCell: UITableViewCell { + + static let marginForRegularHorizontalSizeClass: CGFloat = 64 + + let logger = Logger(subsystem: "StatusThreadRootTableViewCell", category: "View") + + weak var delegate: StatusTableViewCellDelegate? + var disposeBag = Set<AnyCancellable>() + + let statusView = StatusView() + let separatorLine = UIView.separatorLine + + var containerViewLeadingLayoutConstraint: NSLayoutConstraint! + var containerViewTrailingLayoutConstraint: NSLayoutConstraint! + + override func prepareForReuse() { + super.prepareForReuse() + + disposeBag.removeAll() + statusView.prepareForReuse() + } + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension StatusThreadRootTableViewCell { + + private func _init() { + selectionStyle = .none + + statusView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(statusView) + setupContainerViewMarginConstraints() + NSLayoutConstraint.activate([ + statusView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 16), + containerViewLeadingLayoutConstraint, + containerViewTrailingLayoutConstraint, + statusView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + ]) + statusView.setup(style: .plain) + updateContainerViewMarginConstraints() + + 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)).priority(.required - 1), + ]) + + statusView.delegate = self + + // a11y + statusView.contentMetaText.textView.isAccessibilityElement = false + statusView.contentMetaText.textView.isSelectable = true + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + updateContainerViewMarginConstraints() + } + +} + +extension StatusThreadRootTableViewCell { + + override var accessibilityElements: [Any]? { + get { + var elements = [ + statusView.headerContainerView, + statusView.avatarButton, + statusView.authorNameLabel, + statusView.menuButton, + statusView.authorUsernameLabel, + statusView.dateLabel, + statusView.contentSensitiveeToggleButton, + statusView.spoilerOverlayView, + statusView.contentMetaText.textView, + statusView.mediaGridContainerView, + statusView.pollTableView, + statusView.pollStatusStackView, + statusView.actionToolbarContainer, + statusView.statusMetricView + ] + + if !statusView.viewModel.isSensitive { + elements.removeAll(where: { $0 === statusView.contentSensitiveeToggleButton }) + } + + if statusView.viewModel.isContentReveal { + elements.removeAll(where: { $0 === statusView.spoilerOverlayView }) + } else { + elements.removeAll(where: { $0 === statusView.contentMetaText.textView }) + } + + if statusView.viewModel.pollItems.isEmpty { + elements.removeAll(where: { $0 === statusView.pollTableView }) + elements.removeAll(where: { $0 === statusView.pollStatusStackView }) + } + + return elements + } + set { } + } + +} + +extension StatusThreadRootTableViewCell: AdaptiveContainerMarginTableViewCell { + var containerView: StatusView { + statusView + } +} + + +// MARK: - StatusViewContainerTableViewCell +extension StatusThreadRootTableViewCell: StatusViewContainerTableViewCell { } + +// MARK: - StatusViewDelegate +extension StatusThreadRootTableViewCell: StatusViewDelegate { } diff --git a/Mastodon/Scene/Share/View/TableviewCell/ThreadReplyLoaderTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/ThreadReplyLoaderTableViewCell.swift index a819f301c..065a41281 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/ThreadReplyLoaderTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/ThreadReplyLoaderTableViewCell.swift @@ -8,6 +8,8 @@ import os.log import UIKit import Combine +import MastodonAsset +import MastodonLocalization protocol ThreadReplyLoaderTableViewCellDelegate: AnyObject { func threadReplyLoaderTableViewCell(_ cell: ThreadReplyLoaderTableViewCell, loadMoreButtonDidPressed button: UIButton) diff --git a/Mastodon/Scene/Share/View/TableviewCell/TimelineFooterTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/TimelineFooterTableViewCell.swift index 43dd2c6fa..a1b9fe083 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/TimelineFooterTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/TimelineFooterTableViewCell.swift @@ -6,6 +6,8 @@ // import UIKit +import MastodonAsset +import MastodonLocalization final class TimelineFooterTableViewCell: UITableViewCell { diff --git a/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift index da0b80fb4..29344eb28 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift @@ -7,6 +7,8 @@ import UIKit import Combine +import MastodonAsset +import MastodonLocalization class TimelineLoaderTableViewCell: UITableViewCell { diff --git a/Mastodon/Scene/Share/View/TableviewCell/TimelineMiddleLoaderTableViewCell+ViewModel.swift b/Mastodon/Scene/Share/View/TableviewCell/TimelineMiddleLoaderTableViewCell+ViewModel.swift new file mode 100644 index 000000000..406d2a7ec --- /dev/null +++ b/Mastodon/Scene/Share/View/TableviewCell/TimelineMiddleLoaderTableViewCell+ViewModel.swift @@ -0,0 +1,50 @@ +// +// TimelineMiddleLoaderTableViewCell+ViewModel.swift +// Mastodon +// +// Created by MainasuK on 2022-1-17. +// + +import UIKit +import Combine +import CoreDataStack + +extension TimelineMiddleLoaderTableViewCell { + class ViewModel { + var disposeBag = Set<AnyCancellable>() + + @Published var isFetching = false + } +} + +extension TimelineMiddleLoaderTableViewCell.ViewModel { + func bind(cell: TimelineMiddleLoaderTableViewCell) { + $isFetching + .sink { isFetching in + if isFetching { + cell.startAnimating() + } else { + cell.stopAnimating() + } + } + .store(in: &disposeBag) + } +} + + +extension TimelineMiddleLoaderTableViewCell { + func configure( + feed: Feed, + delegate: TimelineMiddleLoaderTableViewCellDelegate? + ) { + feed.publisher(for: \.isLoadingMore) + .sink { [weak self] isLoadingMore in + guard let self = self else { return } + self.viewModel.isFetching = isLoadingMore + } + .store(in: &disposeBag) + + self.delegate = delegate + } + +} diff --git a/Mastodon/Scene/Share/View/TableviewCell/TimelineMiddleLoaderTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/TimelineMiddleLoaderTableViewCell.swift index 4a0b623ef..a12920c59 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/TimelineMiddleLoaderTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/TimelineMiddleLoaderTableViewCell.swift @@ -11,13 +11,19 @@ import os.log import UIKit protocol TimelineMiddleLoaderTableViewCellDelegate: AnyObject { - func configure(cell: TimelineMiddleLoaderTableViewCell, upperTimelineStatusID: String?, timelineIndexobjectID:NSManagedObjectID?) func timelineMiddleLoaderTableViewCell(_ cell: TimelineMiddleLoaderTableViewCell, loadMoreButtonDidPressed button: UIButton) } final class TimelineMiddleLoaderTableViewCell: TimelineLoaderTableViewCell { + weak var delegate: TimelineMiddleLoaderTableViewCellDelegate? + private(set) lazy var viewModel: ViewModel = { + let viewModel = ViewModel() + viewModel.bind(cell: self) + return viewModel + }() + let topSawToothView = SawToothView() let bottomSawToothView = SawToothView() diff --git a/Mastodon/Scene/Share/View/TableviewCell/UserTableViewCell+ViewModel.swift b/Mastodon/Scene/Share/View/TableviewCell/UserTableViewCell+ViewModel.swift new file mode 100644 index 000000000..3ec85fa4a --- /dev/null +++ b/Mastodon/Scene/Share/View/TableviewCell/UserTableViewCell+ViewModel.swift @@ -0,0 +1,41 @@ +// +// UserTableViewCell+ViewModel.swift +// Mastodon +// +// Created by MainasuK on 2022-1-19. +// + +import UIKit +import CoreDataStack + +extension UserTableViewCell { + final class ViewModel { + let value: Value + + init(value: Value) { + self.value = value + } + + enum Value { + case user(MastodonUser) + // case status(Status) + } + } +} + +extension UserTableViewCell { + + func configure( + tableView: UITableView, + viewModel: ViewModel, + delegate: UserTableViewCellDelegate? + ) { + switch viewModel.value { + case .user(let user): + userView.configure(user: user) + } + + self.delegate = delegate + } + +} diff --git a/Mastodon/Scene/Share/View/TableviewCell/UserTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/UserTableViewCell.swift index 29e28415e..425226b71 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/UserTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/UserTableViewCell.swift @@ -5,13 +5,13 @@ // Created by Cirno MainasuK on 2021-11-1. // -import CoreData -import CoreDataStack -import MastodonSDK import UIKit -import MetaTextKit -import MastodonMeta -import FLAnimatedImage +import Combine +import CoreDataStack +import MastodonAsset +import MastodonLocalization +import MastodonUI +import MastodonSDK protocol UserTableViewCellDelegate: AnyObject { } @@ -19,25 +19,16 @@ 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 userView = UserView() let separatorLine = UIView.separatorLine + override func prepareForReuse() { + super.prepareForReuse() + + userView.prepareForReuse() + } + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) _init() @@ -53,79 +44,23 @@ final class UserTableViewCell: UITableViewCell { 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) + userView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(userView) 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) + userView.topAnchor.constraint(equalTo: contentView.topAnchor), + userView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), + userView.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), + userView.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.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), + separatorLine.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), separatorLine.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - separatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView)), + separatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView)).priority(.required - 1), ]) - - - 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/View/ToolBar/ActionToolBarContainer.swift b/Mastodon/Scene/Share/View/ToolBar/ActionToolBarContainer.swift deleted file mode 100644 index f771f8bb2..000000000 --- a/Mastodon/Scene/Share/View/ToolBar/ActionToolBarContainer.swift +++ /dev/null @@ -1,229 +0,0 @@ -// -// ActionToolBarContainer.swift -// Mastodon -// -// Created by sxiaojian on 2021/2/1. -// - -import os.log -import UIKit - -protocol ActionToolbarContainerDelegate: AnyObject { - func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, replayButtonDidPressed sender: UIButton) - func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, reblogButtonDidPressed sender: UIButton) - func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, starButtonDidPressed sender: UIButton) -} - - -final class ActionToolbarContainer: UIView { - - let replyButton = HighlightDimmableButton() - let reblogButton = HighlightDimmableButton() - let favoriteButton = HighlightDimmableButton() - let moreButton = HighlightDimmableButton() - - var isReblogButtonHighlight: Bool = false { - didSet { isReblogButtonHighlightStateDidChange(to: isReblogButtonHighlight) } - } - - var isFavoriteButtonHighlight: Bool = false { - didSet { isFavoriteButtonHighlightStateDidChange(to: isFavoriteButtonHighlight) } - } - - weak var delegate: ActionToolbarContainerDelegate? - - private let container = UIStackView() - private var style: Style? - - override init(frame: CGRect) { - super.init(frame: frame) - _init() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - _init() - } - -} - -extension ActionToolbarContainer { - - private func _init() { - container.translatesAutoresizingMaskIntoConstraints = false - addSubview(container) - NSLayoutConstraint.activate([ - container.topAnchor.constraint(equalTo: topAnchor), - container.leadingAnchor.constraint(equalTo: leadingAnchor), - trailingAnchor.constraint(equalTo: container.trailingAnchor), - bottomAnchor.constraint(equalTo: container.bottomAnchor), - ]) - - replyButton.addTarget(self, action: #selector(ActionToolbarContainer.replyButtonDidPressed(_:)), for: .touchUpInside) - reblogButton.addTarget(self, action: #selector(ActionToolbarContainer.reblogButtonDidPressed(_:)), for: .touchUpInside) - favoriteButton.addTarget(self, action: #selector(ActionToolbarContainer.favoriteButtonDidPressed(_:)), for: .touchUpInside) - } - -} - -extension ActionToolbarContainer { - - enum Style { - case inline - case plain - - var buttonTitleImagePadding: CGFloat { - switch self { - case .inline: return 4.0 - case .plain: return 0 - } - } - } - - func configure(for style: Style) { - guard needsConfigure(for: style) else { - return - } - - self.style = style - container.arrangedSubviews.forEach { subview in - container.removeArrangedSubview(subview) - subview.removeFromSuperview() - } - - let buttons = [replyButton, reblogButton, favoriteButton, moreButton] - buttons.forEach { button in - button.tintColor = Asset.Colors.Button.actionToolbar.color - button.titleLabel?.font = .monospacedDigitSystemFont(ofSize: 12, weight: .regular) - button.setTitle("", for: .normal) - button.setTitleColor(.secondaryLabel, for: .normal) - button.expandEdgeInsets = UIEdgeInsets(top: -10, left: -10, bottom: -10, right: -10) - button.setInsets(forContentPadding: .zero, imageTitlePadding: style.buttonTitleImagePadding) - } - // add more expand for menu button - moreButton.expandEdgeInsets = UIEdgeInsets(top: -10, left: -20, bottom: -10, right: -20) - - let replyImage = UIImage(systemName: "arrowshape.turn.up.left.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 17, weight: .ultraLight))!.withRenderingMode(.alwaysTemplate) - let reblogImage = UIImage(systemName: "arrow.2.squarepath", withConfiguration: UIImage.SymbolConfiguration(pointSize: 17, weight: .bold))!.withRenderingMode(.alwaysTemplate) - let starImage = UIImage(systemName: "star.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 17, weight: .bold))!.withRenderingMode(.alwaysTemplate) - let moreImage = UIImage(systemName: "ellipsis", withConfiguration: UIImage.SymbolConfiguration(pointSize: 17, weight: .bold))!.withRenderingMode(.alwaysTemplate) - - replyButton.accessibilityLabel = L10n.Common.Controls.Status.Actions.reply - reblogButton.accessibilityLabel = L10n.Common.Controls.Status.Actions.reblog // needs update to follow state - favoriteButton.accessibilityLabel = L10n.Common.Controls.Status.Actions.favorite // needs update to follow state - moreButton.accessibilityLabel = L10n.Common.Controls.Status.Actions.menu - - switch style { - case .inline: - buttons.forEach { button in - button.contentHorizontalAlignment = .leading - } - replyButton.setImage(replyImage, for: .normal) - reblogButton.setImage(reblogImage, for: .normal) - favoriteButton.setImage(starImage, for: .normal) - moreButton.setImage(moreImage, for: .normal) - - container.axis = .horizontal - container.distribution = .fill - - replyButton.translatesAutoresizingMaskIntoConstraints = false - reblogButton.translatesAutoresizingMaskIntoConstraints = false - favoriteButton.translatesAutoresizingMaskIntoConstraints = false - moreButton.translatesAutoresizingMaskIntoConstraints = false - container.addArrangedSubview(replyButton) - container.addArrangedSubview(reblogButton) - container.addArrangedSubview(favoriteButton) - container.addArrangedSubview(moreButton) - NSLayoutConstraint.activate([ - replyButton.heightAnchor.constraint(equalToConstant: 44).priority(.defaultHigh), - replyButton.heightAnchor.constraint(equalTo: reblogButton.heightAnchor).priority(.defaultHigh), - replyButton.heightAnchor.constraint(equalTo: favoriteButton.heightAnchor).priority(.defaultHigh), - replyButton.heightAnchor.constraint(equalTo: moreButton.heightAnchor).priority(.defaultHigh), - replyButton.widthAnchor.constraint(equalTo: reblogButton.widthAnchor).priority(.defaultHigh), - replyButton.widthAnchor.constraint(equalTo: favoriteButton.widthAnchor).priority(.defaultHigh), - ]) - moreButton.setContentHuggingPriority(.defaultHigh, for: .horizontal) - moreButton.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) - - case .plain: - buttons.forEach { button in - button.contentHorizontalAlignment = .center - } - replyButton.setImage(replyImage, for: .normal) - reblogButton.setImage(reblogImage, for: .normal) - favoriteButton.setImage(starImage, for: .normal) - - container.axis = .horizontal - container.spacing = 8 - container.distribution = .fillEqually - - container.addArrangedSubview(replyButton) - container.addArrangedSubview(reblogButton) - container.addArrangedSubview(favoriteButton) - } - } - - private func needsConfigure(for style: Style) -> Bool { - guard let oldStyle = self.style else { return true } - return oldStyle != style - } - - private func isReblogButtonHighlightStateDidChange(to isHighlight: Bool) { - let tintColor = isHighlight ? Asset.Colors.successGreen.color : Asset.Colors.Button.actionToolbar.color - reblogButton.tintColor = tintColor - reblogButton.setTitleColor(tintColor, for: .normal) - reblogButton.setTitleColor(tintColor, for: .highlighted) - } - - private func isFavoriteButtonHighlightStateDidChange(to isHighlight: Bool) { - let tintColor = isHighlight ? Asset.Colors.systemOrange.color : Asset.Colors.Button.actionToolbar.color - favoriteButton.tintColor = tintColor - favoriteButton.setTitleColor(tintColor, for: .normal) - favoriteButton.setTitleColor(tintColor, for: .highlighted) - } -} - -extension ActionToolbarContainer { - - @objc private func replyButtonDidPressed(_ sender: UIButton) { - os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - delegate?.actionToolbarContainer(self, replayButtonDidPressed: sender) - } - - @objc private func reblogButtonDidPressed(_ sender: UIButton) { - os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - delegate?.actionToolbarContainer(self, reblogButtonDidPressed: sender) - } - - @objc private func favoriteButtonDidPressed(_ sender: UIButton) { - os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - delegate?.actionToolbarContainer(self, starButtonDidPressed: sender) - } - -} - -extension ActionToolbarContainer { - - override var accessibilityElements: [Any]? { - get { [replyButton, reblogButton, favoriteButton, moreButton] } - set { } - } -} - -#if DEBUG -import SwiftUI - -struct ActionToolbarContainer_Previews: PreviewProvider { - static var previews: some View { - Group { - UIViewPreview(width: 300) { - let toolbar = ActionToolbarContainer() - toolbar.configure(for: .inline) - return toolbar - } - .previewLayout(.fixed(width: 300, height: 44)) - .previewDisplayName("Inline") - } - } -} -#endif diff --git a/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift b/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift deleted file mode 100644 index c31802211..000000000 --- a/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift +++ /dev/null @@ -1,117 +0,0 @@ -// -// AudioContainerViewModel.swift -// Mastodon -// -// Created by sxiaojian on 2021/3/9. -// - -import CoreDataStack -import Foundation -import UIKit - -class AudioContainerViewModel { - - static func configure( - cell: StatusCell, - audioAttachment: Attachment, - audioService: AudioPlaybackService - ) { - guard let duration = audioAttachment.meta?.original?.duration else { return } - let audioView = cell.statusView.audioView - audioView.timeLabel.text = duration.asString(style: .positional) - - audioView.playButton.publisher(for: .touchUpInside) - .sink { [weak audioService] _ in - guard let audioService = audioService else { return } - if audioAttachment === audioService.attachment { - if audioService.isPlaying() { - audioService.pause() - } else { - audioService.resume() - } - if audioService.currentTimeSubject.value == 0 { - audioService.playAudio(audioAttachment: audioAttachment) - } - } else { - audioService.playAudio(audioAttachment: audioAttachment) - } - } - .store(in: &cell.disposeBag) - audioView.slider.maximumValue = Float(duration) - audioView.slider.publisher(for: .valueChanged) - .sink { [weak audioService] slider in - guard let audioService = audioService else { return } - let slider = slider as! UISlider - let time = TimeInterval(slider.value) - audioService.seekToTime(time: time) - } - .store(in: &cell.disposeBag) - observePlayer(cell: cell, audioAttachment: audioAttachment, audioService: audioService) - if audioAttachment != audioService.attachment { - configureAudioView(audioView: audioView, audioAttachment: audioAttachment, playbackState: .stopped) - } - } - - static func observePlayer( - cell: StatusCell, - audioAttachment: Attachment, - audioService: AudioPlaybackService - ) { - let audioView = cell.statusView.audioView - var lastCurrentTimeSubject: TimeInterval? - audioService.currentTimeSubject - .throttle(for: 0.008, scheduler: DispatchQueue.main, latest: true) - .compactMap { [weak audioService] time -> TimeInterval? in - defer { - lastCurrentTimeSubject = time - } - guard audioAttachment === audioService?.attachment else { return nil } - // guard let duration = audioAttachment.meta?.original?.duration else { return nil } - - if let lastCurrentTimeSubject = lastCurrentTimeSubject, time != 0.0 { - guard abs(time - lastCurrentTimeSubject) < 0.5 else { return nil } // debounce - } - - guard !audioView.slider.isTracking else { return nil } - return TimeInterval(time) - } - .sink(receiveValue: { time in - audioView.timeLabel.text = time.asString(style: .positional) - audioView.slider.setValue(Float(time), animated: true) - }) - .store(in: &cell.disposeBag) - audioService.playbackState - .receive(on: DispatchQueue.main) - .sink(receiveValue: { playbackState in - if audioAttachment === audioService.attachment { - configureAudioView(audioView: audioView, audioAttachment: audioAttachment, playbackState: playbackState) - } else { - configureAudioView(audioView: audioView, audioAttachment: audioAttachment, playbackState: .stopped) - } - }) - .store(in: &cell.disposeBag) - } - - static func configureAudioView( - audioView: AudioContainerView, - audioAttachment: Attachment, - playbackState: PlaybackState - ) { - switch playbackState { - case .stopped: - audioView.playButton.isSelected = false - audioView.slider.isUserInteractionEnabled = false - audioView.slider.setValue(0, animated: false) - case .paused: - audioView.playButton.isSelected = false - audioView.slider.isUserInteractionEnabled = true - case .playing, .readyToPlay: - audioView.playButton.isSelected = true - audioView.slider.isUserInteractionEnabled = true - default: - assertionFailure() - } - guard let duration = audioAttachment.meta?.original?.duration else { return } - audioView.timeLabel.text = duration.asString(style: .positional) - } -} diff --git a/Mastodon/Scene/Share/ViewModel/MosaicImageViewModel.swift b/Mastodon/Scene/Share/ViewModel/MosaicImageViewModel.swift deleted file mode 100644 index 5ceb87818..000000000 --- a/Mastodon/Scene/Share/ViewModel/MosaicImageViewModel.swift +++ /dev/null @@ -1,55 +0,0 @@ -// -// MosaicImageViewModel.swift -// Mastodon -// -// Created by Cirno MainasuK on 2021-2-23. -// - -import UIKit -import Combine -import CoreDataStack - -struct MosaicImageViewModel { - - let metas: [MosaicMeta] - - init(mediaAttachments: [Attachment]) { - var metas: [MosaicMeta] = [] - for element in mediaAttachments where element.type == .image { - guard let meta = element.meta, - let width = meta.original?.width, - let height = meta.original?.height, - let url = URL(string: element.url) else { - continue - } - let mosaicMeta = MosaicMeta( - previewURL: element.previewURL.flatMap { URL(string: $0) }, - url: url, - size: CGSize(width: width, height: height), - blurhash: element.blurhash, - altText: element.descriptionString - ) - metas.append(mosaicMeta) - } - self.metas = metas - } - -} - -struct MosaicMeta { - static let edgeMaxLength: CGFloat = 20 - - let previewURL: URL? - let url: URL - let size: CGSize - let blurhash: String? - let altText: String? - - func blurhashImagePublisher() -> AnyPublisher<UIImage?, Never> { - guard let blurhash = blurhash else { - return Just(nil).eraseToAnyPublisher() - } - return AppContext.shared.blurhashImageCacheService.image(blurhash: blurhash, size: size, url: url) - } - -} diff --git a/Mastodon/Scene/Share/ViewModel/VideoPlayerViewModel.swift b/Mastodon/Scene/Share/ViewModel/VideoPlayerViewModel.swift deleted file mode 100644 index 61a437e02..000000000 --- a/Mastodon/Scene/Share/ViewModel/VideoPlayerViewModel.swift +++ /dev/null @@ -1,193 +0,0 @@ -// -// VideoPlayerViewModel.swift -// Mastodon -// -// Created by xiaojian sun on 2021/3/10. -// - -import AVKit -import Combine -import CoreDataStack -import os.log -import UIKit - -final class VideoPlayerViewModel { - var disposeBag = Set<AnyCancellable>() - - static let appWillPlayVideoNotification = NSNotification.Name(rawValue: "org.joinmastodon.app.video-playback-service.appWillPlayVideo") - // input - let previewImageURL: URL? - let videoURL: URL - let videoSize: CGSize - let videoKind: Kind - - var isTransitioning = false - var isFullScreenPresentationing = false - var isPlayingWhenEndDisplaying = false - - // prevent player state flick when tableView reload - private typealias Play = Bool - private let debouncePlayingState = PassthroughSubject<Play, Never>() - - private var updateDate = Date() - - // output - let player: AVPlayer - private(set) var looper: AVPlayerLooper? // works with AVQueuePlayer (iOS 10+) - - private var timeControlStatusObservation: NSKeyValueObservation? - let timeControlStatus = CurrentValueSubject<AVPlayer.TimeControlStatus, Never>(.paused) - let playbackState = CurrentValueSubject<PlaybackState, Never>(PlaybackState.unknown) - - init(previewImageURL: URL?, videoURL: URL, videoSize: CGSize, videoKind: VideoPlayerViewModel.Kind) { - self.previewImageURL = previewImageURL - self.videoURL = videoURL - self.videoSize = videoSize - self.videoKind = videoKind - - let playerItem = AVPlayerItem(url: videoURL) - let player = videoKind == .gif ? AVQueuePlayer(playerItem: playerItem) : AVPlayer(playerItem: playerItem) - player.isMuted = true - self.player = player - - if videoKind == .gif { - setupLooper() - } - - timeControlStatusObservation = player.observe(\.timeControlStatus, options: [.initial, .new]) { [weak self] player, _ in - guard let self = self else { return } - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: player state: %s", (#file as NSString).lastPathComponent, #line, #function, player.timeControlStatus.debugDescription) - self.timeControlStatus.value = player.timeControlStatus - } - - player.publisher(for: \.status, options: [.initial, .new]) - .sink(receiveValue: { [weak self] status in - guard let self = self else { return } - switch status { - case .failed: - self.playbackState.value = .failed - case .readyToPlay: - self.playbackState.value = .readyToPlay - case .unknown: - self.playbackState.value = .unknown - @unknown default: - assertionFailure() - } - }) - .store(in: &disposeBag) - - timeControlStatus - .sink { [weak self] timeControlStatus in - guard let self = self else { return } - - // emit playing event - if timeControlStatus == .playing { - NotificationCenter.default.post(name: VideoPlayerViewModel.appWillPlayVideoNotification, object: nil) - } - - switch timeControlStatus { - case .paused: - self.playbackState.value = .paused - case .waitingToPlayAtSpecifiedRate: - self.playbackState.value = .buffering - case .playing: - self.playbackState.value = .playing - @unknown default: - assertionFailure() - self.playbackState.value = .unknown - } - } - .store(in: &disposeBag) - - debouncePlayingState - .debounce(for: 0.3, scheduler: DispatchQueue.main) - .sink { [weak self] isPlay in - guard let self = self else { return } - isPlay ? self.play() : self.pause() - } - .store(in: &disposeBag) - - let sessionName = videoKind == .gif ? "GIF" : "Video" - playbackState - .receive(on: RunLoop.main) - .sink { [weak self] status in - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: %s status: %s", ((#file as NSString).lastPathComponent), #line, #function, sessionName, status.description) - guard let self = self else { return } - - // only update audio session for video - guard self.videoKind == .video else { return } - switch status { - case .unknown, .buffering, .readyToPlay: - break - case .playing: - try? AVAudioSession.sharedInstance().setCategory(.playback) - try? AVAudioSession.sharedInstance().setActive(true) - case .paused, .stopped, .failed: - try? AVAudioSession.sharedInstance().setCategory(.ambient) // set to ambient to allow mixed (needed for GIFV) - try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) - } - } - .store(in: &disposeBag) - } - - deinit { - timeControlStatusObservation = nil - } -} - -extension VideoPlayerViewModel { - enum Kind { - case gif - case video - } -} - -extension VideoPlayerViewModel { - func setupLooper() { - guard looper == nil, let queuePlayer = player as? AVQueuePlayer else { return } - guard let templateItem = queuePlayer.items().first else { return } - looper = AVPlayerLooper(player: queuePlayer, templateItem: templateItem) - } - - func play() { - player.play() - updateDate = Date() - } - - func pause() { - player.pause() - updateDate = Date() - } - - func willDisplay() { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: url: %s", (#file as NSString).lastPathComponent, #line, #function, videoURL.debugDescription) - - switch videoKind { - case .gif: - play() // always auto play GIF - case .video: - guard isPlayingWhenEndDisplaying else { return } - // mute before resume - if updateDate.timeIntervalSinceNow < -3 { - player.isMuted = true - } - debouncePlayingState.send(true) - } - - updateDate = Date() - } - - func didEndDisplaying() { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: url: %s", (#file as NSString).lastPathComponent, #line, #function, videoURL.debugDescription) - - isPlayingWhenEndDisplaying = timeControlStatus.value != .paused - switch videoKind { - case .gif: - pause() // always pause GIF immediately - case .video: - debouncePlayingState.send(false) - } - - updateDate = Date() - } -} diff --git a/Mastodon/Scene/SuggestionAccount/CollectionViewCell/SuggestionAccountCollectionViewCell.swift b/Mastodon/Scene/SuggestionAccount/CollectionViewCell/SuggestionAccountCollectionViewCell.swift index a973e1c52..2fb467fbd 100644 --- a/Mastodon/Scene/SuggestionAccount/CollectionViewCell/SuggestionAccountCollectionViewCell.swift +++ b/Mastodon/Scene/SuggestionAccount/CollectionViewCell/SuggestionAccountCollectionViewCell.swift @@ -8,6 +8,8 @@ import CoreDataStack import Foundation import UIKit +import MastodonAsset +import MastodonLocalization class SuggestionAccountCollectionViewCell: UICollectionViewCell { let imageView: UIImageView = { diff --git a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift index d27c1fbe5..07c27a721 100644 --- a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift +++ b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift @@ -11,14 +11,47 @@ import CoreDataStack import Foundation import OSLog import UIKit +import MastodonAsset +import MastodonLocalization class SuggestionAccountViewController: UIViewController, NeedsDependency { + + static let collectionViewHeight: CGFloat = 24 + 64 + 24 + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } var disposeBag = Set<AnyCancellable>() - var viewModel: SuggestionAccountViewModel! + + private static func createCollectionViewLayout() -> UICollectionViewLayout { + let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(64), heightDimension: .absolute(64)) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let group = NSCollectionLayoutGroup.horizontal(layoutSize: itemSize, subitems: [item]) + + let section = NSCollectionLayoutSection(group: group) + section.contentInsets = NSDirectionalEdgeInsets(top: 24, leading: 0, bottom: 24, trailing: 0) + section.orthogonalScrollingBehavior = .continuous + section.contentInsetsReference = .readableContent + section.interGroupSpacing = 16 + + return UICollectionViewCompositionalLayout(section: section) + } + + let collectionView: UICollectionView = { + let collectionViewLayout = SuggestionAccountViewController.createCollectionViewLayout() + let view = ControlContainableCollectionView( + frame: .zero, + collectionViewLayout: collectionViewLayout + ) + view.register(SuggestionAccountCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: SuggestionAccountCollectionViewCell.self)) + view.backgroundColor = .clear + view.showsHorizontalScrollIndicator = false + view.showsVerticalScrollIndicator = false + view.layer.masksToBounds = false + return view + }() let tableView: UITableView = { let tableView = ControlContainableTableView() @@ -30,34 +63,6 @@ class SuggestionAccountViewController: UIViewController, NeedsDependency { return tableView }() - lazy var tableHeader: UIView = { - let view = UIView() - view.backgroundColor = ThemeService.shared.currentTheme.value.systemGroupedBackgroundColor - view.frame = CGRect(origin: .zero, size: CGSize(width: tableView.frame.width, height: 156)) - return view - }() - - let followExplainLabel: UILabel = { - let label = UILabel() - label.text = L10n.Scene.SuggestionAccount.followExplain - label.textColor = Asset.Colors.Label.primary.color - label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .regular)) - label.numberOfLines = 0 - return label - }() - - let selectedCollectionView: UICollectionView = { - let flowLayout = UICollectionViewFlowLayout() - flowLayout.scrollDirection = .horizontal - let view = ControlContainableCollectionView(frame: .zero, collectionViewLayout: flowLayout) - view.register(SuggestionAccountCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: SuggestionAccountCollectionViewCell.self)) - view.backgroundColor = .clear - view.showsHorizontalScrollIndicator = false - view.showsVerticalScrollIndicator = false - view.layer.masksToBounds = false - return view - }() - deinit { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", (#file as NSString).lastPathComponent, #line, #function) } @@ -69,7 +74,7 @@ extension SuggestionAccountViewController { setupBackgroundColor(theme: ThemeService.shared.currentTheme.value) ThemeService.shared.currentTheme - .receive(on: RunLoop.main) + .receive(on: DispatchQueue.main) .sink { [weak self] theme in guard let self = self else { return } self.setupBackgroundColor(theme: theme) @@ -77,151 +82,126 @@ extension SuggestionAccountViewController { .store(in: &disposeBag) title = L10n.Scene.SuggestionAccount.title - navigationItem.rightBarButtonItem - = UIBarButtonItem(barButtonSystemItem: UIBarButtonItem.SystemItem.done, - target: self, - action: #selector(SuggestionAccountViewController.doneButtonDidClick(_:))) + navigationItem.rightBarButtonItem = UIBarButtonItem( + barButtonSystemItem: UIBarButtonItem.SystemItem.done, + target: self, + action: #selector(SuggestionAccountViewController.doneButtonDidClick(_:)) + ) + + collectionView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(collectionView) + NSLayoutConstraint.activate([ + collectionView.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor), + collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + collectionView.heightAnchor.constraint(equalToConstant: SuggestionAccountViewController.collectionViewHeight), + ]) + defer { view.bringSubviewToFront(collectionView) } - tableView.delegate = self tableView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(tableView) NSLayoutConstraint.activate([ - tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.topAnchor.constraint(equalTo: collectionView.bottomAnchor), tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), ]) - viewModel.diffableDataSource = RecommendAccountSection.tableViewDiffableDataSource( - for: tableView, - managedObjectContext: context.managedObjectContext, - viewModel: viewModel, - delegate: self + + collectionView.delegate = self + viewModel.setupDiffableDataSource( + collectionView: collectionView + ) + + tableView.delegate = self + viewModel.setupDiffableDataSource( + tableView: tableView, + suggestionAccountTableViewCellDelegate: self ) - - viewModel.collectionDiffableDataSource = SelectedAccountSection.collectionViewDiffableDataSource(for: selectedCollectionView, managedObjectContext: context.managedObjectContext) - - viewModel.accounts - .receive(on: DispatchQueue.main) - .sink { [weak self] accounts in - guard let self = self else { return } - self.setupHeader(accounts: accounts) - } - .store(in: &disposeBag) } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + tableView.deselectRow(with: transitionCoordinator, animated: animated) - viewModel.checkAccountsFollowState() - } - - override func viewWillLayoutSubviews() { - super.viewWillLayoutSubviews() - let avatarImageViewHeight: Double = 56 - let avatarImageViewCount = Int(floor((Double(view.frame.width) - 20) / (avatarImageViewHeight + 15))) - viewModel.headerPlaceholderCount.value = avatarImageViewCount - } - - func setupHeader(accounts: [NSManagedObjectID]) { - if accounts.isEmpty { - return - } - followExplainLabel.translatesAutoresizingMaskIntoConstraints = false - tableHeader.addSubview(followExplainLabel) - NSLayoutConstraint.activate([ - followExplainLabel.topAnchor.constraint(equalTo: tableHeader.topAnchor, constant: 20), - followExplainLabel.leadingAnchor.constraint(equalTo: tableHeader.leadingAnchor, constant: 20), - tableHeader.trailingAnchor.constraint(equalTo: followExplainLabel.trailingAnchor, constant: 20), - ]) - - selectedCollectionView.translatesAutoresizingMaskIntoConstraints = false - tableHeader.addSubview(selectedCollectionView) - NSLayoutConstraint.activate([ - selectedCollectionView.frameLayoutGuide.topAnchor.constraint(equalTo: followExplainLabel.topAnchor, constant: 20), - selectedCollectionView.frameLayoutGuide.leadingAnchor.constraint(equalTo: tableHeader.leadingAnchor, constant: 20), - selectedCollectionView.frameLayoutGuide.trailingAnchor.constraint(equalTo: tableHeader.trailingAnchor), - selectedCollectionView.frameLayoutGuide.bottomAnchor.constraint(equalTo: tableHeader.bottomAnchor), - ]) - selectedCollectionView.delegate = self - - tableView.tableHeaderView = tableHeader } private func setupBackgroundColor(theme: Theme) { view.backgroundColor = theme.systemBackgroundColor - tableHeader.backgroundColor = theme.systemGroupedBackgroundColor + collectionView.backgroundColor = theme.systemGroupedBackgroundColor } } -extension SuggestionAccountViewController: UICollectionViewDelegateFlowLayout { - func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat { - 15 - } - - func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { - CGSize(width: 56, height: 56) - } +// MARK: - UICollectionViewDelegateFlowLayout +extension SuggestionAccountViewController: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - guard let diffableDataSource = viewModel.collectionDiffableDataSource else { return } - guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } - switch item { - case .accountObjectID(let accountObjectID): - let mastodonUser = context.managedObjectContext.object(with: accountObjectID) as! MastodonUser - let viewModel = ProfileViewModel(context: context, optionalMastodonUser: mastodonUser) - DispatchQueue.main.async { - self.coordinator.present(scene: .profile(viewModel: viewModel), from: self, transition: .show) - } - default: - break - } +// guard let diffableDataSource = viewModel.collectionDiffableDataSource else { return } +// guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } +// switch item { +// case .accountObjectID(let accountObjectID): +// let mastodonUser = context.managedObjectContext.object(with: accountObjectID) as! MastodonUser +// let viewModel = ProfileViewModel(context: context, optionalMastodonUser: mastodonUser) +// DispatchQueue.main.async { +// self.coordinator.present(scene: .profile(viewModel: viewModel), from: self, transition: .show) +// } +// default: +// break +// } } } +// MARK: - UITableViewDelegate extension SuggestionAccountViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - guard let diffableDataSource = viewModel.diffableDataSource else { return } - guard let objectID = diffableDataSource.itemIdentifier(for: indexPath) else { return } - let mastodonUser = context.managedObjectContext.object(with: objectID) as! MastodonUser - let viewModel = ProfileViewModel(context: context, optionalMastodonUser: mastodonUser) - DispatchQueue.main.async { - self.coordinator.present(scene: .profile(viewModel: viewModel), from: self, transition: .show) + guard let tableViewDiffableDataSource = viewModel.tableViewDiffableDataSource else { return } + guard let item = tableViewDiffableDataSource.itemIdentifier(for: indexPath) else { return } + switch item { + case .account(let record): + guard let account = record.object(in: context.managedObjectContext) else { return } + let cachedProfileViewModel = CachedProfileViewModel(context: context, mastodonUser: account) + coordinator.present( + scene: .profile(viewModel: cachedProfileViewModel), + from: self, + transition: .show + ) } } } extension SuggestionAccountViewController: SuggestionAccountTableViewCellDelegate { - func accountButtonPressed(objectID: NSManagedObjectID, cell: SuggestionAccountTableViewCell) { - let selected = !viewModel.selectedAccounts.value.contains(objectID) - cell.startAnimating() - viewModel.followAction(objectID: objectID)? - .sink(receiveCompletion: { [weak self] completion in - guard let self = self else { return } - cell.stopAnimating() - switch completion { - case .failure(let error): - os_log("%{public}s[%{public}ld], %{public}s: follow failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) - case .finished: - var selectedAccounts = self.viewModel.selectedAccounts.value - if selected { - selectedAccounts.append(objectID) - } else { - selectedAccounts.removeAll { $0 == objectID } - } - cell.button.isSelected = selected - self.viewModel.selectedAccounts.value = selectedAccounts + func suggestionAccountTableViewCell( + _ cell: SuggestionAccountTableViewCell, + friendshipDidPressed button: UIButton + ) { + guard let tableViewDiffableDataSource = viewModel.tableViewDiffableDataSource else { return } + guard let indexPath = tableView.indexPath(for: cell) else { return } + guard let item = tableViewDiffableDataSource.itemIdentifier(for: indexPath) else { return } + guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } + + switch item { + case .account(let user): + Task { @MainActor in + cell.startAnimating() + do { + try await DataSourceFacade.responseToUserFollowAction( + dependency: self, + user: user, + authenticationBox: authenticationBox + ) + } catch { + // do noting } - }, receiveValue: { _ in - }) - .store(in: &disposeBag) + cell.stopAnimating() + } // end Task + } } } extension SuggestionAccountViewController { @objc func doneButtonDidClick(_ sender: UIButton) { dismiss(animated: true, completion: nil) - if viewModel.selectedAccounts.value.count > 0 { - viewModel.delegate?.homeTimelineNeedRefresh.send() - } +// if viewModel.selectedAccounts.value.count > 0 { +// viewModel.delegate?.homeTimelineNeedRefresh.send() +// } } } diff --git a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel+Diffable.swift b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel+Diffable.swift new file mode 100644 index 000000000..4496b9f0a --- /dev/null +++ b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel+Diffable.swift @@ -0,0 +1,84 @@ +// +// SuggestionAccountViewModel+Diffable.swift +// Mastodon +// +// Created by MainasuK on 2022-2-10. +// + +import UIKit + +extension SuggestionAccountViewModel { + + func setupDiffableDataSource( + tableView: UITableView, + suggestionAccountTableViewCellDelegate: SuggestionAccountTableViewCellDelegate + ) { + tableViewDiffableDataSource = RecommendAccountSection.tableViewDiffableDataSource( + tableView: tableView, + context: context, + configuration: RecommendAccountSection.Configuration( + suggestionAccountTableViewCellDelegate: suggestionAccountTableViewCellDelegate + ) + ) + + userFetchedResultsController.$records + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] records in + guard let self = self else { return } + guard let tableViewDiffableDataSource = self.tableViewDiffableDataSource else { return } + + var snapshot = NSDiffableDataSourceSnapshot<RecommendAccountSection, RecommendAccountItem>() + snapshot.appendSections([.main]) + let items: [RecommendAccountItem] = records.map { RecommendAccountItem.account($0) } + snapshot.appendItems(items, toSection: .main) + + if #available(iOS 15.0, *) { + tableViewDiffableDataSource.applySnapshotUsingReloadData(snapshot, completion: nil) + } else { + // Fallback on earlier versions + tableViewDiffableDataSource.applySnapshot(snapshot, animated: false, completion: nil) + } + } + .store(in: &disposeBag) + } + + func setupDiffableDataSource( + collectionView: UICollectionView + ) { + collectionViewDiffableDataSource = SelectedAccountSection.collectionViewDiffableDataSource( + collectionView: collectionView, + context: context + ) + + selectedUserFetchedResultsController.$records + .receive(on: DispatchQueue.main) + .sink { [weak self] records in + guard let self = self else { return } + guard let collectionViewDiffableDataSource = self.collectionViewDiffableDataSource else { return } + + var snapshot = NSDiffableDataSourceSnapshot<SelectedAccountSection, SelectedAccountItem>() + snapshot.appendSections([.main]) + var items: [SelectedAccountItem] = records.map { SelectedAccountItem.account($0) } + + if items.count < 10 { + let count = 10 - items.count + let placeholderItems: [SelectedAccountItem] = (0..<count).map { _ in + SelectedAccountItem.placeHolder(uuid: UUID()) + } + items.append(contentsOf: placeholderItems) + } + + snapshot.appendItems(items, toSection: .main) + + if #available(iOS 15.0, *) { + collectionViewDiffableDataSource.applySnapshotUsingReloadData(snapshot, completion: nil) + } else { + // Fallback on earlier versions + collectionViewDiffableDataSource.applySnapshot(snapshot, animated: false, completion: nil) + } + } + .store(in: &disposeBag) + } + +} diff --git a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift index e876041ca..0263b61ec 100644 --- a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift +++ b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift @@ -20,212 +20,85 @@ protocol SuggestionAccountViewModelDelegate: AnyObject { final class SuggestionAccountViewModel: NSObject { var disposeBag = Set<AnyCancellable>() + weak var delegate: SuggestionAccountViewModelDelegate? + // input let context: AppContext - - let currentMastodonUser = CurrentValueSubject<MastodonUser?, Never>(nil) - weak var delegate: SuggestionAccountViewModelDelegate? - // output - let accounts = CurrentValueSubject<[NSManagedObjectID], Never>([]) - var selectedAccounts = CurrentValueSubject<[NSManagedObjectID], Never>([]) - - var headerPlaceholderCount = CurrentValueSubject<Int?, Never>(nil) - var suggestionAccountsFallback = PassthroughSubject<Void, Never>() + let userFetchedResultsController: UserFetchedResultsController + let selectedUserFetchedResultsController: UserFetchedResultsController var viewWillAppear = PassthroughSubject<Void, Never>() + + // output + var collectionViewDiffableDataSource: UICollectionViewDiffableDataSource<SelectedAccountSection, SelectedAccountItem>? + var tableViewDiffableDataSource: UITableViewDiffableDataSource<RecommendAccountSection, RecommendAccountItem>? - var diffableDataSource: UITableViewDiffableDataSource<RecommendAccountSection, NSManagedObjectID>? { - didSet(value) { - if !accounts.value.isEmpty { - applyTableViewDataSource(accounts: accounts.value) - } - } - } - - var collectionDiffableDataSource: UICollectionViewDiffableDataSource<SelectedAccountSection, SelectedAccountItem>? - - init(context: AppContext, accounts: [NSManagedObjectID]? = nil) { + init( + context: AppContext + ) { self.context = context - + self.userFetchedResultsController = UserFetchedResultsController( + managedObjectContext: context.managedObjectContext, + domain: nil, + additionalPredicate: nil + ) + self.selectedUserFetchedResultsController = UserFetchedResultsController( + managedObjectContext: context.managedObjectContext, + domain: nil, + additionalPredicate: nil + ) super.init() - - Publishers.CombineLatest( - self.accounts, - self.selectedAccounts - ) - .receive(on: RunLoop.main) - .sink { [weak self] accounts,selectedAccounts in - self?.applyTableViewDataSource(accounts: accounts) - self?.applySelectedCollectionViewDataSource(accounts: selectedAccounts) - } - .store(in: &disposeBag) - - Publishers.CombineLatest( - self.selectedAccounts, - self.headerPlaceholderCount - ) - .receive(on: RunLoop.main) - .sink { [weak self] selectedAccount,count in - self?.applySelectedCollectionViewDataSource(accounts: selectedAccount) - } - .store(in: &disposeBag) - - viewWillAppear - .sink { [weak self] _ in - self?.checkAccountsFollowState() - } - .store(in: &disposeBag) - - if let accounts = accounts { - self.accounts.value = accounts - } - - context.authenticationService.activeMastodonAuthentication - .sink { [weak self] activeMastodonAuthentication in - guard let self = self else { return } - guard let activeMastodonAuthentication = activeMastodonAuthentication else { - self.currentMastodonUser.value = nil - return - } - self.currentMastodonUser.value = activeMastodonAuthentication.user - } - .store(in: &disposeBag) - - if accounts == nil || (accounts ?? []).isEmpty { - guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } - - context.apiService.suggestionAccountV2(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox) - .sink { [weak self] completion in - switch completion { - case .failure(let error): - if let apiError = error as? Mastodon.API.Error { - if apiError.httpResponseStatus == .notFound { - self?.suggestionAccountsFallback.send() - } - } - os_log("%{public}s[%{public}ld], %{public}s: fetch recommendAccountV2 failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) - case .finished: - // handle isFetchingLatestTimeline in fetch controller delegate - break - } - } receiveValue: { [weak self] response in - let ids = response.value.map(\.account.id) - self?.receiveAccounts(ids: ids) - } - .store(in: &disposeBag) - - suggestionAccountsFallback - .sink(receiveValue: { [weak self] _ in - self?.requestSuggestionAccount() - }) - .store(in: &disposeBag) - } - } - - func requestSuggestionAccount() { - guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } - context.apiService.suggestionAccount(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox) - .sink { completion in - switch completion { - case .failure(let error): - os_log("%{public}s[%{public}ld], %{public}s: fetch recommendAccount failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) - case .finished: - // handle isFetchingLatestTimeline in fetch controller delegate - break - } - } receiveValue: { [weak self] response in - let ids = response.value.map(\.id) - self?.receiveAccounts(ids: ids) - } - .store(in: &disposeBag) - } - - func applyTableViewDataSource(accounts: [NSManagedObjectID]) { - assert(Thread.isMainThread) - guard let dataSource = diffableDataSource else { return } - var snapshot = NSDiffableDataSourceSnapshot<RecommendAccountSection, NSManagedObjectID>() - snapshot.appendSections([.main]) - snapshot.appendItems(accounts, toSection: .main) - dataSource.apply(snapshot, animatingDifferences: false, completion: nil) - } - - func applySelectedCollectionViewDataSource(accounts: [NSManagedObjectID]) { - assert(Thread.isMainThread) - guard let count = headerPlaceholderCount.value else { return } - guard let dataSource = collectionDiffableDataSource else { return } - var snapshot = NSDiffableDataSourceSnapshot<SelectedAccountSection, SelectedAccountItem>() - snapshot.appendSections([.main]) - let placeholderCount = count - accounts.count - let accountItems = accounts.map { SelectedAccountItem.accountObjectID(accountObjectID: $0) } - snapshot.appendItems(accountItems, toSection: .main) - - if placeholderCount > 0 { - for _ in 0 ..< placeholderCount { - snapshot.appendItems([SelectedAccountItem.placeHolder(uuid: UUID())], toSection: .main) - } - } - dataSource.apply(snapshot, animatingDifferences: false, completion: nil) - } - - func receiveAccounts(ids: [String]) { - guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { + + guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } - let userFetchRequest = MastodonUser.sortedFetchRequest - userFetchRequest.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, ids: ids) - let mastodonUsers: [MastodonUser]? = { - let userFetchRequest = MastodonUser.sortedFetchRequest - userFetchRequest.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, ids: ids) - userFetchRequest.returnsObjectsAsFaults = false + userFetchedResultsController.domain = authenticationBox.domain + selectedUserFetchedResultsController.domain = authenticationBox.domain + selectedUserFetchedResultsController.additionalPredicate = NSCompoundPredicate(orPredicateWithSubpredicates: [ + MastodonUser.predicate(followingBy: authenticationBox.userID), + MastodonUser.predicate(followRequestedBy: authenticationBox.userID) + ]) + + // fetch recomment users + Task { + var userIDs: [MastodonUser.ID] = [] do { - return try self.context.managedObjectContext.fetch(userFetchRequest) + let response = try await context.apiService.suggestionAccountV2( + query: nil, + authenticationBox: authenticationBox + ) + userIDs = response.value.map { $0.account.id } + } catch let error as Mastodon.API.Error where error.httpResponseStatus == .notFound { + let response = try await context.apiService.suggestionAccount( + query: nil, + authenticationBox: authenticationBox + ) + userIDs = response.value.map { $0.id } } catch { - assertionFailure(error.localizedDescription) - return nil + os_log("%{public}s[%{public}ld], %{public}s: fetch recommendAccountV2 failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) } - }() - if let users = mastodonUsers { - let sortedUsers = users.sorted { (user1, user2) -> Bool in - (ids.firstIndex(of: user1.id) ?? 0) < (ids.firstIndex(of: user2.id) ?? 0) - } - accounts.value = sortedUsers.map(\.objectID) + + guard !userIDs.isEmpty else { return } + userFetchedResultsController.userIDs = userIDs + selectedUserFetchedResultsController.userIDs = userIDs } + + // fetch relationship + userFetchedResultsController.$records + .removeDuplicates() + .sink { [weak self] records in + guard let _ = self else { return } + Task { + guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { + return + } + _ = try await context.apiService.relationship( + records: records, + authenticationBox: authenticationBox + ) + } + } + .store(in: &disposeBag) } - func followAction(objectID: NSManagedObjectID) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error>? { - guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return nil } - - let mastodonUser = context.managedObjectContext.object(with: objectID) as! MastodonUser - return context.apiService.toggleFollow( - for: mastodonUser, - activeMastodonAuthenticationBox: activeMastodonAuthenticationBox - ) - } - - func checkAccountsFollowState() { - guard let currentMastodonUser = currentMastodonUser.value else { - return - } - let users: [MastodonUser] = accounts.value.compactMap { - guard let user = context.managedObjectContext.object(with: $0) as? MastodonUser else { - return nil - } - let isBlock = user.blockingBy.flatMap { $0.contains(currentMastodonUser) } ?? false - let isDomainBlock = user.domainBlockingBy.flatMap { $0.contains(currentMastodonUser) } ?? false - if isBlock || isDomainBlock { - return nil - } else { - return user - } - } - accounts.value = users.map(\.objectID) - - let followingUsers = users.filter { user -> Bool in - let isFollowing = user.followingBy.flatMap { $0.contains(currentMastodonUser) } ?? false - let isPending = user.followRequestedBy.flatMap { $0.contains(currentMastodonUser) } ?? false - return isFollowing || isPending - }.map(\.objectID) - - selectedAccounts.value = followingUsers - } } diff --git a/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell+ViewModel.swift b/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell+ViewModel.swift new file mode 100644 index 000000000..722f76180 --- /dev/null +++ b/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell+ViewModel.swift @@ -0,0 +1,139 @@ +// +// SuggestionAccountTableViewCell+ViewModel.swift +// Mastodon +// +// Created by MainasuK on 2022-2-16. +// + +import UIKit +import Combine +import CoreDataStack +import MastodonAsset +import MastodonMeta +import Meta + +extension SuggestionAccountTableViewCell { + + class ViewModel { + var disposeBag = Set<AnyCancellable>() + + @Published public var userIdentifier: UserIdentifier? // me + + @Published var avatarImageURL: URL? + @Published public var authorName: MetaContent? + @Published public var authorUsername: String? + + @Published var isFollowing = false + @Published var isPending = false + + func prepareForReuse() { + isFollowing = false + isPending = false + } + } + +} + +extension SuggestionAccountTableViewCell.ViewModel { + func bind(cell: SuggestionAccountTableViewCell) { + // avatar + $avatarImageURL.removeDuplicates() + .sink { url in + let configuration = AvatarImageView.Configuration(url: url) + cell.avatarButton.avatarImageView.configure(configuration: configuration) + cell.avatarButton.avatarImageView.configure(cornerConfiguration: .init(corner: .fixed(radius: 12))) + } + .store(in: &disposeBag) + // name + $authorName + .sink { metaContent in + let metaContent = metaContent ?? PlaintextMetaContent(string: " ") + cell.titleLabel.configure(content: metaContent) + } + .store(in: &disposeBag) + // username + $authorUsername + .map { text -> String in + guard let text = text else { return "" } + return "@\(text)" + } + .sink { username in + cell.subTitleLabel.text = username + } + .store(in: &disposeBag) + // button + Publishers.CombineLatest( + $isFollowing, + $isPending + ) + .sink { isFollowing, isPending in + let isFollowState = isFollowing || isPending + let imageName = isFollowState ? "minus.circle.fill" : "plus.circle" + let image = UIImage(systemName: imageName, withConfiguration: UIImage.SymbolConfiguration(pointSize: 22, weight: .regular)) + cell.button.setImage(image, for: .normal) + cell.button.tintColor = isFollowState ? Asset.Colors.danger.color : Asset.Colors.Label.secondary.color + } + .store(in: &disposeBag) + } +} + +extension SuggestionAccountTableViewCell { + func configure(user: MastodonUser) { + // author avatar + Publishers.CombineLatest( + user.publisher(for: \.avatar), + UserDefaults.shared.publisher(for: \.preferredStaticAvatar) + ) + .map { _ in user.avatarImageURL() } + .assign(to: \.avatarImageURL, on: viewModel) + .store(in: &disposeBag) + // author name + Publishers.CombineLatest( + user.publisher(for: \.displayName), + user.publisher(for: \.emojis) + ) + .map { _, emojis in + do { + let content = MastodonContent(content: user.displayNameWithFallback, emojis: emojis.asDictionary) + let metaContent = try MastodonMetaContent.convert(document: content) + return metaContent + } catch { + assertionFailure(error.localizedDescription) + return PlaintextMetaContent(string: user.displayNameWithFallback) + } + } + .assign(to: \.authorName, on: viewModel) + .store(in: &disposeBag) + // author username + user.publisher(for: \.acct) + .map { $0 as String? } + .assign(to: \.authorUsername, on: viewModel) + .store(in: &disposeBag) + // isFollowing + Publishers.CombineLatest( + viewModel.$userIdentifier, + user.publisher(for: \.followingBy) + ) + .map { userIdentifier, followingBy in + guard let userIdentifier = userIdentifier else { return false } + return followingBy.contains(where: { + $0.id == userIdentifier.userID && $0.domain == userIdentifier.domain + }) + } + .assign(to: \.isFollowing, on: viewModel) + .store(in: &disposeBag) + // isPending + Publishers.CombineLatest( + viewModel.$userIdentifier, + user.publisher(for: \.followRequestedBy) + ) + .map { userIdentifier, followRequestedBy in + guard let userIdentifier = userIdentifier else { return false } + return followRequestedBy.contains(where: { + $0.id == userIdentifier.userID && $0.domain == userIdentifier.domain + }) + } + .assign(to: \.isPending, on: viewModel) + .store(in: &disposeBag) + } +} diff --git a/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift b/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift index 905e1db32..47bd9d6b3 100644 --- a/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift +++ b/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift @@ -5,6 +5,7 @@ // Created by sxiaojian on 2021/4/21. // +import os.log import Combine import CoreData import CoreDataStack @@ -13,23 +14,30 @@ import MastodonSDK import UIKit import MetaTextKit import MastodonMeta +import MastodonAsset +import MastodonLocalization +import MastodonUI protocol SuggestionAccountTableViewCellDelegate: AnyObject { - func accountButtonPressed(objectID: NSManagedObjectID, cell: SuggestionAccountTableViewCell) + func suggestionAccountTableViewCell(_ cell: SuggestionAccountTableViewCell, friendshipDidPressed button: UIButton) } final class SuggestionAccountTableViewCell: UITableViewCell { + + let logger = Logger(subsystem: "SuggestionAccountTableViewCell", category: "View") + var disposeBag = Set<AnyCancellable>() + weak var delegate: SuggestionAccountTableViewCellDelegate? - let _imageView: UIImageView = { - let imageView = UIImageView() - imageView.tintColor = Asset.Colors.Label.primary.color - imageView.layer.cornerRadius = 4 - imageView.clipsToBounds = true - return imageView + public private(set) lazy var viewModel: ViewModel = { + let viewModel = ViewModel() + viewModel.bind(cell: self) + return viewModel }() + let avatarButton = AvatarButton() + let titleLabel = MetaLabel(style: .statusName) let subTitleLabel: UILabel = { @@ -47,12 +55,8 @@ final class SuggestionAccountTableViewCell: UITableViewCell { let button: HighlightDimmableButton = { let button = HighlightDimmableButton(type: .custom) - if let plusImage = UIImage(systemName: "plus.circle", withConfiguration: UIImage.SymbolConfiguration(pointSize: 22, weight: .regular))?.withRenderingMode(.alwaysTemplate) { - button.setImage(plusImage, for: .normal) - } - if let minusImage = UIImage(systemName: "minus.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 22, weight: .regular))?.withRenderingMode(.alwaysTemplate) { - button.setImage(minusImage, for: .selected) - } + let image = UIImage(systemName: "plus.circle", withConfiguration: UIImage.SymbolConfiguration(pointSize: 22, weight: .regular)) + button.setImage(image, for: .normal) return button }() @@ -64,9 +68,10 @@ final class SuggestionAccountTableViewCell: UITableViewCell { override func prepareForReuse() { super.prepareForReuse() - _imageView.af.cancelImageRequest() - _imageView.image = nil + disposeBag.removeAll() + avatarButton.avatarImageView.prepareForReuse() + viewModel.prepareForReuse() } override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { @@ -78,9 +83,11 @@ final class SuggestionAccountTableViewCell: UITableViewCell { super.init(coder: coder) configure() } + } extension SuggestionAccountTableViewCell { + private func configure() { let containerStackView = UIStackView() containerStackView.axis = .horizontal @@ -97,11 +104,11 @@ extension SuggestionAccountTableViewCell { containerStackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), ]) - _imageView.translatesAutoresizingMaskIntoConstraints = false - containerStackView.addArrangedSubview(_imageView) + avatarButton.translatesAutoresizingMaskIntoConstraints = false + containerStackView.addArrangedSubview(avatarButton) NSLayoutConstraint.activate([ - _imageView.widthAnchor.constraint(equalToConstant: 42).priority(.required - 1), - _imageView.heightAnchor.constraint(equalToConstant: 42).priority(.required - 1), + avatarButton.widthAnchor.constraint(equalToConstant: 42).priority(.required - 1), + avatarButton.heightAnchor.constraint(equalToConstant: 42).priority(.required - 1), ]) let textStackView = UIStackView() @@ -137,56 +144,31 @@ extension SuggestionAccountTableViewCell { buttonContainer.centerXAnchor.constraint(equalTo: button.centerXAnchor), buttonContainer.centerYAnchor.constraint(equalTo: button.centerYAnchor), ]) + + button.addTarget(self, action: #selector(SuggestionAccountTableViewCell.buttonDidPressed(_:)), for: .touchUpInside) } - func config(with account: MastodonUser, isSelected: Bool) { - if let url = account.avatarImageURL() { - _imageView.af.setImage( - withURL: url, - placeholderImage: UIImage.placeholder(color: .systemFill), - imageTransition: .crossDissolve(0.2) - ) - } - let mastodonContent = MastodonContent(content: account.displayNameWithFallback, emojis: account.emojiMeta) - do { - let metaContent = try MastodonMetaContent.convert(document: mastodonContent) - titleLabel.configure(content: metaContent) - } catch { - let metaContent = PlaintextMetaContent(string: account.displayNameWithFallback) - titleLabel.configure(content: metaContent) - } - subTitleLabel.text = "@" + account.acct - button.isSelected = isSelected - button.publisher(for: .touchUpInside) - .sink { [weak self] _ in - guard let self = self else { return } - self.delegate?.accountButtonPressed(objectID: account.objectID, cell: self) - } - .store(in: &disposeBag) - button.publisher(for: \.isSelected) - .sink { [weak self] isSelected in - if isSelected { - self?.button.tintColor = Asset.Colors.danger.color - } else { - self?.button.tintColor = Asset.Colors.Label.secondary.color - } - } - .store(in: &disposeBag) - activityIndicatorView.publisher(for: \.isHidden) - .receive(on: DispatchQueue.main) - .sink { [weak self] isHidden in - self?.button.isHidden = !isHidden - } - .store(in: &disposeBag) +} + +extension SuggestionAccountTableViewCell { + @objc private func buttonDidPressed(_ sender: UIButton) { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") + delegate?.suggestionAccountTableViewCell(self, friendshipDidPressed: sender) } +} + +extension SuggestionAccountTableViewCell { func startAnimating() { activityIndicatorView.isHidden = false activityIndicatorView.startAnimating() + button.isHidden = true } func stopAnimating() { activityIndicatorView.stopAnimating() activityIndicatorView.isHidden = true + button.isHidden = false } + } diff --git a/Mastodon/Scene/Thread/CachedThreadViewModel.swift b/Mastodon/Scene/Thread/CachedThreadViewModel.swift index d4866b0bd..c4ff3b985 100644 --- a/Mastodon/Scene/Thread/CachedThreadViewModel.swift +++ b/Mastodon/Scene/Thread/CachedThreadViewModel.swift @@ -10,6 +10,10 @@ import CoreDataStack final class CachedThreadViewModel: ThreadViewModel { init(context: AppContext, status: Status) { - super.init(context: context, optionalStatus: status) + let threadContext = StatusItem.Thread.Context(status: .init(objectID: status.objectID)) + super.init( + context: context, + optionalRoot: .root(context: threadContext) + ) } } diff --git a/Mastodon/Scene/Thread/MastodonStatusThreadViewModel.swift b/Mastodon/Scene/Thread/MastodonStatusThreadViewModel.swift new file mode 100644 index 000000000..c158270cb --- /dev/null +++ b/Mastodon/Scene/Thread/MastodonStatusThreadViewModel.swift @@ -0,0 +1,278 @@ +// +// MastodonStatusThreadViewModel.swift +// MastodonStatusThreadViewModel +// +// Created by Cirno MainasuK on 2021-9-6. +// Copyright © 2021 Twidere. All rights reserved. +// + +import os.log +import Foundation +import Combine +import CoreData +import CoreDataStack +import MastodonSDK +import MastodonMeta + +final class MastodonStatusThreadViewModel { + + var disposeBag = Set<AnyCancellable>() + + // input + let context: AppContext + @Published private(set) var deletedObjectIDs: Set<NSManagedObjectID> = Set() + + // output + @Published var __ancestors: [StatusItem] = [] + @Published var ancestors: [StatusItem] = [] + + @Published var __descendants: [StatusItem] = [] + @Published var descendants: [StatusItem] = [] + + init(context: AppContext) { + self.context = context + + Publishers.CombineLatest( + $__ancestors, + $deletedObjectIDs + ) + .sink { [weak self] items, deletedObjectIDs in + guard let self = self else { return } + let newItems = items.filter { item in + switch item { + case .thread(let thread): + return !deletedObjectIDs.contains(thread.record.objectID) + default: + assertionFailure() + return false + } + } + self.ancestors = newItems + } + .store(in: &disposeBag) + + Publishers.CombineLatest( + $__descendants, + $deletedObjectIDs + ) + .sink { [weak self] items, deletedObjectIDs in + guard let self = self else { return } + let newItems = items.filter { item in + switch item { + case .thread(let thread): + return !deletedObjectIDs.contains(thread.record.objectID) + default: + assertionFailure() + return false + } + } + self.descendants = newItems + } + .store(in: &disposeBag) + } + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension MastodonStatusThreadViewModel { + + func appendAncestor( + domain: String, + nodes: [Node] + ) { + let ids = nodes.map { $0.statusID } + var dictionary: [Status.ID: Status] = [:] + do { + let request = Status.sortedFetchRequest + request.predicate = Status.predicate(domain: domain, ids: ids) + let statuses = try self.context.managedObjectContext.fetch(request) + for status in statuses { + dictionary[status.id] = status + } + } catch { + os_log("%{public}s[%{public}ld], %{public}s: fetch conversation fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + return + } + + var newItems: [StatusItem] = [] + for (i, node) in nodes.enumerated() { + guard let status = dictionary[node.statusID] else { continue } + let isLast = i == nodes.count - 1 + + let record = ManagedObjectRecord<Status>(objectID: status.objectID) + let context = StatusItem.Thread.Context( + status: record, + displayUpperConversationLink: !isLast, + displayBottomConversationLink: true + ) + let item = StatusItem.thread(.leaf(context: context)) + newItems.append(item) + } + + let items = self.__ancestors + newItems + self.__ancestors = items + } + + func appendDescendant( + domain: String, + nodes: [Node] + ) { + let childrenIDs = nodes + .map { node in [node.statusID, node.children.first?.statusID].compactMap { $0 } } + .flatMap { $0 } + var dictionary: [Status.ID: Status] = [:] + do { + let request = Status.sortedFetchRequest + request.predicate = Status.predicate(domain: domain, ids: childrenIDs) + let statuses = try self.context.managedObjectContext.fetch(request) + for status in statuses { + dictionary[status.id] = status + } + } catch { + os_log("%{public}s[%{public}ld], %{public}s: fetch conversation fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + return + } + + var newItems: [StatusItem] = [] + for node in nodes { + guard let status = dictionary[node.statusID] else { continue } + // first tier + let record = ManagedObjectRecord<Status>(objectID: status.objectID) + let context = StatusItem.Thread.Context( + status: record + ) + let item = StatusItem.thread(.leaf(context: context)) + newItems.append(item) + + // second tier + if let child = node.children.first { + guard let secondaryStatus = dictionary[child.statusID] else { continue } + let secondaryRecord = ManagedObjectRecord<Status>(objectID: secondaryStatus.objectID) + let secondaryContext = StatusItem.Thread.Context( + status: secondaryRecord, + displayUpperConversationLink: true + ) + let secondaryItem = StatusItem.thread(.leaf(context: secondaryContext)) + newItems.append(secondaryItem) + + // update first tier context + context.displayBottomConversationLink = true + } + } + + var items = self.__descendants + for item in newItems { + guard !items.contains(item) else { continue } + items.append(item) + } + self.__descendants = items + } + +} + +extension MastodonStatusThreadViewModel { + class Node { + typealias ID = String + + let statusID: ID + let children: [Node] + + init( + statusID: ID, + children: [MastodonStatusThreadViewModel.Node] + ) { + self.statusID = statusID + self.children = children + } + } +} + +extension MastodonStatusThreadViewModel.Node { + static func replyToThread( + for replyToID: Mastodon.Entity.Status.ID?, + from statuses: [Mastodon.Entity.Status] + ) -> [MastodonStatusThreadViewModel.Node] { + guard let replyToID = replyToID else { + return [] + } + + var dict: [Mastodon.Entity.Status.ID: Mastodon.Entity.Status] = [:] + for status in statuses { + dict[status.id] = status + } + + var nextID: Mastodon.Entity.Status.ID? = replyToID + var nodes: [MastodonStatusThreadViewModel.Node] = [] + while let _nextID = nextID { + guard let status = dict[_nextID] else { break } + nodes.append(MastodonStatusThreadViewModel.Node( + statusID: _nextID, + children: [] + )) + nextID = status.inReplyToID + } + + return nodes + } +} + +extension MastodonStatusThreadViewModel.Node { + static func children( + of statusID: ID, + from statuses: [Mastodon.Entity.Status] + ) -> [MastodonStatusThreadViewModel.Node] { + var dictionary: [ID: Mastodon.Entity.Status] = [:] + var mapping: [ID: Set<ID>] = [:] + + for status in statuses { + dictionary[status.id] = status + guard let replyToID = status.inReplyToID else { continue } + if var set = mapping[replyToID] { + set.insert(status.id) + mapping[replyToID] = set + } else { + mapping[replyToID] = Set([status.id]) + } + } + + var children: [MastodonStatusThreadViewModel.Node] = [] + let replies = Array(mapping[statusID] ?? Set()) + .compactMap { dictionary[$0] } + .sorted(by: { $0.createdAt > $1.createdAt }) + for reply in replies { + let child = child(of: reply.id, dictionary: dictionary, mapping: mapping) + children.append(child) + } + return children + } + + static func child( + of statusID: ID, + dictionary: [ID: Mastodon.Entity.Status], + mapping: [ID: Set<ID>] + ) -> MastodonStatusThreadViewModel.Node { + let childrenIDs = mapping[statusID] ?? [] + let children = Array(childrenIDs) + .compactMap { dictionary[$0] } + .sorted(by: { $0.createdAt > $1.createdAt }) + .map { status in child(of: status.id, dictionary: dictionary, mapping: mapping) } + return MastodonStatusThreadViewModel.Node( + statusID: statusID, + children: children + ) + } + +} + +extension MastodonStatusThreadViewModel { + func delete(objectIDs: [NSManagedObjectID]) { + var set = deletedObjectIDs + for objectID in objectIDs { + set.insert(objectID) + } + self.deletedObjectIDs = set + } +} diff --git a/Mastodon/Scene/Thread/RemoteThreadViewModel.swift b/Mastodon/Scene/Thread/RemoteThreadViewModel.swift index f8f5d3e7e..6d2e3d975 100644 --- a/Mastodon/Scene/Thread/RemoteThreadViewModel.swift +++ b/Mastodon/Scene/Thread/RemoteThreadViewModel.swift @@ -12,29 +12,26 @@ import MastodonSDK final class RemoteThreadViewModel: ThreadViewModel { - init(context: AppContext, statusID: Mastodon.Entity.Status.ID) { - super.init(context: context, optionalStatus: nil) + init( + context: AppContext, + statusID: Mastodon.Entity.Status.ID + ) { + super.init( + context: context, + optionalRoot: nil + ) - guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { + guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } - let domain = activeMastodonAuthenticationBox.domain - context.apiService.status( - domain: domain, - statusID: statusID, - authorizationBox: activeMastodonAuthenticationBox - ) - .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 status %s fetch failed: %s", ((#file as NSString).lastPathComponent), #line, #function, statusID, error.localizedDescription) - case .finished: - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: remote status %s fetched", ((#file as NSString).lastPathComponent), #line, #function, statusID) - } - } receiveValue: { [weak self] response in - guard let self = self else { return } + + Task { @MainActor in + let domain = authenticationBox.domain + let response = try await context.apiService.status( + statusID: statusID, + authenticationBox: authenticationBox + ) + let managedObjectContext = context.managedObjectContext let request = Status.sortedFetchRequest request.fetchLimit = 1 @@ -43,33 +40,32 @@ final class RemoteThreadViewModel: ThreadViewModel { assertionFailure() return } - self.rootItem.value = .root(statusObjectID: status.objectID, attribute: Item.StatusAttribute()) - } - .store(in: &disposeBag) + let threadContext = StatusItem.Thread.Context(status: .init(objectID: status.objectID)) + self.root = .root(context: threadContext) + + } // end Task } - init(context: AppContext, notificationID: Mastodon.Entity.Notification.ID) { - super.init(context: context, optionalStatus: nil) + init( + context: AppContext, + notificationID: Mastodon.Entity.Notification.ID + ) { + super.init( + context: context, + optionalRoot: nil + ) - guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { + guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } - let domain = activeMastodonAuthenticationBox.domain - context.apiService.notification( - notificationID: notificationID, - mastodonAuthenticationBox: activeMastodonAuthenticationBox - ) - .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 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 fetched", ((#file as NSString).lastPathComponent), #line, #function, notificationID) - } - } receiveValue: { [weak self] response in - guard let self = self else { return } + + Task { @MainActor in + let domain = authenticationBox.domain + let response = try await context.apiService.notification( + notificationID: notificationID, + authenticationBox: authenticationBox + ) + guard let statusID = response.value.status?.id else { return } let managedObjectContext = context.managedObjectContext @@ -80,9 +76,9 @@ final class RemoteThreadViewModel: ThreadViewModel { assertionFailure() return } - self.rootItem.value = .root(statusObjectID: status.objectID, attribute: Item.StatusAttribute()) - } - .store(in: &disposeBag) + let threadContext = StatusItem.Thread.Context(status: .init(objectID: status.objectID)) + self.root = .root(context: threadContext) + } // end Task } } diff --git a/Mastodon/Scene/Thread/ThreadViewController+DataSourceProvider.swift b/Mastodon/Scene/Thread/ThreadViewController+DataSourceProvider.swift new file mode 100644 index 000000000..fc2584dc5 --- /dev/null +++ b/Mastodon/Scene/Thread/ThreadViewController+DataSourceProvider.swift @@ -0,0 +1,36 @@ +// +// ThreadViewController+DataSourceProvider.swift +// Mastodon +// +// Created by MainasuK on 2022-1-17. +// + +import UIKit + +// MARK: - DataSourceProvider +extension ThreadViewController: DataSourceProvider { + func item(from source: DataSourceItem.Source) async -> DataSourceItem? { + var _indexPath = source.indexPath + if _indexPath == nil, let cell = source.tableViewCell { + _indexPath = await self.indexPath(for: cell) + } + guard let indexPath = _indexPath else { return nil } + + guard let item = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else { + return nil + } + + switch item { + case .thread(let thread): + return .status(record: thread.record) + default: + assertionFailure() + return nil + } + } + + @MainActor + private func indexPath(for cell: UITableViewCell) async -> IndexPath? { + return tableView.indexPath(for: cell) + } +} diff --git a/Mastodon/Scene/Thread/ThreadViewController+Provider.swift b/Mastodon/Scene/Thread/ThreadViewController+Provider.swift deleted file mode 100644 index c6bd29e15..000000000 --- a/Mastodon/Scene/Thread/ThreadViewController+Provider.swift +++ /dev/null @@ -1,96 +0,0 @@ -// -// ThreadViewController+Provider.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-4-12. -// - -import UIKit -import Combine -import CoreData -import CoreDataStack - -// MARK: - StatusProvider -extension ThreadViewController: StatusProvider { - - func status() -> Future<Status?, Never> { - return Future { promise in promise(.success(nil)) } - } - - func status(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future<Status?, Never> { - return Future { promise in - 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 .root(let statusObjectID, _), - .reply(let statusObjectID, _), - .leaf(let statusObjectID, _): - let managedObjectContext = self.viewModel.context.managedObjectContext - managedObjectContext.perform { - let status = managedObjectContext.object(with: statusObjectID) as? Status - promise(.success(status)) - } - default: - promise(.success(nil)) - } - } - } - - func status(for cell: UICollectionViewCell) -> Future<Status?, Never> { - return Future { promise in promise(.success(nil)) } - } - - var managedObjectContext: NSManagedObjectContext { - return viewModel.context.managedObjectContext - } - - var tableViewDiffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>? { - return viewModel.diffableDataSource - } - - func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item? { - guard let diffableDataSource = self.viewModel.diffableDataSource else { - assertionFailure() - return nil - } - - guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }), - let item = diffableDataSource.itemIdentifier(for: indexPath) else { - return nil - } - - return item - } - - func items(indexPaths: [IndexPath]) -> [Item] { - guard let diffableDataSource = self.viewModel.diffableDataSource else { - assertionFailure() - return [] - } - - var items: [Item] = [] - for indexPath in indexPaths { - guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { continue } - items.append(item) - } - return items - } - - func statusObjectItems(indexPaths: [IndexPath]) -> [StatusObjectItem] { - guard let diffableDataSource = self.viewModel.diffableDataSource else { return [] } - let items = indexPaths.compactMap { diffableDataSource.itemIdentifier(for: $0)?.statusObjectItem } - return items - } - -} - -extension ThreadViewController: UserProvider {} diff --git a/Mastodon/Scene/Thread/ThreadViewController.swift b/Mastodon/Scene/Thread/ThreadViewController.swift index a0de13477..bd90fb370 100644 --- a/Mastodon/Scene/Thread/ThreadViewController.swift +++ b/Mastodon/Scene/Thread/ThreadViewController.swift @@ -11,8 +11,12 @@ import Combine import CoreData import AVKit import MastodonMeta +import MastodonAsset +import MastodonLocalization final class ThreadViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { + + let logger = Logger(subsystem: "ThreadViewController", category: "ViewController") weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } @@ -55,27 +59,29 @@ extension ThreadViewController { view.backgroundColor = ThemeService.shared.currentTheme.value.secondarySystemBackgroundColor ThemeService.shared.currentTheme - .receive(on: RunLoop.main) + .receive(on: DispatchQueue.main) .sink { [weak self] theme in guard let self = self else { return } self.view.backgroundColor = theme.secondarySystemBackgroundColor } .store(in: &disposeBag) + navigationItem.title = L10n.Scene.Thread.backTitle navigationItem.titleView = titleView navigationItem.rightBarButtonItem = replyBarButtonItem replyBarButtonItem.button.addTarget(self, action: #selector(ThreadViewController.replyBarButtonItemPressed(_:)), for: .touchUpInside) - viewModel.tableView = tableView - viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self - tableView.delegate = self - tableView.prefetchDataSource = self - viewModel.setupDiffableDataSource( - for: tableView, - dependency: self, - statusTableViewCellDelegate: self, - threadReplyLoaderTableViewCellDelegate: self - ) + viewModel.$navigationBarTitle + .receive(on: DispatchQueue.main) + .sink { [weak self] title in + guard let self = self else { return } + guard let title = title else { + self.titleView.update(title: "", subtitle: nil) + return + } + self.titleView.update(titleMetaContent: title, subtitle: nil) + } + .store(in: &disposeBag) tableView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(tableView) @@ -85,97 +91,57 @@ extension ThreadViewController { tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), ]) - - Publishers.CombineLatest( - viewModel.navigationBarTitle, - viewModel.navigationBarTitleEmojiMeta + + tableView.delegate = self + viewModel.setupDiffableDataSource( + tableView: tableView, + statusTableViewCellDelegate: self ) - .receive(on: DispatchQueue.main) - .sink { [weak self] title, emojiMeta in - guard let self = self else { return } - guard let title = title else { - self.titleView.update(title: "", subtitle: nil) - return - } - let mastodonContent = MastodonContent(content: title, emojis: emojiMeta) - do { - let metaContent = try MastodonMetaContent.convert(document: mastodonContent) - self.titleView.update(titleMetaContent: metaContent, subtitle: nil) - } catch { - assertionFailure() - } - } - .store(in: &disposeBag) } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - aspectViewWillAppear(animated) - } - - override func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - - aspectViewDidDisappear(animated) + tableView.deselectRow(with: transitionCoordinator, animated: animated) } } extension ThreadViewController { @objc private func replyBarButtonItemPressed(_ sender: UIBarButtonItem) { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - guard let rootItem = viewModel.rootItem.value, - case let .root(statusObjectID, _) = rootItem else { return } - let composeViewModel = ComposeViewModel(context: context, composeKind: .reply(repliedToStatusObjectID: statusObjectID)) - coordinator.present(scene: .compose(viewModel: composeViewModel), from: self, transition: .modal(animated: true, completion: nil)) + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") + guard case let .root(threadContext) = viewModel.root else { return } + guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } + let composeViewModel = ComposeViewModel( + context: context, + composeKind: .reply(status: threadContext.status), + authenticationBox: authenticationBox + ) + coordinator.present( + scene: .compose(viewModel: composeViewModel), + from: self, + transition: .modal(animated: true, completion: nil) + ) } } -// MARK: - StatusTableViewControllerAspect -extension ThreadViewController: StatusTableViewControllerAspect { } - -// MARK: - TableViewCellHeightCacheableContainer -extension ThreadViewController: TableViewCellHeightCacheableContainer { - var cellFrameCache: NSCache<NSNumber, NSValue> { viewModel.cellFrameCache } -} +//// MARK: - StatusTableViewControllerAspect +//extension ThreadViewController: StatusTableViewControllerAspect { } // MARK: - UITableViewDelegate -extension ThreadViewController: UITableViewDelegate { +extension ThreadViewController: UITableViewDelegate, AutoGenerateTableViewDelegate { + // sourcery:inline:ThreadViewController.AutoGenerateTableViewDelegate - func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { - aspectTableView(tableView, estimatedHeightForRowAt: indexPath) - } - - func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { - aspectTableView(tableView, willDisplay: cell, forRowAt: indexPath) - } - - func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { - aspectTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) - } - + // Generated using Sourcery + // DO NOT EDIT func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { aspectTableView(tableView, didSelectRowAt: indexPath) } - - 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 } - - // disable root selection - switch item { - case .root: - return nil - default: - return 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) } @@ -183,72 +149,35 @@ extension ThreadViewController: UITableViewDelegate { 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) } + // sourcery:end -} - -// MARK: - UITableViewDataSourcePrefetching -extension ThreadViewController: UITableViewDataSourcePrefetching { - func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { - aspectTableView(tableView, prefetchRowsAt: indexPaths) - } -} - -// MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate -extension ThreadViewController: ContentOffsetAdjustableTimelineViewControllerDelegate { - func navigationBar() -> UINavigationBar? { - return navigationController?.navigationBar - } -} - -// MARK: - AVPlayerViewControllerDelegate -extension ThreadViewController: AVPlayerViewControllerDelegate { - - func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { - aspectPlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator) - } - - func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { - aspectPlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator) - } - -} - -// MARK: - statusTableViewCellDelegate -extension ThreadViewController: StatusTableViewCellDelegate { - weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self } - func parent() -> UIViewController { return self } -} - -// MARK: - ThreadReplyLoaderTableViewCellDelegate -extension ThreadViewController: ThreadReplyLoaderTableViewCellDelegate { - func threadReplyLoaderTableViewCell(_ cell: ThreadReplyLoaderTableViewCell, loadMoreButtonDidPressed button: UIButton) { - guard let diffableDataSource = viewModel.diffableDataSource else { return } - guard let indexPath = tableView.indexPath(for: cell) else { return } - guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } - guard case let .leafBottomLoader(statusObjectID) = item else { return } + 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 } - let nodes = viewModel.descendantNodes.value - nodes.forEach { node in - expandReply(node: node, statusObjectID: statusObjectID) - } - viewModel.descendantNodes.value = nodes - } - - private func expandReply(node: ThreadViewModel.LeafNode, statusObjectID: NSManagedObjectID) { - if node.objectID == statusObjectID { - node.isChildrenExpanded = true - } else { - for child in node.children { - expandReply(node: child, statusObjectID: statusObjectID) + switch item { + case .thread(let thread): + switch thread { + case .root: + return nil + default: + return indexPath } + default: + return indexPath } } } + +// MARK: - StatusTableViewCellDelegate +extension ThreadViewController: StatusTableViewCellDelegate { } + + extension ThreadViewController { override var keyCommands: [UIKeyCommand]? { return navigationKeyCommands + statusNavigationKeyCommands @@ -260,7 +189,7 @@ extension ThreadViewController: StatusTableViewControllerNavigateable { @objc func navigateKeyCommandHandlerRelay(_ sender: UIKeyCommand) { navigateKeyCommandHandler(sender) } - + @objc func statusKeyCommandHandlerRelay(_ sender: UIKeyCommand) { statusKeyCommandHandler(sender) } diff --git a/Mastodon/Scene/Thread/ThreadViewModel+Diffable.swift b/Mastodon/Scene/Thread/ThreadViewModel+Diffable.swift index 853bee9da..a6b4848c1 100644 --- a/Mastodon/Scene/Thread/ThreadViewModel+Diffable.swift +++ b/Mastodon/Scene/Thread/ThreadViewModel+Diffable.swift @@ -13,228 +13,423 @@ import MastodonSDK extension ThreadViewModel { + @MainActor func setupDiffableDataSource( - for tableView: UITableView, - dependency: NeedsDependency, - statusTableViewCellDelegate: StatusTableViewCellDelegate, - threadReplyLoaderTableViewCellDelegate: ThreadReplyLoaderTableViewCellDelegate + tableView: UITableView, + statusTableViewCellDelegate: StatusTableViewCellDelegate ) { - diffableDataSource = StatusSection.tableViewDiffableDataSource( - for: tableView, - timelineContext: .thread, - dependency: dependency, - managedObjectContext: context.managedObjectContext, - statusTableViewCellDelegate: statusTableViewCellDelegate, - timelineMiddleLoaderTableViewCellDelegate: nil, - threadReplyLoaderTableViewCellDelegate: threadReplyLoaderTableViewCellDelegate + diffableDataSource = StatusSection.diffableDataSource( + tableView: tableView, + context: context, + configuration: StatusSection.Configuration( + statusTableViewCellDelegate: statusTableViewCellDelegate, + timelineMiddleLoaderTableViewCellDelegate: nil, + filterContext: .thread, + activeFilters: context.statusFilterService.$activeFilters + ) ) - var snapshot = NSDiffableDataSourceSnapshot<StatusSection, Item>() + // make initial snapshot animation smooth + var snapshot = NSDiffableDataSourceSnapshot<StatusSection, StatusItem>() snapshot.appendSections([.main]) - if let rootNode = self.rootNode.value, rootNode.replyToID != nil { - snapshot.appendItems([.topLoader], toSection: .main) + if let root = self.root { + if case let .root(threadContext) = root, + let status = threadContext.status.object(in: context.managedObjectContext), + status.inReplyToID != nil + { + snapshot.appendItems([.topLoader], toSection: .main) + } + + snapshot.appendItems([.thread(root)], toSection: .main) + } else { + } + diffableDataSource?.apply(snapshot, animatingDifferences: false) - diffableDataSource?.apply(snapshot, animatingDifferences: false, completion: nil) - - Publishers.CombineLatest3( - rootItem.removeDuplicates(), - ancestorItems.removeDuplicates(), - descendantItems.removeDuplicates() - ) - .receive(on: RunLoop.main) - .sink { [weak self] rootItem, ancestorItems, descendantItems in - guard let self = self else { return } - var items: [Item] = [] - rootItem.flatMap { items.append($0) } - items.append(contentsOf: ancestorItems) - items.append(contentsOf: descendantItems) - self.updateDeletedStatus(for: items) - } - .store(in: &disposeBag) - - Publishers.CombineLatest4( - rootItem, - ancestorItems, - descendantItems, - existStatusFetchedResultsController.objectIDs - ) - .debounce(for: .milliseconds(100), scheduler: RunLoop.main) // some magic to avoid jitter - .sink { [weak self] rootItem, ancestorItems, descendantItems, existObjectIDs in - guard let self = self else { return } - guard let tableView = self.tableView, - let navigationBar = self.contentOffsetAdjustableTimelineViewControllerDelegate?.navigationBar() - else { return } - - guard let diffableDataSource = self.diffableDataSource else { return } - let oldSnapshot = diffableDataSource.snapshot() - - var newSnapshot = NSDiffableDataSourceSnapshot<StatusSection, Item>() - newSnapshot.appendSections([.main]) - - let currentState = self.loadThreadStateMachine.currentState - - // reply to - if self.rootNode.value?.replyToID != nil, !(currentState is LoadThreadState.NoMore) { - newSnapshot.appendItems([.topLoader], toSection: .main) - } - - let ancestorItems = ancestorItems.filter { item in - guard case let .reply(statusObjectID, _) = item else { return false } - return existObjectIDs.contains(statusObjectID) - } - newSnapshot.appendItems(ancestorItems, toSection: .main) - - // root - if let rootItem = rootItem, - case let .root(objectID, _) = rootItem, - existObjectIDs.contains(objectID) { - newSnapshot.appendItems([rootItem], toSection: .main) - } - - // leaf - if !(currentState is LoadThreadState.NoMore) { - newSnapshot.appendItems([.bottomLoader], toSection: .main) - } - - let descendantItems = descendantItems.filter { item in - switch item { - case .leaf(let statusObjectID, _): - return existObjectIDs.contains(statusObjectID) - default: - return true - } - } - newSnapshot.appendItems(descendantItems, toSection: .main) - - // difference for first visible item exclude .topLoader - guard let difference = self.calculateReloadSnapshotDifference(navigationBar: navigationBar, tableView: tableView, oldSnapshot: oldSnapshot, newSnapshot: newSnapshot) else { - diffableDataSource.apply(newSnapshot) - return - } - - // additional margin for .topLoader - let oldTopMargin: CGFloat = { - let marginHeight = TimelineTopLoaderTableViewCell.cellHeight - if oldSnapshot.itemIdentifiers.contains(.topLoader) { - return marginHeight - } - if !ancestorItems.isEmpty { - return marginHeight - } - - return .zero - }() - - let oldRootCell: UITableViewCell? = { - guard let rootItem = rootItem else { return nil } - guard let index = oldSnapshot.indexOfItem(rootItem) else { return nil } - guard let cell = tableView.cellForRow(at: IndexPath(row: index, section: 0)) else { return nil } - return cell - }() - // save height before cell reuse - let oldRootCellHeight = oldRootCell?.frame.height - - diffableDataSource.reloadData(snapshot: newSnapshot) { - guard let _ = rootItem else { + $threadContext + .receive(on: DispatchQueue.main) + .sink { [weak self] threadContext in + guard let self = self else { return } + guard let _ = threadContext else { return } - if let oldRootCellHeight = oldRootCellHeight { - // set bottom inset. Make root item pin to top (with margin). - let bottomSpacing = tableView.safeAreaLayoutGuide.layoutFrame.height - oldRootCellHeight - oldTopMargin - tableView.contentInset.bottom = max(0, bottomSpacing) - } - // set scroll position - tableView.scrollToRow(at: difference.targetIndexPath, at: .top, animated: false) - let contentOffsetY: CGFloat = { - var offset: CGFloat = tableView.contentOffset.y - difference.offset - if tableView.contentInset.bottom != 0.0 && descendantItems.isEmpty { - // needs restore top margin if bottom inset adjusted AND no descendantItems - offset += oldTopMargin - } - return offset - }() - tableView.setContentOffset(CGPoint(x: 0, y: contentOffsetY), animated: false) + self.loadThreadStateMachine.enter(LoadThreadState.Loading.self) } + .store(in: &disposeBag) + + Publishers.CombineLatest3( + $root, + mastodonStatusThreadViewModel.$ancestors, + mastodonStatusThreadViewModel.$descendants + ) + .throttle(for: 1, scheduler: DispatchQueue.main, latest: true) + .sink { [weak self] root, ancestors, descendants in + guard let self = self else { return } + guard let diffableDataSource = self.diffableDataSource else { return } + + Task { @MainActor in + let oldSnapshot = diffableDataSource.snapshot() + + var newSnapshot = NSDiffableDataSourceSnapshot<StatusSection, StatusItem>() + newSnapshot.appendSections([.main]) + + // top loader + let _hasReplyTo: Bool? = try? await self.context.managedObjectContext.perform { + guard case let .root(threadContext) = root else { return nil } + guard let status = threadContext.status.object(in: self.context.managedObjectContext) else { return nil } + return status.inReplyToID != nil + } + if let hasReplyTo = _hasReplyTo, hasReplyTo { + let state = self.loadThreadStateMachine.currentState + if state is LoadThreadState.NoMore { + // do nothing + } else { + newSnapshot.appendItems([.topLoader], toSection: .main) + } + } + + // replies + newSnapshot.appendItems(ancestors.reversed(), toSection: .main) + // root + if let root = root { + let item = StatusItem.thread(root) + newSnapshot.appendItems([item], toSection: .main) + } + // leafs + newSnapshot.appendItems(descendants, toSection: .main) + // bottom loader + if let currentState = self.loadThreadStateMachine.currentState { + switch currentState { + case is LoadThreadState.Initial, + is LoadThreadState.Loading, + is LoadThreadState.Fail: + newSnapshot.appendItems([.bottomLoader], toSection: .main) + default: + break + } + } + + let hasChanges = newSnapshot.itemIdentifiers != oldSnapshot.itemIdentifiers + if !hasChanges { + self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): snapshot not changes") + return + } else { + self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): snapshot has changes") + } + + guard let difference = self.calculateReloadSnapshotDifference( + tableView: tableView, + oldSnapshot: oldSnapshot, + newSnapshot: newSnapshot + ) else { + await self.updateDataSource(snapshot: newSnapshot, animatingDifferences: false) + self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): applied new snapshot without tweak") + return + } + + self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [Snapshot] oldSnapshot: \(oldSnapshot.itemIdentifiers.debugDescription)") + self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [Snapshot] newSnapshot: \(newSnapshot.itemIdentifiers.debugDescription)") + await self.updateSnapshotUsingReloadData( + tableView: tableView, + oldSnapshot: oldSnapshot, + newSnapshot: newSnapshot, + difference: difference + ) + } // end Task } .store(in: &disposeBag) + + +// Publishers.CombineLatest3( +// rootItem.removeDuplicates(), +// ancestorItems.removeDuplicates(), +// descendantItems.removeDuplicates() +// ) +// .receive(on: RunLoop.main) +// .sink { [weak self] rootItem, ancestorItems, descendantItems in +// guard let self = self else { return } +// var items: [Item] = [] +// rootItem.flatMap { items.append($0) } +// items.append(contentsOf: ancestorItems) +// items.append(contentsOf: descendantItems) +// self.updateDeletedStatus(for: items) +// } +// .store(in: &disposeBag) +// +// Publishers.CombineLatest4( +// rootItem, +// ancestorItems, +// descendantItems, +// existStatusFetchedResultsController.objectIDs +// ) +// .debounce(for: .milliseconds(100), scheduler: RunLoop.main) // some magic to avoid jitter +// .sink { [weak self] rootItem, ancestorItems, descendantItems, existObjectIDs in +// guard let self = self else { return } +// guard let tableView = self.tableView, +// let navigationBar = self.contentOffsetAdjustableTimelineViewControllerDelegate?.navigationBar() +// else { return } +// +// guard let diffableDataSource = self.diffableDataSource else { return } +// let oldSnapshot = diffableDataSource.snapshot() +// +// var newSnapshot = NSDiffableDataSourceSnapshot<StatusSection, Item>() +// newSnapshot.appendSections([.main]) +// +// let currentState = self.loadThreadStateMachine.currentState +// +// // reply to +// if self.rootNode.value?.replyToID != nil, !(currentState is LoadThreadState.NoMore) { +// newSnapshot.appendItems([.topLoader], toSection: .main) +// } +// +// let ancestorItems = ancestorItems.filter { item in +// guard case let .reply(statusObjectID, _) = item else { return false } +// return existObjectIDs.contains(statusObjectID) +// } +// newSnapshot.appendItems(ancestorItems, toSection: .main) +// +// // root +// if let rootItem = rootItem, +// case let .root(objectID, _) = rootItem, +// existObjectIDs.contains(objectID) { +// newSnapshot.appendItems([rootItem], toSection: .main) +// } +// +// // leaf +// if !(currentState is LoadThreadState.NoMore) { +// newSnapshot.appendItems([.bottomLoader], toSection: .main) +// } +// +// let descendantItems = descendantItems.filter { item in +// switch item { +// case .leaf(let statusObjectID, _): +// return existObjectIDs.contains(statusObjectID) +// default: +// return true +// } +// } +// newSnapshot.appendItems(descendantItems, toSection: .main) +// +// // difference for first visible item exclude .topLoader +// guard let difference = self.calculateReloadSnapshotDifference(navigationBar: navigationBar, tableView: tableView, oldSnapshot: oldSnapshot, newSnapshot: newSnapshot) else { +// diffableDataSource.apply(newSnapshot) +// return +// } +// +// // additional margin for .topLoader +// let oldTopMargin: CGFloat = { +// let marginHeight = TimelineTopLoaderTableViewCell.cellHeight +// if oldSnapshot.itemIdentifiers.contains(.topLoader) { +// return marginHeight +// } +// if !ancestorItems.isEmpty { +// return marginHeight +// } +// +// return .zero +// }() +// +// let oldRootCell: UITableViewCell? = { +// guard let rootItem = rootItem else { return nil } +// guard let index = oldSnapshot.indexOfItem(rootItem) else { return nil } +// guard let cell = tableView.cellForRow(at: IndexPath(row: index, section: 0)) else { return nil } +// return cell +// }() +// // save height before cell reuse +// let oldRootCellHeight = oldRootCell?.frame.height +// +// diffableDataSource.reloadData(snapshot: newSnapshot) { +// guard let _ = rootItem else { +// return +// } +// if let oldRootCellHeight = oldRootCellHeight { +// // set bottom inset. Make root item pin to top (with margin). +// let bottomSpacing = tableView.safeAreaLayoutGuide.layoutFrame.height - oldRootCellHeight - oldTopMargin +// tableView.contentInset.bottom = max(0, bottomSpacing) +// } +// +// // set scroll position +// tableView.scrollToRow(at: difference.targetIndexPath, at: .top, animated: false) +// let contentOffsetY: CGFloat = { +// var offset: CGFloat = tableView.contentOffset.y - difference.offset +// if tableView.contentInset.bottom != 0.0 && descendantItems.isEmpty { +// // needs restore top margin if bottom inset adjusted AND no descendantItems +// offset += oldTopMargin +// } +// return offset +// }() +// tableView.setContentOffset(CGPoint(x: 0, y: contentOffsetY), animated: false) +// } +// } +// .store(in: &disposeBag) } } + extension ThreadViewModel { - private struct Difference<T> { - let item: T - let sourceIndexPath: IndexPath - let targetIndexPath: IndexPath - let offset: CGFloat + + @MainActor func updateDataSource( + snapshot: NSDiffableDataSourceSnapshot<StatusSection, StatusItem>, + animatingDifferences: Bool + ) async { + diffableDataSource?.apply(snapshot, animatingDifferences: animatingDifferences) } - private func calculateReloadSnapshotDifference( - navigationBar: UINavigationBar, - tableView: UITableView, - oldSnapshot: NSDiffableDataSourceSnapshot<StatusSection, Item>, - newSnapshot: NSDiffableDataSourceSnapshot<StatusSection, Item> - ) -> Difference<Item>? { - guard oldSnapshot.numberOfItems != 0 else { return nil } - guard let visibleIndexPaths = tableView.indexPathsForVisibleRows?.sorted() else { return nil } + @MainActor func updateSnapshotUsingReloadData( + snapshot: NSDiffableDataSourceSnapshot<StatusSection, StatusItem> + ) async { + if #available(iOS 15.0, *) { + await self.diffableDataSource?.applySnapshotUsingReloadData(snapshot) + } else { + diffableDataSource?.applySnapshot(snapshot, animated: false, completion: nil) + } + } - // find index of the first visible item exclude .topLoader + // Some UI tweaks to present replies and conversation smoothly + @MainActor private func updateSnapshotUsingReloadData( + tableView: UITableView, + oldSnapshot: NSDiffableDataSourceSnapshot<StatusSection, StatusItem>, + newSnapshot: NSDiffableDataSourceSnapshot<StatusSection, StatusItem>, + difference: ThreadViewModel.Difference // <StatusItem> + ) async { + let replies: [StatusItem] = { + newSnapshot.itemIdentifiers.filter { item in + guard case let .thread(thread) = item else { return false } + guard case .reply = thread else { return false } + return true + } + }() + // additional margin for .topLoader + let oldTopMargin: CGFloat = { + let marginHeight = TimelineTopLoaderTableViewCell.cellHeight + if oldSnapshot.itemIdentifiers.contains(.topLoader) || !replies.isEmpty { + return marginHeight + } + return .zero + }() + + await self.updateSnapshotUsingReloadData(snapshot: newSnapshot) + + // note: + // tweak the content offset and bottom inset + // make the table view stable when data reload + // the keypoint is set the bottom inset to make the root padding with "TopLoaderHeight" to top edge + // and restore the "TopLoaderHeight" when bottom inset adjusted + + // set bottom inset. Make root item pin to top. + if let item = root.flatMap({ StatusItem.thread($0) }), + let index = newSnapshot.indexOfItem(item), + let cell = tableView.cellForRow(at: IndexPath(row: index, section: 0)) + { + // always set bottom inset due to lazy reply loading + // otherwise tableView will jump when insert replies + let bottomSpacing = tableView.safeAreaLayoutGuide.layoutFrame.height - cell.frame.height - oldTopMargin + let additionalInset = round(tableView.contentSize.height - cell.frame.maxY) + + tableView.contentInset.bottom = max(0, bottomSpacing - additionalInset) + self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): content inset bottom: \(tableView.contentInset.bottom)") + } + + // set scroll position + tableView.scrollToRow(at: difference.targetIndexPath, at: .top, animated: false) + tableView.contentOffset.y = { + var offset: CGFloat = tableView.contentOffset.y - difference.sourceDistanceToTableViewTopEdge + if tableView.contentInset.bottom != 0.0 { + // needs restore top margin if bottom inset adjusted + offset += oldTopMargin + } + return offset + }() + self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): applied new snapshot") + } +} + +extension ThreadViewModel { + struct Difference { + let item: StatusItem + let sourceIndexPath: IndexPath + let sourceDistanceToTableViewTopEdge: CGFloat + let targetIndexPath: IndexPath + } + + @MainActor private func calculateReloadSnapshotDifference( + tableView: UITableView, + oldSnapshot: NSDiffableDataSourceSnapshot<StatusSection, StatusItem>, + newSnapshot: NSDiffableDataSourceSnapshot<StatusSection, StatusItem> + ) -> Difference? { + guard oldSnapshot.numberOfItems != 0 else { return nil } + guard let indexPathsForVisibleRows = tableView.indexPathsForVisibleRows?.sorted() else { return nil } + + // find index of the first visible item in both old and new snapshot var _index: Int? let items = oldSnapshot.itemIdentifiers(inSection: .main) for (i, item) in items.enumerated() { - if case .topLoader = item { continue } - guard visibleIndexPaths.contains(where: { $0.row == i }) else { continue } - + guard let indexPath = indexPathsForVisibleRows.first(where: { $0.row == i }) else { continue } + guard newSnapshot.indexOfItem(item) != nil else { continue } + let rectForCell = tableView.rectForRow(at: indexPath) + let distanceToTableViewTopEdge = tableView.convert(rectForCell, to: nil).origin.y - tableView.safeAreaInsets.top + guard distanceToTableViewTopEdge >= 0 else { continue } _index = i break } - - guard let index = _index else { return nil } + + guard let index = _index else { return nil } let sourceIndexPath = IndexPath(row: index, section: 0) - guard sourceIndexPath.row < oldSnapshot.itemIdentifiers(inSection: .main).count else { return nil } - - let item = oldSnapshot.itemIdentifiers(inSection: .main)[sourceIndexPath.row] - guard let itemIndex = newSnapshot.itemIdentifiers(inSection: .main).firstIndex(of: item) else { return nil } - let targetIndexPath = IndexPath(row: itemIndex, section: 0) - - let offset = UIViewController.tableViewCellOriginOffsetToWindowTop(in: tableView, at: sourceIndexPath, navigationBar: navigationBar) + + let rectForSourceItemCell = tableView.rectForRow(at: sourceIndexPath) + let sourceDistanceToTableViewTopEdge = tableView.convert(rectForSourceItemCell, to: nil).origin.y - tableView.safeAreaInsets.top + + guard sourceIndexPath.section < oldSnapshot.numberOfSections, + sourceIndexPath.row < oldSnapshot.numberOfItems(inSection: oldSnapshot.sectionIdentifiers[sourceIndexPath.section]) + else { return nil } + + let sectionIdentifier = oldSnapshot.sectionIdentifiers[sourceIndexPath.section] + let item = oldSnapshot.itemIdentifiers(inSection: sectionIdentifier)[sourceIndexPath.row] + + guard let targetIndexPathRow = newSnapshot.indexOfItem(item), + let newSectionIdentifier = newSnapshot.sectionIdentifier(containingItem: item), + let targetIndexPathSection = newSnapshot.indexOfSection(newSectionIdentifier) + else { return nil } + + let targetIndexPath = IndexPath(row: targetIndexPathRow, section: targetIndexPathSection) + return Difference( item: item, sourceIndexPath: sourceIndexPath, - targetIndexPath: targetIndexPath, - offset: offset + sourceDistanceToTableViewTopEdge: sourceDistanceToTableViewTopEdge, + targetIndexPath: targetIndexPath ) } } -extension ThreadViewModel { - private func updateDeletedStatus(for items: [Item]) { - let parentManagedObjectContext = context.managedObjectContext - let managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) - managedObjectContext.parent = parentManagedObjectContext - managedObjectContext.perform { - var statusIDs: [Status.ID] = [] - for item in items { - switch item { - case .root(let objectID, _): - guard let status = managedObjectContext.object(with: objectID) as? Status else { continue } - statusIDs.append(status.id) - case .reply(let objectID, _): - guard let status = managedObjectContext.object(with: objectID) as? Status else { continue } - statusIDs.append(status.id) - case .leaf(let objectID, _): - guard let status = managedObjectContext.object(with: objectID) as? Status else { continue } - statusIDs.append(status.id) - default: - continue - } - } - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - self.existStatusFetchedResultsController.statusIDs.value = statusIDs - } - } - } -} +//extension ThreadViewModel { +// private func updateDeletedStatus(for items: [Item]) { +// let parentManagedObjectContext = context.managedObjectContext +// let managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) +// managedObjectContext.parent = parentManagedObjectContext +// managedObjectContext.perform { +// var statusIDs: [Status.ID] = [] +// for item in items { +// switch item { +// case .root(let objectID, _): +// guard let status = managedObjectContext.object(with: objectID) as? Status else { continue } +// statusIDs.append(status.id) +// case .reply(let objectID, _): +// guard let status = managedObjectContext.object(with: objectID) as? Status else { continue } +// statusIDs.append(status.id) +// case .leaf(let objectID, _): +// guard let status = managedObjectContext.object(with: objectID) as? Status else { continue } +// statusIDs.append(status.id) +// default: +// continue +// } +// } +// DispatchQueue.main.async { [weak self] in +// guard let self = self else { return } +// self.existStatusFetchedResultsController.statusIDs.value = statusIDs +// } +// } +// } +//} diff --git a/Mastodon/Scene/Thread/ThreadViewModel+LoadThreadState.swift b/Mastodon/Scene/Thread/ThreadViewModel+LoadThreadState.swift index 827242644..86fdc2111 100644 --- a/Mastodon/Scene/Thread/ThreadViewModel+LoadThreadState.swift +++ b/Mastodon/Scene/Thread/ThreadViewModel+LoadThreadState.swift @@ -13,7 +13,16 @@ import CoreDataStack import MastodonSDK extension ThreadViewModel { - class LoadThreadState: GKState { + class LoadThreadState: GKState, NamingState { + + let logger = Logger(subsystem: "ThreadViewModel.LoadThreadState", category: "StateMachine") + + let id = UUID() + + var name: String { + String(describing: Self.self) + } + weak var viewModel: ThreadViewModel? init(viewModel: ThreadViewModel) { @@ -21,7 +30,18 @@ extension ThreadViewModel { } 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) + super.didEnter(from: previousState) + let previousState = previousState as? ThreadViewModel.LoadThreadState + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [\(self.id.uuidString)] enter \(self.name), previous: \(previousState?.name ?? "<nil>")") + } + + @MainActor + func enter(state: LoadThreadState.Type) { + stateMachine?.enter(state) + } + + deinit { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [\(self.id.uuidString)] \(self.name)") } } } @@ -40,62 +60,57 @@ extension ThreadViewModel.LoadThreadState { override func isValidNextState(_ stateClass: AnyClass) -> Bool { switch stateClass { case is Fail.Type: return true - case is NoMore.Type: return true + case is NoMore.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 } - guard let mastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { - stateMachine.enter(Fail.self) - return - } - - guard let rootNode = viewModel.rootNode.value else { - stateMachine.enter(Fail.self) - return - } - - // trigger data source update - viewModel.rootItem.value = viewModel.rootItem.value - - let domain = rootNode.domain - let statusID = rootNode.statusID - let replyToID = rootNode.replyToID - - viewModel.context.apiService.statusContext( - domain: domain, - statusID: statusID, - mastodonAuthenticationBox: mastodonAuthenticationBox - ) - .sink { completion in - switch completion { - case .failure(let error): - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch status context for %s fail: %s", ((#file as NSString).lastPathComponent), #line, #function, statusID, error.localizedDescription) - stateMachine.enter(Fail.self) - case .finished: - break - } - } receiveValue: { response in - stateMachine.enter(NoMore.self) - viewModel.ancestorNodes.value = ThreadViewModel.ReplyNode.replyToThread( - for: replyToID, - from: response.value.ancestors, - domain: domain, - managedObjectContext: viewModel.context.managedObjectContext - ) - viewModel.descendantNodes.value = ThreadViewModel.LeafNode.tree( - for: rootNode.statusID, - from: response.value.descendants, - domain: domain, - managedObjectContext: viewModel.context.managedObjectContext - ) + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + guard let authenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { + stateMachine.enter(Fail.self) + return + } + + guard let threadContext = viewModel.threadContext else { + stateMachine.enter(Fail.self) + return + } + + Task { + do { + let response = try await viewModel.context.apiService.statusContext( + statusID: threadContext.statusID, + authenticationBox: authenticationBox + ) + + await enter(state: NoMore.self) + + // assert(!Thread.isMainThread) + // await Task.sleep(1_000_000_000) // 1s delay to prevent UI render issue + + viewModel.mastodonStatusThreadViewModel.appendAncestor( + domain: threadContext.domain, + nodes: MastodonStatusThreadViewModel.Node.replyToThread( + for: threadContext.replyToID, + from: response.value.ancestors + ) + ) + viewModel.mastodonStatusThreadViewModel.appendDescendant( + domain: threadContext.domain, + nodes: MastodonStatusThreadViewModel.Node.children( + of: threadContext.statusID, + from: response.value.descendants + ) + ) + } catch { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch status context for \(threadContext.statusID) fail: \(error.localizedDescription)") + await enter(state: Fail.self) + } + } - .store(in: &viewModel.disposeBag) } } diff --git a/Mastodon/Scene/Thread/ThreadViewModel.swift b/Mastodon/Scene/Thread/ThreadViewModel.swift index 7c2f07c31..5a3127e66 100644 --- a/Mastodon/Scene/Thread/ThreadViewModel.swift +++ b/Mastodon/Scene/Thread/ThreadViewModel.swift @@ -13,24 +13,31 @@ import CoreDataStack import GameplayKit import MastodonSDK import MastodonMeta +import MastodonAsset +import MastodonLocalization class ThreadViewModel { + let logger = Logger(subsystem: "ThreadViewModel", category: "ViewModel") + var disposeBag = Set<AnyCancellable>() var rootItemObserver: AnyCancellable? // input let context: AppContext - let rootNode: CurrentValueSubject<RootNode?, Never> - let rootItem: CurrentValueSubject<Item?, Never> - let cellFrameCache = NSCache<NSNumber, NSValue>() - let existStatusFetchedResultsController: StatusFetchedResultsController + let mastodonStatusThreadViewModel: MastodonStatusThreadViewModel - weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate? - weak var tableView: UITableView? +// let cellFrameCache = NSCache<NSNumber, NSValue>() +// let existStatusFetchedResultsController: StatusFetchedResultsController + +// weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate? +// weak var tableView: UITableView? // output - var diffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>? + var diffableDataSource: UITableViewDiffableDataSource<StatusSection, StatusItem>? + @Published var root: StatusItem.Thread? + @Published var threadContext: ThreadContext? + private(set) lazy var loadThreadStateMachine: GKStateMachine = { let stateMachine = GKStateMachine(states: [ LoadThreadState.Initial(viewModel: self), @@ -42,153 +49,174 @@ class ThreadViewModel { stateMachine.enter(LoadThreadState.Initial.self) return stateMachine }() - let ancestorNodes = CurrentValueSubject<[ReplyNode], Never>([]) - let ancestorItems = CurrentValueSubject<[Item], Never>([]) - let descendantNodes = CurrentValueSubject<[LeafNode], Never>([]) - let descendantItems = CurrentValueSubject<[Item], Never>([]) - let navigationBarTitle: CurrentValueSubject<String?, Never> - let navigationBarTitleEmojiMeta: CurrentValueSubject<MastodonContent.Emojis, Never> + @Published var navigationBarTitle: MastodonMetaContent? - init(context: AppContext, optionalStatus: Status?) { + init( + context: AppContext, + optionalRoot: StatusItem.Thread? + ) { self.context = context - self.rootNode = CurrentValueSubject(optionalStatus.flatMap { RootNode(domain: $0.domain, statusID: $0.id, replyToID: $0.inReplyToID) }) - self.rootItem = CurrentValueSubject(optionalStatus.flatMap { Item.root(statusObjectID: $0.objectID, attribute: Item.StatusAttribute()) }) - self.existStatusFetchedResultsController = StatusFetchedResultsController(managedObjectContext: context.managedObjectContext, domain: nil, additionalTweetPredicate: nil) - self.navigationBarTitle = CurrentValueSubject( - optionalStatus.flatMap { L10n.Scene.Thread.title($0.author.displayNameWithFallback) }) - self.navigationBarTitleEmojiMeta = CurrentValueSubject(optionalStatus.flatMap { $0.author.emojiMeta } ?? [:]) + self.root = optionalRoot + self.mastodonStatusThreadViewModel = MastodonStatusThreadViewModel(context: context) +// self.rootNode = CurrentValueSubject(optionalStatus.flatMap { RootNode(domain: $0.domain, statusID: $0.id, replyToID: $0.inReplyToID) }) +// self.rootItem = CurrentValueSubject(optionalStatus.flatMap { Item.root(statusObjectID: $0.objectID, attribute: Item.StatusAttribute()) }) +// self.existStatusFetchedResultsController = StatusFetchedResultsController(managedObjectContext: context.managedObjectContext, domain: nil, additionalTweetPredicate: nil) +// self.navigationBarTitle = CurrentValueSubject( +// optionalStatus.flatMap { L10n.Scene.Thread.title($0.author.displayNameWithFallback) }) +// self.navigationBarTitleEmojiMeta = CurrentValueSubject(optionalStatus.flatMap { $0.author.emojis.asDictionary } ?? [:]) + // end init - // bind fetcher domain - context.authenticationService.activeMastodonAuthenticationBox - .receive(on: RunLoop.main) - .sink { [weak self] box in + ManagedObjectObserver.observe(context: context.managedObjectContext) + .sink(receiveCompletion: { completion in + // do nohting + }, receiveValue: { [weak self] changes in guard let self = self else { return } - self.existStatusFetchedResultsController.domain.value = box?.domain - } + + let objectIDs: [NSManagedObjectID] = changes.changeTypes.compactMap { changeType in + guard case let .delete(object) = changeType else { return nil } + return object.objectID + } + + self.delete(objectIDs: objectIDs) + }) .store(in: &disposeBag) - rootNode +// // bind fetcher domain +// context.authenticationService.activeMastodonAuthenticationBox +// .receive(on: RunLoop.main) +// .sink { [weak self] box in +// guard let self = self else { return } +// self.existStatusFetchedResultsController.domain.value = box?.domain +// } +// .store(in: &disposeBag) +// +// rootNode +// .receive(on: DispatchQueue.main) +// .sink { [weak self] rootNode in +// guard let self = self else { return } +// guard rootNode != nil else { return } +// self.loadThreadStateMachine.enter(LoadThreadState.Loading.self) +// } +// .store(in: &disposeBag) + + $root .receive(on: DispatchQueue.main) - .sink { [weak self] rootNode in + .sink { [weak self] root in guard let self = self else { return } - guard rootNode != nil else { return } - self.loadThreadStateMachine.enter(LoadThreadState.Loading.self) + guard case let .root(threadContext) = root else { return } + guard let status = threadContext.status.object(in: self.context.managedObjectContext) else { return } + + // bind threadContext + self.threadContext = .init( + domain: status.domain, + statusID: status.id, + replyToID: status.inReplyToID + ) + + // bind titleView + self.navigationBarTitle = { + let title = L10n.Scene.Thread.title(status.author.displayNameWithFallback) + let content = MastodonContent(content: title, emojis: status.author.emojis.asDictionary) + return try? MastodonMetaContent.convert(document: content) + }() } .store(in: &disposeBag) - - if optionalStatus == nil { - rootItem - .receive(on: DispatchQueue.main) - .sink { [weak self] rootItem in - guard let self = self else { return } - guard case let .root(objectID, _) = rootItem else { return } - self.context.managedObjectContext.perform { - guard let status = self.context.managedObjectContext.object(with: objectID) as? Status else { - return - } - self.rootNode.value = RootNode(domain: status.domain, statusID: status.id, replyToID: status.inReplyToID) - self.navigationBarTitle.value = L10n.Scene.Thread.title(status.author.displayNameWithFallback) - self.navigationBarTitleEmojiMeta.value = status.author.emojiMeta - } - } - .store(in: &disposeBag) - } - - rootItem - .receive(on: DispatchQueue.main) - .sink { [weak self] rootItem in - guard let self = self else { return } - guard case let .root(objectID, _) = rootItem else { return } - self.context.managedObjectContext.perform { - guard let status = self.context.managedObjectContext.object(with: objectID) as? Status else { - return - } - self.rootItemObserver = ManagedObjectObserver.observe(object: status) - .receive(on: DispatchQueue.main) - .sink(receiveCompletion: { _ in - // do nothing - }, receiveValue: { [weak self] change in - guard let self = self else { return } - switch change.changeType { - case .delete: - self.rootItem.value = nil - default: - break - } - }) - } - } - .store(in: &disposeBag) - - ancestorNodes - .receive(on: DispatchQueue.main) - .compactMap { [weak self] nodes -> [Item]? in - guard let self = self else { return nil } - guard !nodes.isEmpty else { return [] } - - guard let diffableDataSource = self.diffableDataSource else { return nil } - let oldSnapshot = diffableDataSource.snapshot() - var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:] - for item in oldSnapshot.itemIdentifiers { - switch item { - case .reply(let objectID, let attribute): - oldSnapshotAttributeDict[objectID] = attribute - default: - break - } - } - - var items: [Item] = [] - for node in nodes { - let attribute = oldSnapshotAttributeDict[node.statusObjectID] ?? Item.StatusAttribute() - items.append(Item.reply(statusObjectID: node.statusObjectID, attribute: attribute)) - } - - return items.reversed() - } - .assign(to: \.value, on: ancestorItems) - .store(in: &disposeBag) - - descendantNodes - .receive(on: DispatchQueue.main) - .compactMap { [weak self] nodes -> [Item]? in - guard let self = self else { return nil } - guard !nodes.isEmpty else { return [] } - - guard let diffableDataSource = self.diffableDataSource else { return nil } - let oldSnapshot = diffableDataSource.snapshot() - var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:] - for item in oldSnapshot.itemIdentifiers { - switch item { - case .leaf(let objectID, let attribute): - oldSnapshotAttributeDict[objectID] = attribute - default: - break - } - } - - var items: [Item] = [] - - func buildThread(node: LeafNode) { - let attribute = oldSnapshotAttributeDict[node.objectID] ?? Item.StatusAttribute() - items.append(Item.leaf(statusObjectID: node.objectID, attribute: attribute)) - // only expand the first child - if let firstChild = node.children.first { - if !node.isChildrenExpanded { - items.append(Item.leafBottomLoader(statusObjectID: node.objectID)) - } else { - buildThread(node: firstChild) - } - } - } - - for node in nodes { - buildThread(node: node) - } - return items - } - .assign(to: \.value, on: descendantItems) - .store(in: &disposeBag) + +// rootItem +// .receive(on: DispatchQueue.main) +// .sink { [weak self] rootItem in +// guard let self = self else { return } +// guard case let .root(objectID, _) = rootItem else { return } +// self.context.managedObjectContext.perform { +// guard let status = self.context.managedObjectContext.object(with: objectID) as? Status else { +// return +// } +// self.rootItemObserver = ManagedObjectObserver.observe(object: status) +// .receive(on: DispatchQueue.main) +// .sink(receiveCompletion: { _ in +// // do nothing +// }, receiveValue: { [weak self] change in +// guard let self = self else { return } +// switch change.changeType { +// case .delete: +// self.rootItem.value = nil +// default: +// break +// } +// }) +// } +// } +// .store(in: &disposeBag) +// +// ancestorNodes +// .receive(on: DispatchQueue.main) +// .compactMap { [weak self] nodes -> [Item]? in +// guard let self = self else { return nil } +// guard !nodes.isEmpty else { return [] } +// +// guard let diffableDataSource = self.diffableDataSource else { return nil } +// let oldSnapshot = diffableDataSource.snapshot() +// var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:] +// for item in oldSnapshot.itemIdentifiers { +// switch item { +// case .reply(let objectID, let attribute): +// oldSnapshotAttributeDict[objectID] = attribute +// default: +// break +// } +// } +// +// var items: [Item] = [] +// for node in nodes { +// let attribute = oldSnapshotAttributeDict[node.statusObjectID] ?? Item.StatusAttribute() +// items.append(Item.reply(statusObjectID: node.statusObjectID, attribute: attribute)) +// } +// +// return items.reversed() +// } +// .assign(to: \.value, on: ancestorItems) +// .store(in: &disposeBag) +// +// descendantNodes +// .receive(on: DispatchQueue.main) +// .compactMap { [weak self] nodes -> [Item]? in +// guard let self = self else { return nil } +// guard !nodes.isEmpty else { return [] } +// +// guard let diffableDataSource = self.diffableDataSource else { return nil } +// let oldSnapshot = diffableDataSource.snapshot() +// var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:] +// for item in oldSnapshot.itemIdentifiers { +// switch item { +// case .leaf(let objectID, let attribute): +// oldSnapshotAttributeDict[objectID] = attribute +// default: +// break +// } +// } +// +// var items: [Item] = [] +// +// func buildThread(node: LeafNode) { +// let attribute = oldSnapshotAttributeDict[node.objectID] ?? Item.StatusAttribute() +// items.append(Item.leaf(statusObjectID: node.objectID, attribute: attribute)) +// // only expand the first child +// if let firstChild = node.children.first { +// if !node.isChildrenExpanded { +// items.append(Item.leafBottomLoader(statusObjectID: node.objectID)) +// } else { +// buildThread(node: firstChild) +// } +// } +// } +// +// for node in nodes { +// buildThread(node: node) +// } +// return items +// } +// .assign(to: \.value, on: descendantItems) +// .store(in: &disposeBag) } deinit { @@ -199,119 +227,23 @@ class ThreadViewModel { extension ThreadViewModel { - struct RootNode { + struct ThreadContext { let domain: String let statusID: Mastodon.Entity.Status.ID let replyToID: Mastodon.Entity.Status.ID? } - class ReplyNode { - let statusID: Mastodon.Entity.Status.ID - let statusObjectID: NSManagedObjectID - - init(statusID: Mastodon.Entity.Status.ID, statusObjectID: NSManagedObjectID) { - self.statusID = statusID - self.statusObjectID = statusObjectID - } - - static func replyToThread( - for replyToID: Mastodon.Entity.Status.ID?, - from statuses: [Mastodon.Entity.Status], - domain: String, - managedObjectContext: NSManagedObjectContext - ) -> [ReplyNode] { - guard let replyToID = replyToID else { - return [] - } - - var nodes: [ReplyNode] = [] - managedObjectContext.performAndWait { - let request = Status.sortedFetchRequest - request.predicate = Status.predicate(domain: domain, ids: statuses.map { $0.id }) - request.fetchLimit = statuses.count - let objects = managedObjectContext.safeFetch(request) - - var objectDict: [Mastodon.Entity.Status.ID: Status] = [:] - for object in objects { - objectDict[object.id] = object - } - var nextID: Mastodon.Entity.Status.ID? = replyToID - while let _nextID = nextID { - guard let object = objectDict[_nextID] else { break } - nodes.append(ThreadViewModel.ReplyNode(statusID: _nextID, statusObjectID: object.objectID)) - nextID = object.inReplyToID - } - } - return nodes - } - } - - class LeafNode { - let statusID: Mastodon.Entity.Status.ID - let objectID: NSManagedObjectID - let repliesCount: Int - let children: [LeafNode] - - var isChildrenExpanded: Bool = false // default collapsed - - init( - statusID: Mastodon.Entity.Status.ID, - objectID: NSManagedObjectID, - repliesCount: Int, - children: [ThreadViewModel.LeafNode] - ) { - self.statusID = statusID - self.objectID = objectID - self.repliesCount = repliesCount - self.children = children - } - - static func tree( - for statusID: Mastodon.Entity.Status.ID, - from statuses: [Mastodon.Entity.Status], - domain: String, - managedObjectContext: NSManagedObjectContext - ) -> [LeafNode] { - // make an cache collection - var objectDict: [Mastodon.Entity.Status.ID: Status] = [:] - - managedObjectContext.performAndWait { - let request = Status.sortedFetchRequest - request.predicate = Status.predicate(domain: domain, ids: statuses.map { $0.id }) - request.fetchLimit = statuses.count - let objects = managedObjectContext.safeFetch(request) - - for object in objects { - objectDict[object.id] = object - } - } - - var tree: [LeafNode] = [] - let firstTierStatuses = statuses.filter { $0.inReplyToID == statusID } - for status in firstTierStatuses { - guard let node = node(of: status.id, objectDict: objectDict) else { continue } - tree.append(node) - } - - return tree - } - - static func node( - of statusID: Mastodon.Entity.Status.ID, - objectDict: [Mastodon.Entity.Status.ID: Status] - ) -> LeafNode? { - guard let object = objectDict[statusID] else { return nil } - let replies = (object.replyFrom ?? Set()).sorted( - by: { $0.createdAt > $1.createdAt } // order by date - ) - let children = replies.compactMap { node(of: $0.id, objectDict: objectDict) } - return LeafNode( - statusID: statusID, - objectID: object.objectID, - repliesCount: object.repliesCount?.intValue ?? 0, - children: children - ) - } - } - +} + +extension ThreadViewModel { + func delete(objectIDs: [NSManagedObjectID]) { + if let root = self.root, + case let .root(threadContext) = root, + objectIDs.contains(threadContext.status.objectID) + { + self.root = nil + } + + self.mastodonStatusThreadViewModel.delete(objectIDs: objectIDs) + } } diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift b/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift index ec4ac35ad..f730e0b8b 100644 --- a/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift +++ b/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift @@ -58,7 +58,7 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { // set to image hidden toVC.pagingViewController.view.alpha = 0 // set from image hidden. update hidden when paging. seealso: `MediaPreviewViewController` - transitionItem.source.updateAppearance(position: .start, index: toVC.viewModel.currentPage.value) + transitionItem.source.updateAppearance(position: .start, index: toVC.viewModel.currentPage) // Set transition image view assert(transitionItem.initialFrame != nil) @@ -78,7 +78,7 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { return imageView }() transitionItem.targetFrame = transitionTargetFrame - transitionItem.imageView = transitionImageView + transitionItem.transitionView = transitionImageView transitionContext.containerView.addSubview(transitionImageView) toVC.closeButtonBackground.alpha = 0 @@ -109,122 +109,166 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { return animator } - private func popTransition(using transitionContext: UIViewControllerContextTransitioning, curve: UIView.AnimationCurve = .easeInOut) -> UIViewPropertyAnimator { - guard let fromVC = transitionContext.viewController(forKey: .from) as? MediaPreviewViewController, - let fromView = transitionContext.view(forKey: .from), - let mediaPreviewImageViewController = fromVC.pagingViewController.currentViewController as? MediaPreviewImageViewController, - let index = fromVC.pagingViewController.currentIndex else { - fatalError() - } - - // assert view hierarchy not change - let toVC = transitionItem.previewableViewController - let targetFrame = toVC.sourceFrame(transitionItem: transitionItem, index: index) - - let imageView = mediaPreviewImageViewController.previewImageView.imageView - let _snapshot: UIView? = { - transitionItem.snapshotRaw = imageView - let snapshot = imageView.snapshotView(afterScreenUpdates: false) - snapshot?.clipsToBounds = true - snapshot?.contentMode = .scaleAspectFill - return snapshot - }() - guard let snapshot = _snapshot else { - transitionContext.completeTransition(false) - fatalError() - } - - let transitionMaskView = UIView(frame: transitionContext.containerView.bounds) - transitionMaskView.autoresizingMask = [.flexibleWidth, .flexibleHeight] - transitionContext.containerView.addSubview(transitionMaskView) - - let maskLayer = CAShapeLayer() - maskLayer.frame = transitionMaskView.bounds - let maskLayerFromPath = UIBezierPath(rect: maskLayer.bounds).cgPath - maskLayer.path = maskLayerFromPath - transitionMaskView.layer.mask = maskLayer - - transitionMaskView.addSubview(snapshot) - snapshot.center = transitionMaskView.center - fromVC.view.bringSubviewToFront(fromVC.closeButtonBackground) - - transitionItem.imageView = imageView - transitionItem.snapshotTransitioning = snapshot - transitionItem.initialFrame = snapshot.frame - transitionItem.targetFrame = targetFrame - - // disable interaction - fromVC.pagingViewController.isUserInteractionEnabled = false - + @discardableResult + private func popTransition( + using transitionContext: UIViewControllerContextTransitioning, + curve: UIView.AnimationCurve = .easeInOut + ) -> UIViewPropertyAnimator { let animator = popInteractiveTransitionAnimator - - self.transitionItem.snapshotRaw?.alpha = 0.0 - var needsMaskWithAnimation = true - let maskLayerToRect: CGRect? = { - guard case .mosaic = transitionItem.source else { return nil } - guard let navigationBar = toVC.navigationController?.navigationBar, let navigationBarSuperView = navigationBar.superview else { return nil } - let navigationBarFrameInWindow = navigationBarSuperView.convert(navigationBar.frame, to: nil) - - // crop rect top edge - var rect = transitionMaskView.frame - let _toViewFrameInWindow = toVC.view.superview.flatMap { $0.convert(toVC.view.frame, to: nil) } - if let toViewFrameInWindow = _toViewFrameInWindow, toViewFrameInWindow.minY > navigationBarFrameInWindow.maxY { - rect.origin.y = toViewFrameInWindow.minY - } else { - rect.origin.y = navigationBarFrameInWindow.maxY + UIView.separatorLineHeight(of: toVC.view) // extra hairline - } - - if rect.minY < snapshot.frame.minY { - needsMaskWithAnimation = false - } - - return rect - }() - let maskLayerToPath = maskLayerToRect.flatMap { UIBezierPath(rect: $0) }?.cgPath - let maskLayerToFinalRect: CGRect? = { - guard case .mosaic = transitionItem.source else { return 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 - return rect - }() - let maskLayerToFinalPath = maskLayerToFinalRect.flatMap { UIBezierPath(rect: $0) }?.cgPath - - if !needsMaskWithAnimation, let maskLayerToPath = maskLayerToPath { - maskLayer.path = maskLayerToPath + animator.addCompletion { position in + transitionContext.completeTransition(position == .end) } - - animator.addAnimations { - if let targetFrame = targetFrame { - self.transitionItem.snapshotTransitioning?.frame = targetFrame - } else { - fromView.alpha = 0 + + guard let fromVC = transitionContext.viewController(forKey: .from) as? MediaPreviewViewController, + let index = fromVC.pagingViewController.currentIndex, + let fromView = transitionContext.view(forKey: .from), + let mediaPreviewTransitionViewController = fromVC.pagingViewController.currentViewController as? MediaPreviewTransitionViewController, + let mediaPreviewTransitionContext = mediaPreviewTransitionViewController.mediaPreviewTransitionContext + else { + animator.addAnimations { + self.transitionItem.source.updateAppearance(position: .end, index: nil) } - self.transitionItem.sourceImageViewCornerRadius.flatMap { self.transitionItem.snapshotTransitioning?.layer.cornerRadius = $0 } + return animator + } + + // update close button + UIView.animate(withDuration: 0.33, delay: 0, options: [.curveEaseInOut]) { fromVC.closeButtonBackground.alpha = 0 - fromVC.visualEffectView.effect = nil - if let maskLayerToFinalPath = maskLayerToFinalPath { - maskLayer.path = maskLayerToFinalPath + } + animator.addCompletion { position in + UIView.animate(withDuration: 0.33, delay: 0, options: [.curveEaseInOut]) { + fromVC.closeButtonBackground.alpha = position == .end ? 0 : 1 } + } + + // update view controller + fromVC.pagingViewController.isUserInteractionEnabled = false + animator.addCompletion { position in + fromVC.pagingViewController.isUserInteractionEnabled = true + } + + // update background + let blurEffect = fromVC.visualEffectView.effect + animator.addAnimations { + fromVC.visualEffectView.effect = nil if UIAccessibility.isReduceTransparencyEnabled { fromVC.visualEffectView.alpha = 0 } } - animator.addCompletion { position in - self.transitionItem.snapshotTransitioning?.removeFromSuperview() - self.transitionItem.source.updateAppearance(position: position, index: nil) - transitionContext.completeTransition(position == .end) + fromVC.visualEffectView.effect = position == .end ? nil : blurEffect + if UIAccessibility.isReduceTransparencyEnabled { + fromVC.visualEffectView.alpha = position == .end ? 0 : 1 + } } + + // update transition item source + animator.addCompletion { position in + if position == .end { + // reset appearance + self.transitionItem.source.updateAppearance(position: position, index: nil) + } + } + + // update transitioning snapshot + let transitionMaskView = UIView(frame: transitionContext.containerView.bounds) + transitionMaskView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + transitionContext.containerView.addSubview(transitionMaskView) + transitionItem.interactiveTransitionMaskView = transitionMaskView + + animator.addCompletion { position in + transitionMaskView.removeFromSuperview() + } + + let transitionMaskViewTapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer + transitionMaskViewTapGestureRecognizer.addTarget(self, action: #selector(MediaHostToMediaPreviewViewControllerAnimatedTransitioning.transitionMaskViewTapGestureRecognizerHandler(_:))) + transitionMaskView.addGestureRecognizer(transitionMaskViewTapGestureRecognizer) + + let maskLayer = CAShapeLayer() + maskLayer.frame = transitionMaskView.bounds + maskLayer.path = UIBezierPath(rect: maskLayer.bounds).cgPath + transitionMaskView.layer.mask = maskLayer + transitionItem.interactiveTransitionMaskLayer = maskLayer + + // attach transitioning snapshot + mediaPreviewTransitionContext.snapshot.center = transitionMaskView.center + mediaPreviewTransitionContext.snapshot.contentMode = .scaleAspectFill + mediaPreviewTransitionContext.snapshot.clipsToBounds = true + transitionMaskView.addSubview(mediaPreviewTransitionContext.snapshot) + fromVC.view.bringSubviewToFront(fromVC.closeButtonBackground) + transitionItem.transitionView = mediaPreviewTransitionContext.transitionView + transitionItem.snapshotTransitioning = mediaPreviewTransitionContext.snapshot + transitionItem.initialFrame = mediaPreviewTransitionContext.snapshot.frame + + // assert view hierarchy not change + let toVC = transitionItem.previewableViewController + let targetFrame = toVC.sourceFrame(transitionItem: transitionItem, index: index) + transitionItem.targetFrame = targetFrame + + animator.addAnimations { + self.transitionItem.snapshotTransitioning?.layer.cornerRadius = self.transitionItem.sourceImageViewCornerRadius ?? 0 + } + animator.addCompletion { position in + self.transitionItem.snapshotTransitioning?.layer.cornerRadius = position == .end ? 0 : (self.transitionItem.sourceImageViewCornerRadius ?? 0) + } + + if !isInteractive { + animator.addAnimations { + if let targetFrame = targetFrame { + self.transitionItem.snapshotTransitioning?.frame = targetFrame + } else { + fromView.alpha = 0 + } + } + + // calculate transition mask + let maskLayerToRect: CGRect? = { + guard case .attachments = transitionItem.source else { return nil } + guard let navigationBar = toVC.navigationController?.navigationBar, let navigationBarSuperView = navigationBar.superview else { return nil } + let navigationBarFrameInWindow = navigationBarSuperView.convert(navigationBar.frame, to: nil) + + // crop rect top edge + var rect = transitionMaskView.frame + let _toViewFrameInWindow = toVC.view.superview.flatMap { $0.convert(toVC.view.frame, to: nil) } + if let toViewFrameInWindow = _toViewFrameInWindow, toViewFrameInWindow.minY > navigationBarFrameInWindow.maxY { + rect.origin.y = toViewFrameInWindow.minY + } else { + rect.origin.y = navigationBarFrameInWindow.maxY + UIView.separatorLineHeight(of: toVC.view) // extra hairline + } + + return rect + }() + let maskLayerToPath = maskLayerToRect.flatMap { UIBezierPath(rect: $0) }?.cgPath + let maskLayerToFinalRect: CGRect? = { + guard case .attachments = transitionItem.source else { return 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 + return rect + }() + let maskLayerToFinalPath = maskLayerToFinalRect.flatMap { UIBezierPath(rect: $0) }?.cgPath + + if let maskLayerToPath = maskLayerToPath { + maskLayer.path = maskLayerToPath + } + } + + mediaPreviewTransitionContext.transitionView.isHidden = true + animator.addCompletion { position in + self.transitionItem.transitionView?.isHidden = position == .end + self.transitionItem.snapshotRaw?.alpha = position == .start ? 1.0 : 0.0 + self.transitionItem.snapshotTransitioning?.removeFromSuperview() + } + return animator } @@ -248,100 +292,7 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { } private func popInteractiveTransition(using transitionContext: UIViewControllerContextTransitioning) { - guard let fromVC = transitionContext.viewController(forKey: .from) as? MediaPreviewViewController, - let _ = transitionContext.view(forKey: .from), - let mediaPreviewImageViewController = fromVC.pagingViewController.currentViewController as? MediaPreviewImageViewController, - let index = fromVC.pagingViewController.currentIndex else { - fatalError() - } - - // assert view hierarchy not change - let toVC = transitionItem.previewableViewController - let targetFrame = toVC.sourceFrame(transitionItem: transitionItem, index: index) - - let imageView = mediaPreviewImageViewController.previewImageView.imageView - let _snapshot: UIView? = { - transitionItem.snapshotRaw = imageView - let snapshot = imageView.snapshotView(afterScreenUpdates: false) - snapshot?.clipsToBounds = true - snapshot?.contentMode = .scaleAspectFill - return snapshot - }() - guard let snapshot = _snapshot else { - transitionContext.completeTransition(false) - return - } - - let transitionMaskView = UIView(frame: transitionContext.containerView.bounds) - transitionMaskView.autoresizingMask = [.flexibleWidth, .flexibleHeight] - transitionContext.containerView.addSubview(transitionMaskView) - transitionItem.interactiveTransitionMaskView = transitionMaskView - - let transitionMaskViewTapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer - transitionMaskViewTapGestureRecognizer.addTarget(self, action: #selector(MediaHostToMediaPreviewViewControllerAnimatedTransitioning.transitionMaskViewTapGestureRecognizerHandler(_:))) - transitionMaskView.addGestureRecognizer(transitionMaskViewTapGestureRecognizer) - - let maskLayer = CAShapeLayer() - maskLayer.frame = transitionMaskView.bounds - maskLayer.path = UIBezierPath(rect: maskLayer.bounds).cgPath - transitionMaskView.layer.mask = maskLayer - transitionItem.interactiveTransitionMaskLayer = maskLayer - - transitionMaskView.addSubview(snapshot) - snapshot.center = transitionMaskView.center - fromVC.view.bringSubviewToFront(fromVC.closeButtonBackground) - - transitionItem.imageView = imageView - transitionItem.snapshotTransitioning = snapshot - transitionItem.initialFrame = snapshot.frame - transitionItem.targetFrame = targetFrame ?? snapshot.frame - - // disable interaction - fromVC.pagingViewController.isUserInteractionEnabled = false - - let animator = popInteractiveTransitionAnimator - - let blurEffect = fromVC.visualEffectView.effect - self.transitionItem.snapshotRaw?.alpha = 0.0 - - UIView.animate(withDuration: 0.33, delay: 0, options: [.curveEaseInOut]) { - fromVC.closeButtonBackground.alpha = 0 - } - - animator.addAnimations { - switch self.transitionItem.source { - case .profileBanner: - self.transitionItem.snapshotTransitioning?.alpha = 0.4 - default: - break - } - fromVC.visualEffectView.effect = nil - self.transitionItem.sourceImageViewCornerRadius.flatMap { self.transitionItem.snapshotTransitioning?.layer.cornerRadius = $0 } - if UIAccessibility.isReduceTransparencyEnabled { - fromVC.visualEffectView.alpha = 0 - } - } - - animator.addCompletion { position in - fromVC.pagingViewController.isUserInteractionEnabled = true - fromVC.closeButtonBackground.alpha = position == .end ? 0 : 1 - self.transitionItem.imageView?.isHidden = position == .end - self.transitionItem.snapshotRaw?.alpha = position == .start ? 1.0 : 0.0 - self.transitionItem.snapshotTransitioning?.removeFromSuperview() - if position == .end { - // reset appearance - self.transitionItem.source.updateAppearance(position: position, index: nil) - } - fromVC.visualEffectView.effect = position == .end ? nil : blurEffect - transitionMaskView.removeFromSuperview() - UIView.animate(withDuration: 0.33, delay: 0, options: [.curveEaseInOut]) { - fromVC.closeButtonBackground.alpha = position == .end ? 0 : 1 - } - if UIAccessibility.isReduceTransparencyEnabled { - fromVC.visualEffectView.alpha = position == .end ? 0 : 1 - } - transitionContext.completeTransition(position == .end) - } + popTransition(using: transitionContext) } } @@ -380,7 +331,7 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { popInteractiveTransitionAnimator.fractionComplete = percent transitionContext.updateInteractiveTransition(percent) updateTransitionItemPosition(of: translation) - + // Reset translation to zero sender.setTranslation(CGPoint.zero, in: transitionContext.containerView) case .ended, .cancelled: @@ -399,7 +350,9 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { } private func convert(_ velocity: CGPoint, for item: MediaPreviewTransitionItem?) -> CGVector { - guard let currentFrame = item?.imageView?.frame, let targetFrame = item?.targetFrame else { + guard let currentFrame = item?.transitionView?.frame, + let targetFrame = item?.targetFrame + else { return CGVector.zero } @@ -450,7 +403,7 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { var needsMaskWithAnimation = true let maskLayerToRect: CGRect? = { - guard case .mosaic = transitionItem.source else { return nil } + guard case .attachments = transitionItem.source else { return nil } guard let navigationBar = toVC.navigationController?.navigationBar, let navigationBarSuperView = navigationBar.superview else { return nil } let navigationBarFrameInWindow = navigationBarSuperView.convert(navigationBar.frame, to: nil) @@ -476,7 +429,7 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { } let maskLayerToFinalRect: CGRect? = { - guard case .mosaic = transitionItem.source else { return nil } + guard case .attachments = transitionItem.source else { return nil } var rect = maskLayerToRect ?? transitionMaskView.frame // clip rect bottom when tabBar visible guard let tabBarController = toVC.tabBarController, diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionController.swift b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionController.swift index bd5781b0b..d8d822bc5 100644 --- a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionController.swift +++ b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionController.swift @@ -76,7 +76,7 @@ extension MediaPreviewTransitionController: UIViewControllerTransitioningDelegat return MediaHostToMediaPreviewViewControllerAnimatedTransitioning( operation: .push, - transitionItem: mediaPreviewViewController.viewModel.pushTransitionItem, + transitionItem: mediaPreviewViewController.viewModel.transitionItem, panGestureRecognizer: panGestureRecognizer ) } @@ -94,7 +94,7 @@ extension MediaPreviewTransitionController: UIViewControllerTransitioningDelegat return MediaHostToMediaPreviewViewControllerAnimatedTransitioning( operation: .pop, - transitionItem: mediaPreviewViewController.viewModel.pushTransitionItem, + transitionItem: mediaPreviewViewController.viewModel.transitionItem, panGestureRecognizer: panGestureRecognizer ) } diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift index 7024d3056..7d80de322 100644 --- a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift +++ b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift @@ -7,6 +7,7 @@ import UIKit import CoreData +import MastodonUI class MediaPreviewTransitionItem: Identifiable { @@ -15,25 +16,28 @@ class MediaPreviewTransitionItem: Identifiable { var previewableViewController: MediaPreviewableViewController // source - // value maybe invalid when preview paging var image: UIImage? var aspectRatio: CGSize? var initialFrame: CGRect? = nil var sourceImageView: UIImageView? var sourceImageViewCornerRadius: CGFloat? - + // target var targetFrame: CGRect? = nil // transitioning - var imageView: UIImageView? + var transitionView: UIView? var snapshotRaw: UIView? var snapshotTransitioning: UIView? var touchOffset: CGVector = CGVector.zero var interactiveTransitionMaskView: UIView? var interactiveTransitionMaskLayer: CAShapeLayer? - init(id: UUID = UUID(), source: Source, previewableViewController: MediaPreviewableViewController) { + init( + id: UUID = UUID(), + source: Source, + previewableViewController: MediaPreviewableViewController + ) { self.id = id self.source = source self.previewableViewController = previewableViewController @@ -43,21 +47,24 @@ class MediaPreviewTransitionItem: Identifiable { extension MediaPreviewTransitionItem { enum Source { - case mosaic(MosaicImageViewContainer) + case attachment(MediaView) + case attachments(MediaGridContainerView) case profileAvatar(ProfileHeaderView) case profileBanner(ProfileHeaderView) func updateAppearance(position: UIViewAnimatingPosition, index: Int?) { let alpha: CGFloat = position == .end ? 1 : 0 switch self { - case .mosaic(let mosaicImageViewContainer): + case .attachment(let mediaView): + mediaView.alpha = alpha + case .attachments(let mediaGridContainerView): if let index = index { - mosaicImageViewContainer.setImageView(alpha: 0, index: index) + mediaGridContainerView.setAlpha(alpha, index: index) } else { - mosaicImageViewContainer.setImageViews(alpha: alpha) + mediaGridContainerView.setAlpha(alpha) } case .profileAvatar(let profileHeaderView): - profileHeaderView.avatarImageView.alpha = alpha + profileHeaderView.avatarButton.alpha = alpha case .profileBanner: break // keep source } diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionViewController.swift b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionViewController.swift new file mode 100644 index 000000000..d1809d0f0 --- /dev/null +++ b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionViewController.swift @@ -0,0 +1,20 @@ +// +// MediaPreviewTransitionViewController.swift +// TwidereX +// +// Created by MainasuK on 2021-12-8. +// Copyright © 2021 Twidere. All rights reserved. +// + +import UIKit + +protocol MediaPreviewTransitionViewController: UIViewController { + var mediaPreviewTransitionContext: MediaPreviewTransitionContext? { get } +} + + +struct MediaPreviewTransitionContext { + let transitionView: UIView + let snapshot: UIView + let snapshotTransitioning: UIView +} diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift index 1fedf0d40..696b72abd 100644 --- a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift +++ b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift @@ -15,12 +15,14 @@ protocol MediaPreviewableViewController: UIViewController { extension MediaPreviewableViewController { func sourceFrame(transitionItem: MediaPreviewTransitionItem, index: Int) -> CGRect? { switch transitionItem.source { - case .mosaic(let mosaicImageViewContainer): - guard index < mosaicImageViewContainer.imageViews.count else { return nil } - let imageView = mosaicImageViewContainer.imageViews[index] - return imageView.superview?.convert(imageView.frame, to: nil) + case .attachment(let mediaView): + return mediaView.superview?.convert(mediaView.frame, to: nil) + case .attachments(let mediaGridContainerView): + guard index < mediaGridContainerView.mediaViews.count else { return nil } + let mediaView = mediaGridContainerView.mediaViews[index] + return mediaView.superview?.convert(mediaView.frame, to: nil) case .profileAvatar(let profileHeaderView): - return profileHeaderView.avatarImageView.superview?.convert(profileHeaderView.avatarImageView.frame, to: nil) + return profileHeaderView.avatarButton.superview?.convert(profileHeaderView.avatarButton.frame, to: nil) case .profileBanner: return nil // fallback to snapshot.frame } diff --git a/Mastodon/Scene/Wizard/WizardViewController.swift b/Mastodon/Scene/Wizard/WizardViewController.swift index 2678c712d..d7530d49b 100644 --- a/Mastodon/Scene/Wizard/WizardViewController.swift +++ b/Mastodon/Scene/Wizard/WizardViewController.swift @@ -8,6 +8,8 @@ import os.log import UIKit import Combine +import MastodonAsset +import MastodonLocalization protocol WizardViewControllerDelegate: AnyObject { func readyToLayoutItem(_ wizardViewController: WizardViewController, item: WizardViewController.Item) -> Bool @@ -35,7 +37,7 @@ class WizardViewController: UIViewController { let backgroundView: UIView = { let view = UIView() - view.backgroundColor = UIColor.black.withAlphaComponent(0.7) + view.backgroundColor = UIColor.black.withAlphaComponent(0.5) return view }() diff --git a/Mastodon/Service/APIService/APIService+APIError.swift b/Mastodon/Service/APIService/APIService+APIError.swift index 181495cf4..5670f8053 100644 --- a/Mastodon/Service/APIService/APIService+APIError.swift +++ b/Mastodon/Service/APIService/APIService+APIError.swift @@ -7,6 +7,7 @@ import UIKit import MastodonSDK +import MastodonLocalization extension APIService { enum APIError: Error { diff --git a/Mastodon/Service/APIService/APIService+Account.swift b/Mastodon/Service/APIService/APIService+Account.swift index 7638f2444..11da2f4ee 100644 --- a/Mastodon/Service/APIService/APIService+Account.swift +++ b/Mastodon/Service/APIService/APIService+Account.swift @@ -5,6 +5,7 @@ // Created by MainasuK Cirno on 2021/2/2. // +import os.log import Foundation import Combine import CommonOSLog @@ -16,42 +17,32 @@ extension APIService { domain: String, userID: Mastodon.Entity.Account.ID, authorization: Mastodon.API.OAuth.Authorization - ) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, Error> { - return Mastodon.API.Account.accountInfo( + ) async throws -> Mastodon.Response.Content<Mastodon.Entity.Account> { + let response = try await Mastodon.API.Account.accountInfo( session: session, domain: domain, userID: userID, authorization: authorization - ) - .flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, Error> in - let log = OSLog.api - let account = response.value - - return self.backgroundManagedObjectContext.performChanges { - let (mastodonUser, isCreated) = APIService.CoreData.createOrMergeMastodonUser( - into: self.backgroundManagedObjectContext, - for: nil, - in: domain, - entity: account, - userCache: nil, - networkDate: response.networkDate, - log: log + ).singleOutput() + + let managedObjectContext = self.backgroundManagedObjectContext + try await managedObjectContext.performChanges { + let result = Persistence.MastodonUser.createOrMerge( + in: managedObjectContext, + context: Persistence.MastodonUser.PersistContext( + domain: domain, + entity: response.value, + cache: nil, + networkDate: response.networkDate ) - let flag = isCreated ? "+" : "-" - os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: fetch mastodon user [%s](%s)%s", ((#file as NSString).lastPathComponent), #line, #function, flag, mastodonUser.id, mastodonUser.username) - } - .setFailureType(to: Error.self) - .tryMap { result -> Mastodon.Response.Content<Mastodon.Entity.Account> in - switch result { - case .success: - return response - case .failure(let error): - throw error - } - } - .eraseToAnyPublisher() + ) + + let flag = result.isNewInsertion ? "+" : "-" + let logger = Logger(subsystem: "APIService", category: "AccountInfo") + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch mastodon user [\(flag)](\(response.value.id))\(response.value.username)") } - .eraseToAnyPublisher() + + return response } } @@ -71,18 +62,19 @@ extension APIService { let log = OSLog.api let account = response.value - return self.backgroundManagedObjectContext.performChanges { - let (mastodonUser, isCreated) = APIService.CoreData.createOrMergeMastodonUser( - into: self.backgroundManagedObjectContext, - for: nil, - in: domain, - entity: account, - userCache: nil, - networkDate: response.networkDate, - log: log + let managedObjectContext = self.backgroundManagedObjectContext + return managedObjectContext.performChanges { + let result = Persistence.MastodonUser.createOrMerge( + in: managedObjectContext, + context: Persistence.MastodonUser.PersistContext( + domain: domain, + entity: account, + cache: nil, + networkDate: response.networkDate + ) ) - let flag = isCreated ? "+" : "-" - os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: mastodon user [%s](%s)%s verifed", ((#file as NSString).lastPathComponent), #line, #function, flag, mastodonUser.id, mastodonUser.username) + let flag = result.isNewInsertion ? "+" : "-" + os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: mastodon user [%s](%s)%s verifed", ((#file as NSString).lastPathComponent), #line, #function, flag, result.user.id, result.user.username) } .setFailureType(to: Error.self) .tryMap { result -> Mastodon.Response.Content<Mastodon.Entity.Account> in @@ -102,41 +94,34 @@ extension APIService { domain: String, query: Mastodon.API.Account.UpdateCredentialQuery, authorization: Mastodon.API.OAuth.Authorization - ) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, Error> { - return Mastodon.API.Account.updateCredentials( + ) async throws -> Mastodon.Response.Content<Mastodon.Entity.Account> { + let logger = Logger(subsystem: "APIService", category: "Account") + + let response = try await Mastodon.API.Account.updateCredentials( session: session, domain: domain, query: query, authorization: authorization - ) - .flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, Error> in - let log = OSLog.api - let account = response.value - - return self.backgroundManagedObjectContext.performChanges { - let (mastodonUser, isCreated) = APIService.CoreData.createOrMergeMastodonUser( - into: self.backgroundManagedObjectContext, - for: nil, - in: domain, - entity: account, - userCache: nil, - networkDate: response.networkDate, - log: log) - let flag = isCreated ? "+" : "-" - os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: mastodon user [%s](%s)%s verifed", ((#file as NSString).lastPathComponent), #line, #function, flag, mastodonUser.id, mastodonUser.username) - } - .setFailureType(to: Error.self) - .tryMap { result -> Mastodon.Response.Content<Mastodon.Entity.Account> in - switch result { - case .success: - return response - case .failure(let error): - throw error - } - } - .eraseToAnyPublisher() + ).singleOutput() + + let managedObjectContext = self.backgroundManagedObjectContext + try await managedObjectContext.performChanges { + let result = Persistence.MastodonUser.createOrMerge( + in: managedObjectContext, + context: Persistence.MastodonUser.PersistContext( + domain: domain, + entity: response.value, + cache: nil, + networkDate: response.networkDate + ) + ) + let flag = result.isNewInsertion ? "+" : "-" + let userID = response.value.id + let username = response.value.username + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): mastodon user [\(flag)](\(userID)\(username) verifed") } - .eraseToAnyPublisher() + + return response } func accountRegister( diff --git a/Mastodon/Service/APIService/APIService+Block.swift b/Mastodon/Service/APIService/APIService+Block.swift index 209ee361f..428401703 100644 --- a/Mastodon/Service/APIService/APIService+Block.swift +++ b/Mastodon/Service/APIService/APIService+Block.swift @@ -14,184 +14,99 @@ import MastodonSDK extension APIService { + private struct MastodonBlockContext { + let sourceUserID: MastodonUser.ID + let targetUserID: MastodonUser.ID + let targetUsername: String + let isBlocking: Bool + let isFollowing: Bool + } + func toggleBlock( - for mastodonUser: MastodonUser, - activeMastodonAuthenticationBox: MastodonAuthenticationBox - ) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> { - let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light) - let notificationFeedbackGenerator = UINotificationFeedbackGenerator() + user: ManagedObjectRecord<MastodonUser>, + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content<Mastodon.Entity.Relationship> { + let logger = Logger(subsystem: "APIService", category: "Block") - return blockUpdateLocal( - mastodonUserObjectID: mastodonUser.objectID, - mastodonAuthenticationBox: activeMastodonAuthenticationBox - ) - .receive(on: DispatchQueue.main) - .handleEvents { _ in - impactFeedbackGenerator.prepare() - } receiveOutput: { _ in - impactFeedbackGenerator.impactOccurred() - } receiveCompletion: { completion in - switch completion { - case .failure(let error): - // TODO: handle error - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] local relationship update fail", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) - assertionFailure(error.localizedDescription) - case .finished: - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] local relationship update success", ((#file as NSString).lastPathComponent), #line, #function) - break + let managedObjectContext = backgroundManagedObjectContext + let blockContext: MastodonBlockContext = try await managedObjectContext.performChanges { + guard let user = user.object(in: managedObjectContext), + let authentication = authenticationBox.authenticationRecord.object(in: managedObjectContext) + else { + throw APIError.implicit(.badRequest) } - } - .flatMap { blockQueryType, mastodonUserID -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> in - return self.blockUpdateRemote( - blockQueryType: blockQueryType, - mastodonUserID: mastodonUserID, - mastodonAuthenticationBox: activeMastodonAuthenticationBox + let me = authentication.user + let isBlocking = user.blockingBy.contains(me) + let isFollowing = user.followingBy.contains(me) + // toggle block state + user.update(isBlocking: !isBlocking, by: me) + // update follow state implicitly + if !isBlocking { + // will do block action. set to unfollow + user.update(isFollowing: false, by: me) + } + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [Local] update user[\(user.id)](\(user.username)) block state: \(!isBlocking)") + return MastodonBlockContext( + sourceUserID: me.id, + targetUserID: user.id, + targetUsername: user.username, + isBlocking: isBlocking, + isFollowing: isFollowing ) } - .receive(on: DispatchQueue.main) - .handleEvents(receiveCompletion: { [weak self] completion in - guard let self = self else { return } - switch completion { - case .failure(let error): - os_log("%{public}s[%{public}ld], %{public}s: [Relationship] remote friendship update fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) - // TODO: handle error - - // rollback - - self.blockUpdateLocal( - mastodonUserObjectID: mastodonUser.objectID, - mastodonAuthenticationBox: activeMastodonAuthenticationBox - ) - .sink { completion in - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Friendship] rollback finish", ((#file as NSString).lastPathComponent), #line, #function) - } receiveValue: { _ in - // do nothing - notificationFeedbackGenerator.prepare() - notificationFeedbackGenerator.notificationOccurred(.error) - } - .store(in: &self.disposeBag) - - case .finished: - notificationFeedbackGenerator.notificationOccurred(.success) - os_log("%{public}s[%{public}ld], %{public}s: [Friendship] remote friendship update success", ((#file as NSString).lastPathComponent), #line, #function) - } - }) - .eraseToAnyPublisher() - } - -} - -extension APIService { - - // update database local and return block query update type for remote request - func blockUpdateLocal( - mastodonUserObjectID: NSManagedObjectID, - mastodonAuthenticationBox: MastodonAuthenticationBox - ) -> AnyPublisher<(Mastodon.API.Account.BlockQueryType, MastodonUser.ID), Error> { - let domain = mastodonAuthenticationBox.domain - let requestMastodonUserID = mastodonAuthenticationBox.userID - var _targetMastodonUserID: MastodonUser.ID? - var _queryType: Mastodon.API.Account.BlockQueryType? - let managedObjectContext = backgroundManagedObjectContext - - return managedObjectContext.performChanges { - let request = MastodonUser.sortedFetchRequest - request.predicate = MastodonUser.predicate(domain: domain, id: requestMastodonUserID) - request.fetchLimit = 1 - request.returnsObjectsAsFaults = false - guard let _requestMastodonUser = managedObjectContext.safeFetch(request).first else { - assertionFailure() - return + let result: Result<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> + do { + if blockContext.isBlocking { + let response = try await Mastodon.API.Account.unblock( + session: session, + domain: authenticationBox.domain, + accountID: blockContext.targetUserID, + authorization: authenticationBox.userAuthorization + ).singleOutput() + result = .success(response) + } else { + let response = try await Mastodon.API.Account.block( + session: session, + domain: authenticationBox.domain, + accountID: blockContext.targetUserID, + authorization: authenticationBox.userAuthorization + ).singleOutput() + result = .success(response) } - - let mastodonUser = managedObjectContext.object(with: mastodonUserObjectID) as! MastodonUser - _targetMastodonUserID = mastodonUser.id - - let isBlocking = (mastodonUser.blockingBy ?? Set()).contains(_requestMastodonUser) - _queryType = isBlocking ? .unblock : .block - mastodonUser.update(isBlocking: !isBlocking, by: _requestMastodonUser) + } catch { + result = .failure(error) + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [Remote] update user[\(blockContext.targetUserID)](\(blockContext.targetUsername)) block failure: \(error.localizedDescription)") } - .tryMap { result in + + try await managedObjectContext.performChanges { + guard let user = user.object(in: managedObjectContext), + let authentication = authenticationBox.authenticationRecord.object(in: managedObjectContext) + else { return } + let me = authentication.user + switch result { - case .success: - guard let targetMastodonUserID = _targetMastodonUserID, - let queryType = _queryType else { - throw APIError.implicit(.badRequest) - } - return (queryType, targetMastodonUserID) - - case .failure(let error): - assertionFailure(error.localizedDescription) - throw error + case .success(let response): + let relationship = response.value + Persistence.MastodonUser.update( + mastodonUser: user, + context: Persistence.MastodonUser.RelationshipContext( + entity: relationship, + me: me, + networkDate: response.networkDate + ) + ) + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [Remote] update user[\(blockContext.targetUserID)](\(blockContext.targetUsername)) block state: \(relationship.blocking)") + case .failure: + // rollback + user.update(isBlocking: blockContext.isBlocking, by: me) + user.update(isFollowing: blockContext.isFollowing, by: me) + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [Remote] rollback user[\(blockContext.targetUserID)](\(blockContext.targetUsername)) block state") } } - .eraseToAnyPublisher() - } - - func blockUpdateRemote( - blockQueryType: Mastodon.API.Account.BlockQueryType, - mastodonUserID: MastodonUser.ID, - mastodonAuthenticationBox: MastodonAuthenticationBox - ) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> { - let domain = mastodonAuthenticationBox.domain - let authorization = mastodonAuthenticationBox.userAuthorization - let requestMastodonUserID = mastodonAuthenticationBox.userID - - return Mastodon.API.Account.block( - session: session, - domain: domain, - accountID: mastodonUserID, - blockQueryType: blockQueryType, - authorization: authorization - ) - .flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, 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 } - - let lookUpMastodonUserRequest = MastodonUser.sortedFetchRequest - lookUpMastodonUserRequest.predicate = MastodonUser.predicate(domain: domain, id: mastodonUserID) - lookUpMastodonUserRequest.fetchLimit = 1 - let lookUpMastodonUser = managedObjectContext.safeFetch(lookUpMastodonUserRequest).first - - if let lookUpMastodonUser = lookUpMastodonUser { - let entity = response.value - APIService.CoreData.update(user: lookUpMastodonUser, entity: entity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: response.networkDate) - } - } - .tryMap { result -> Mastodon.Response.Content<Mastodon.Entity.Relationship> in - switch result { - case .success: - return response - case .failure(let error): - throw error - } - } - .eraseToAnyPublisher() - } - .handleEvents(receiveCompletion: { [weak self] completion in - guard let _ = self else { return } - switch completion { - case .failure(let error): - // TODO: handle error in banner - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] block update fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) - - case .finished: - // TODO: update relationship - switch blockQueryType { - case .block: - break - case .unblock: - break - } - } - }) - .eraseToAnyPublisher() + + let response = try result.get() + return response } } - diff --git a/Mastodon/Service/APIService/APIService+Favorite.swift b/Mastodon/Service/APIService/APIService+Favorite.swift index 78a20d10a..20c2fe729 100644 --- a/Mastodon/Service/APIService/APIService+Favorite.swift +++ b/Mastodon/Service/APIService/APIService+Favorite.swift @@ -15,122 +15,94 @@ import CommonOSLog extension APIService { - // make local state change only - func favorite( - statusObjectID: NSManagedObjectID, - mastodonUserObjectID: NSManagedObjectID, - favoriteKind: Mastodon.API.Favorites.FavoriteKind - ) -> AnyPublisher<Status.ID, Error> { - var _targetStatusID: Status.ID? - let managedObjectContext = backgroundManagedObjectContext - return managedObjectContext.performChanges { - let status = managedObjectContext.object(with: statusObjectID) as! Status - let mastodonUser = managedObjectContext.object(with: mastodonUserObjectID) as! MastodonUser - let targetStatus = status.reblog ?? status - let targetStatusID = targetStatus.id - _targetStatusID = targetStatusID - - let favouritesCount: NSNumber - switch favoriteKind { - case .create: - favouritesCount = NSNumber(value: targetStatus.favouritesCount.intValue + 1) - case .destroy: - favouritesCount = NSNumber(value: max(0, targetStatus.favouritesCount.intValue - 1)) - } - targetStatus.update(favouritesCount: favouritesCount) - targetStatus.update(liked: favoriteKind == .create, by: mastodonUser) - - } - .tryMap { result in - switch result { - case .success: - guard let targetStatusID = _targetStatusID else { - throw APIError.implicit(.badRequest) - } - return targetStatusID - - case .failure(let error): - assertionFailure(error.localizedDescription) - throw error - } - } - .eraseToAnyPublisher() + private struct MastodonFavoriteContext { + let statusID: Status.ID + let isFavorited: Bool + let favoritedCount: Int64 } - // send favorite request to remote func favorite( - statusID: Mastodon.Entity.Status.ID, - favoriteKind: Mastodon.API.Favorites.FavoriteKind, - mastodonAuthenticationBox: MastodonAuthenticationBox - ) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Status>, Error> { - let authorization = mastodonAuthenticationBox.userAuthorization - let requestMastodonUserID = mastodonAuthenticationBox.userID - return Mastodon.API.Favorites.favorites(domain: mastodonAuthenticationBox.domain, statusID: statusID, session: session, authorization: authorization, favoriteKind: favoriteKind) - .map { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Status>, Error> in - let log = OSLog.api - let entity = response.value - let managedObjectContext = self.backgroundManagedObjectContext - - return managedObjectContext.performChanges { - let _requestMastodonUser: MastodonUser? = { - let request = MastodonUser.sortedFetchRequest - request.predicate = MastodonUser.predicate(domain: mastodonAuthenticationBox.domain, id: requestMastodonUserID) - request.fetchLimit = 1 - request.returnsObjectsAsFaults = false - do { - return try managedObjectContext.fetch(request).first - } catch { - assertionFailure(error.localizedDescription) - return nil - } - }() - let _oldStatus: Status? = { - let request = Status.sortedFetchRequest - request.predicate = Status.predicate(domain: mastodonAuthenticationBox.domain, id: statusID) - request.fetchLimit = 1 - request.returnsObjectsAsFaults = false - request.relationshipKeyPathsForPrefetching = [#keyPath(Status.reblog)] - do { - return try managedObjectContext.fetch(request).first - } catch { - assertionFailure(error.localizedDescription) - return nil - } - }() - - guard let requestMastodonUser = _requestMastodonUser, - let oldStatus = _oldStatus else { - assertionFailure() - return - } - APIService.CoreData.merge(status: oldStatus, entity: entity, requestMastodonUser: requestMastodonUser, domain: mastodonAuthenticationBox.domain, networkDate: response.networkDate) - if favoriteKind == .destroy { - oldStatus.update(favouritesCount: NSNumber(value: max(0, oldStatus.favouritesCount.intValue - 1))) - } - os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: did update status %{public}s like status to: %{public}s. now %ld likes", ((#file as NSString).lastPathComponent), #line, #function, entity.id, entity.favourited.flatMap { $0 ? "like" : "unlike" } ?? "<nil>", entity.favouritesCount ) - } - .setFailureType(to: Error.self) - .tryMap { result -> Mastodon.Response.Content<Mastodon.Entity.Status> in - switch result { - case .success: - return response - case .failure(let error): - throw error - } - } - .eraseToAnyPublisher() + record: ManagedObjectRecord<Status>, + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content<Mastodon.Entity.Status> { + let logger = Logger(subsystem: "APIService", category: "Favorite") + + let managedObjectContext = backgroundManagedObjectContext + + // update like state and retrieve like context + let favoriteContext: MastodonFavoriteContext = try await managedObjectContext.performChanges { + guard let authentication = authenticationBox.authenticationRecord.object(in: managedObjectContext), + let _status = record.object(in: managedObjectContext) + else { + throw APIError.implicit(.badRequest) } - .switchToLatest() - .handleEvents(receiveCompletion: { completion in - switch completion { - case .failure(let error): - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: error:", ((#file as NSString).lastPathComponent), #line, #function) - debugPrint(error) - case .finished: - break + let me = authentication.user + let status = _status.reblog ?? _status + let isFavorited = status.favouritedBy.contains(me) + let favoritedCount = status.favouritesCount + let favoriteCount = isFavorited ? favoritedCount - 1 : favoritedCount + 1 + status.update(liked: !isFavorited, by: me) + status.update(favouritesCount: favoriteCount) + let context = MastodonFavoriteContext( + statusID: status.id, + isFavorited: isFavorited, + favoritedCount: favoritedCount + ) + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): update status favorite: \(!isFavorited), \(favoriteCount)") + return context + } + + // request like or undo like + let result: Result<Mastodon.Response.Content<Mastodon.Entity.Status>, Error> + do { + let response = try await Mastodon.API.Favorites.favorites( + domain: authenticationBox.domain, + statusID: favoriteContext.statusID, + session: session, + authorization: authenticationBox.userAuthorization, + favoriteKind: favoriteContext.isFavorited ? .destroy : .create + ).singleOutput() + result = .success(response) + } catch { + result = .failure(error) + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): update favorite failure: \(error.localizedDescription)") + } + + // update like state + try await managedObjectContext.performChanges { + guard let authentication = authenticationBox.authenticationRecord.object(in: managedObjectContext), + let _status = record.object(in: managedObjectContext) + else { return } + let me = authentication.user + let status = _status.reblog ?? _status + + switch result { + case .success(let response): + _ = Persistence.Status.createOrMerge( + in: managedObjectContext, + context: Persistence.Status.PersistContext( + domain: authenticationBox.domain, + entity: response.value, + me: me, + statusCache: nil, + userCache: nil, + networkDate: response.networkDate + ) + ) + if favoriteContext.isFavorited { + status.update(favouritesCount: max(0, status.favouritesCount - 1)) // undo API return count has delay. Needs -1 local } - }) - .eraseToAnyPublisher() + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): update status favorite: \(response.value.favourited.debugDescription)") + case .failure: + // rollback + status.update(liked: favoriteContext.isFavorited, by: me) + status.update(favouritesCount: favoriteContext.favoritedCount) + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): rollback status favorite") + } + } + + let response = try result.get() + return response } } @@ -139,41 +111,42 @@ extension APIService { func favoritedStatuses( limit: Int = onceRequestStatusMaxCount, maxID: String? = nil, - mastodonAuthenticationBox: MastodonAuthenticationBox - ) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Status]>, Error> { - - let requestMastodonUserID = mastodonAuthenticationBox.userID + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Status]> { let query = Mastodon.API.Favorites.FavoriteStatusesQuery(limit: limit, minID: nil, maxID: maxID) - return Mastodon.API.Favorites.favoritedStatus( - domain: mastodonAuthenticationBox.domain, + + let response = try await Mastodon.API.Favorites.favoritedStatus( + domain: authenticationBox.domain, session: session, - authorization: mastodonAuthenticationBox.userAuthorization, + authorization: authenticationBox.userAuthorization, query: query - ) - .map { response -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Status]>, Error> in - let log = OSLog.api - - return APIService.Persist.persistStatus( - managedObjectContext: self.backgroundManagedObjectContext, - domain: mastodonAuthenticationBox.domain, - query: query, - response: response, - persistType: .likeList, - requestMastodonUserID: requestMastodonUserID, - log: log - ) - .setFailureType(to: Error.self) - .tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Status]> in - switch result { - case .success: - return response - case .failure(let error): - throw error - } - } - .eraseToAnyPublisher() + ).singleOutput() + + let managedObjectContext = self.backgroundManagedObjectContext + try await managedObjectContext.performChanges { + guard let me = authenticationBox.authenticationRecord.object(in: managedObjectContext)?.user else { + assertionFailure() + return } - .switchToLatest() - .eraseToAnyPublisher() - } + + for entity in response.value { + let result = Persistence.Status.createOrMerge( + in: managedObjectContext, + context: Persistence.Status.PersistContext( + domain: authenticationBox.domain, + entity: entity, + me: me, + statusCache: nil, + userCache: nil, + networkDate: response.networkDate + ) + ) + + result.status.update(liked: true, by: me) + result.status.reblog?.update(liked: true, by: me) + } // end for … in + } + + return response + } // end func } diff --git a/Mastodon/Service/APIService/APIService+Follow.swift b/Mastodon/Service/APIService/APIService+Follow.swift index ac2ccbead..1e908a2e4 100644 --- a/Mastodon/Service/APIService/APIService+Follow.swift +++ b/Mastodon/Service/APIService/APIService+Follow.swift @@ -14,6 +14,14 @@ import MastodonSDK extension APIService { + private struct MastodonFollowContext { + let sourceUserID: MastodonUser.ID + let targetUserID: MastodonUser.ID + let isFollowing: Bool + let isPending: Bool + let needsUnfollow: Bool + } + /// Toggle friendship between target MastodonUser and current MastodonUser /// /// Following / Following pending <-> Unfollow @@ -23,197 +31,95 @@ extension APIService { /// - activeMastodonAuthenticationBox: `AuthenticationService.MastodonAuthenticationBox` /// - Returns: publisher for `Relationship` func toggleFollow( - for mastodonUser: MastodonUser, - activeMastodonAuthenticationBox: MastodonAuthenticationBox - ) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> { + user: ManagedObjectRecord<MastodonUser>, + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content<Mastodon.Entity.Relationship> { + let logger = Logger(subsystem: "APIService", category: "Follow") - let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light) - let notificationFeedbackGenerator = UINotificationFeedbackGenerator() - - - return followUpdateLocal( - mastodonUserObjectID: mastodonUser.objectID, - mastodonAuthenticationBox: activeMastodonAuthenticationBox - ) - .receive(on: DispatchQueue.main) - .handleEvents { _ in - impactFeedbackGenerator.prepare() - } receiveOutput: { _ in - impactFeedbackGenerator.impactOccurred() - } receiveCompletion: { completion in - switch completion { - case .failure(let error): - // TODO: handle error - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] local relationship update fail", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) - assertionFailure(error.localizedDescription) - case .finished: - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] local relationship update success", ((#file as NSString).lastPathComponent), #line, #function) - break - } - } - .flatMap { followQueryType, mastodonUserID -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> in - return self.followUpdateRemote( - followQueryType: followQueryType, - mastodonUserID: mastodonUserID, - mastodonAuthenticationBox: activeMastodonAuthenticationBox - ) - } - .receive(on: DispatchQueue.main) - .handleEvents(receiveCompletion: { [weak self] completion in - guard let self = self else { return } - switch completion { - case .failure(let error): - os_log("%{public}s[%{public}ld], %{public}s: [Relationship] remote friendship update fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) - // TODO: handle error - - // rollback - - self.followUpdateLocal( - mastodonUserObjectID: mastodonUser.objectID, - mastodonAuthenticationBox: activeMastodonAuthenticationBox - ) - .sink { completion in - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Friendship] rollback finish", ((#file as NSString).lastPathComponent), #line, #function) - } receiveValue: { _ in - // do nothing - notificationFeedbackGenerator.prepare() - notificationFeedbackGenerator.notificationOccurred(.error) - } - .store(in: &self.disposeBag) - - case .finished: - notificationFeedbackGenerator.notificationOccurred(.success) - os_log("%{public}s[%{public}ld], %{public}s: [Friendship] remote friendship update success", ((#file as NSString).lastPathComponent), #line, #function) - } - }) - .eraseToAnyPublisher() - } - -} - -extension APIService { - - // update database local and return follow query update type for remote request - func followUpdateLocal( - mastodonUserObjectID: NSManagedObjectID, - mastodonAuthenticationBox: MastodonAuthenticationBox - ) -> AnyPublisher<(Mastodon.API.Account.FollowQueryType, MastodonUser.ID), Error> { - let domain = mastodonAuthenticationBox.domain - let requestMastodonUserID = mastodonAuthenticationBox.userID - - var _targetMastodonUserID: MastodonUser.ID? - var _queryType: Mastodon.API.Account.FollowQueryType? let managedObjectContext = backgroundManagedObjectContext - - return managedObjectContext.performChanges { - let request = MastodonUser.sortedFetchRequest - request.predicate = MastodonUser.predicate(domain: domain, id: requestMastodonUserID) - request.fetchLimit = 1 - request.returnsObjectsAsFaults = false - guard let _requestMastodonUser = managedObjectContext.safeFetch(request).first else { - assertionFailure() - return - } + let _followContext: MastodonFollowContext? = try await managedObjectContext.performChanges { + guard let me = authenticationBox.authenticationRecord.object(in: managedObjectContext)?.user else { return nil } + guard let user = user.object(in: managedObjectContext) else { return nil } - let mastodonUser = managedObjectContext.object(with: mastodonUserObjectID) as! MastodonUser - _targetMastodonUserID = mastodonUser.id + let isFollowing = user.followingBy.contains(me) + let isPending = user.followRequestedBy.contains(me) + let needsUnfollow = isFollowing || isPending - let isPending = (mastodonUser.followRequestedBy ?? Set()).contains(_requestMastodonUser) - let isFollowing = (mastodonUser.followingBy ?? Set()).contains(_requestMastodonUser) - - if isFollowing || isPending { - _queryType = .unfollow - mastodonUser.update(isFollowing: false, by: _requestMastodonUser) - mastodonUser.update(isFollowRequested: false, by: _requestMastodonUser) + if needsUnfollow { + // unfollow + user.update(isFollowing: false, by: me) + user.update(isFollowRequested: false, by: me) + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [Local] update user friendship: undo follow") } else { - _queryType = .follow(query: Mastodon.API.Account.FollowQuery()) - if mastodonUser.locked { - mastodonUser.update(isFollowing: false, by: _requestMastodonUser) - mastodonUser.update(isFollowRequested: true, by: _requestMastodonUser) + // follow + if user.locked { + user.update(isFollowing: false, by: me) + user.update(isFollowRequested: true, by: me) + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [Local] update user friendship: pending follow") } else { - mastodonUser.update(isFollowing: true, by: _requestMastodonUser) - mastodonUser.update(isFollowRequested: false, by: _requestMastodonUser) + user.update(isFollowing: true, by: me) + user.update(isFollowRequested: false, by: me) + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [Local] update user friendship: following") } } + let context = MastodonFollowContext( + sourceUserID: me.id, + targetUserID: user.id, + isFollowing: isFollowing, + isPending: isPending, + needsUnfollow: needsUnfollow + ) + return context } - .tryMap { result in - switch result { - case .success: - guard let targetMastodonUserID = _targetMastodonUserID, - let queryType = _queryType else { - throw APIError.implicit(.badRequest) - } - return (queryType, targetMastodonUserID) - - case .failure(let error): - assertionFailure(error.localizedDescription) - throw error - } - } - .eraseToAnyPublisher() - } - - func followUpdateRemote( - followQueryType: Mastodon.API.Account.FollowQueryType, - mastodonUserID: MastodonUser.ID, - mastodonAuthenticationBox: MastodonAuthenticationBox - ) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> { - let domain = mastodonAuthenticationBox.domain - let authorization = mastodonAuthenticationBox.userAuthorization - let requestMastodonUserID = mastodonAuthenticationBox.userID - return Mastodon.API.Account.follow( - session: session, - domain: domain, - accountID: mastodonUserID, - followQueryType: followQueryType, - authorization: authorization - ) -// .handleEvents(receiveCompletion: { [weak self] completion in -// guard let _ = self else { return } -// switch completion { -// case .failure(let error): -// // TODO: handle error -// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update follow fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) -// break -// case .finished: -// switch followQueryType { -// case .follow: -// break -// case .unfollow: -// break -// } -// } -// }) - .flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, 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 } - - let lookUpMastodonUserRequest = MastodonUser.sortedFetchRequest - lookUpMastodonUserRequest.predicate = MastodonUser.predicate(domain: domain, id: mastodonUserID) - lookUpMastodonUserRequest.fetchLimit = 1 - let lookUpMastodonUser = managedObjectContext.safeFetch(lookUpMastodonUserRequest).first - - if let lookUpMastodonUser = lookUpMastodonUser { - let entity = response.value - APIService.CoreData.update(user: lookUpMastodonUser, entity: entity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: response.networkDate) - } - } - .tryMap { result -> Mastodon.Response.Content<Mastodon.Entity.Relationship> in - switch result { - case .success: - return response - case .failure(let error): - throw error - } - } - .eraseToAnyPublisher() + guard let followContext = _followContext else { + throw APIError.implicit(.badRequest) } - .eraseToAnyPublisher() + + // request follow or unfollow + let result: Result<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> + do { + let response = try await Mastodon.API.Account.follow( + session: session, + domain: authenticationBox.domain, + accountID: followContext.targetUserID, + followQueryType: followContext.needsUnfollow ? .unfollow : .follow(query: .init()), + authorization: authenticationBox.userAuthorization + ).singleOutput() + result = .success(response) + } catch { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [Remote] update friendship failure: \(error.localizedDescription)") + result = .failure(error) + } + + // update friendship state + try await managedObjectContext.performChanges { + guard let me = authenticationBox.authenticationRecord.object(in: managedObjectContext)?.user, + let user = user.object(in: managedObjectContext) + else { return } + + switch result { + case .success(let response): + Persistence.MastodonUser.update( + mastodonUser: user, + context: Persistence.MastodonUser.RelationshipContext( + entity: response.value, + me: me, + networkDate: response.networkDate + ) + ) + let following = response.value.following + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [Remote] update user friendship: following \(following)") + case .failure: + // rollback + user.update(isFollowing: followContext.isFollowing, by: me) + user.update(isFollowRequested: followContext.isPending, by: me) + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [Remote] rollback user friendship") + } + } + + let response = try result.get() + return response } } diff --git a/Mastodon/Service/APIService/APIService+FollowRequest.swift b/Mastodon/Service/APIService/APIService+FollowRequest.swift index 0f5c3c25d..b2029f3db 100644 --- a/Mastodon/Service/APIService/APIService+FollowRequest.swift +++ b/Mastodon/Service/APIService/APIService+FollowRequest.swift @@ -15,91 +15,91 @@ import CommonOSLog import MastodonSDK extension APIService { - func acceptFollowRequest( - mastodonUserID: MastodonUser.ID, - mastodonAuthenticationBox: MastodonAuthenticationBox - ) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> { - let domain = mastodonAuthenticationBox.domain - let authorization = mastodonAuthenticationBox.userAuthorization - let requestMastodonUserID = mastodonAuthenticationBox.userID - - return Mastodon.API.Account.acceptFollowRequest( - session: session, - domain: domain, - userID: mastodonUserID, - authorization: authorization) - .flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, 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 } - - let lookUpMastodonUserRequest = MastodonUser.sortedFetchRequest - lookUpMastodonUserRequest.predicate = MastodonUser.predicate(domain: domain, id: mastodonUserID) - lookUpMastodonUserRequest.fetchLimit = 1 - let lookUpMastodonuser = managedObjectContext.safeFetch(lookUpMastodonUserRequest).first - - if let lookUpMastodonuser = lookUpMastodonuser { - let entity = response.value - APIService.CoreData.update(user: lookUpMastodonuser, entity: entity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: response.networkDate) - } - } - .tryMap { result -> Mastodon.Response.Content<Mastodon.Entity.Relationship> in - switch result { - case .success: - return response - case .failure(let error): - throw error - } - } - .eraseToAnyPublisher() - } - .eraseToAnyPublisher() - } +// func acceptFollowRequest( +// mastodonUserID: MastodonUser.ID, +// mastodonAuthenticationBox: MastodonAuthenticationBox +// ) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> { +// let domain = mastodonAuthenticationBox.domain +// let authorization = mastodonAuthenticationBox.userAuthorization +// let requestMastodonUserID = mastodonAuthenticationBox.userID +// +// return Mastodon.API.Account.acceptFollowRequest( +// session: session, +// domain: domain, +// userID: mastodonUserID, +// authorization: authorization) +// .flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, 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 } +// +// let lookUpMastodonUserRequest = MastodonUser.sortedFetchRequest +// lookUpMastodonUserRequest.predicate = MastodonUser.predicate(domain: domain, id: mastodonUserID) +// lookUpMastodonUserRequest.fetchLimit = 1 +// let lookUpMastodonuser = managedObjectContext.safeFetch(lookUpMastodonUserRequest).first +// +// if let lookUpMastodonuser = lookUpMastodonuser { +// let entity = response.value +// APIService.CoreData.update(user: lookUpMastodonuser, entity: entity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: response.networkDate) +// } +// } +// .tryMap { result -> Mastodon.Response.Content<Mastodon.Entity.Relationship> in +// switch result { +// case .success: +// return response +// case .failure(let error): +// throw error +// } +// } +// .eraseToAnyPublisher() +// } +// .eraseToAnyPublisher() +// } - func rejectFollowRequest( - mastodonUserID: MastodonUser.ID, - mastodonAuthenticationBox: MastodonAuthenticationBox - ) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> { - let domain = mastodonAuthenticationBox.domain - let authorization = mastodonAuthenticationBox.userAuthorization - let requestMastodonUserID = mastodonAuthenticationBox.userID - - return Mastodon.API.Account.rejectFollowRequest( - session: session, - domain: domain, - userID: mastodonUserID, - authorization: authorization) - .flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, 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 } - - let lookUpMastodonUserRequest = MastodonUser.sortedFetchRequest - lookUpMastodonUserRequest.predicate = MastodonUser.predicate(domain: domain, id: mastodonUserID) - lookUpMastodonUserRequest.fetchLimit = 1 - let lookUpMastodonuser = managedObjectContext.safeFetch(lookUpMastodonUserRequest).first - - if let lookUpMastodonuser = lookUpMastodonuser { - let entity = response.value - APIService.CoreData.update(user: lookUpMastodonuser, entity: entity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: response.networkDate) - } - } - .tryMap { result -> Mastodon.Response.Content<Mastodon.Entity.Relationship> in - switch result { - case .success: - return response - case .failure(let error): - throw error - } - } - .eraseToAnyPublisher() - } - .eraseToAnyPublisher() - } +// func rejectFollowRequest( +// mastodonUserID: MastodonUser.ID, +// mastodonAuthenticationBox: MastodonAuthenticationBox +// ) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> { +// let domain = mastodonAuthenticationBox.domain +// let authorization = mastodonAuthenticationBox.userAuthorization +// let requestMastodonUserID = mastodonAuthenticationBox.userID +// +// return Mastodon.API.Account.rejectFollowRequest( +// session: session, +// domain: domain, +// userID: mastodonUserID, +// authorization: authorization) +// .flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, 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 } +// +// let lookUpMastodonUserRequest = MastodonUser.sortedFetchRequest +// lookUpMastodonUserRequest.predicate = MastodonUser.predicate(domain: domain, id: mastodonUserID) +// lookUpMastodonUserRequest.fetchLimit = 1 +// let lookUpMastodonuser = managedObjectContext.safeFetch(lookUpMastodonUserRequest).first +// +// if let lookUpMastodonuser = lookUpMastodonuser { +// let entity = response.value +// APIService.CoreData.update(user: lookUpMastodonuser, entity: entity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: response.networkDate) +// } +// } +// .tryMap { result -> Mastodon.Response.Content<Mastodon.Entity.Relationship> in +// switch result { +// case .success: +// return response +// case .failure(let error): +// throw error +// } +// } +// .eraseToAnyPublisher() +// } +// .eraseToAnyPublisher() +// } } diff --git a/Mastodon/Service/APIService/APIService+Follower.swift b/Mastodon/Service/APIService/APIService+Follower.swift index f75d2420d..f0350013f 100644 --- a/Mastodon/Service/APIService/APIService+Follower.swift +++ b/Mastodon/Service/APIService/APIService+Follower.swift @@ -17,54 +17,44 @@ extension APIService { func followers( userID: Mastodon.Entity.Account.ID, maxID: String?, - authorizationBox: MastodonAuthenticationBox - ) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Account]>, Error> { - let domain = authorizationBox.domain - let authorization = authorizationBox.userAuthorization - let requestMastodonUserID = authorizationBox.userID + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Account]> { + let domain = authenticationBox.domain + let authorization = authenticationBox.userAuthorization let query = Mastodon.API.Account.FollowerQuery( maxID: maxID, limit: nil ) - return Mastodon.API.Account.followers( + let response = try await Mastodon.API.Account.followers( session: session, domain: domain, userID: userID, query: query, authorization: authorization - ) - .flatMap { response -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Account]>, 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, + ).singleOutput() + + let managedObjectContext = self.backgroundManagedObjectContext + try await managedObjectContext.performChanges { + let me = authenticationBox.authenticationRecord.object(in: managedObjectContext)?.user + + for entity in response.value { + let result = Persistence.MastodonUser.createOrMerge( + in: managedObjectContext, + context: Persistence.MastodonUser.PersistContext( + domain: domain, entity: entity, - userCache: nil, - networkDate: response.networkDate, - log: .api + cache: nil, + networkDate: response.networkDate ) - } + ) + + let user = result.user + me?.update(isFollowing: true, by: user) } - .tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Account]> in - switch result { - case .success: - return response - case .failure(let error): - throw error - } - } - .eraseToAnyPublisher() } - .eraseToAnyPublisher() + + return response } } diff --git a/Mastodon/Service/APIService/APIService+Following.swift b/Mastodon/Service/APIService/APIService+Following.swift index 8f477d6ec..d0cdc233f 100644 --- a/Mastodon/Service/APIService/APIService+Following.swift +++ b/Mastodon/Service/APIService/APIService+Following.swift @@ -17,54 +17,48 @@ extension APIService { func following( userID: Mastodon.Entity.Account.ID, maxID: String?, - authorizationBox: MastodonAuthenticationBox - ) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Account]>, Error> { - let domain = authorizationBox.domain - let authorization = authorizationBox.userAuthorization - let requestMastodonUserID = authorizationBox.userID + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Account]> { + let domain = authenticationBox.domain + let authorization = authenticationBox.userAuthorization let query = Mastodon.API.Account.FollowingQuery( maxID: maxID, limit: nil ) - return Mastodon.API.Account.following( + + let response = try await Mastodon.API.Account.following( session: session, domain: domain, userID: userID, query: query, authorization: authorization - ) - .flatMap { response -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Account]>, 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 - ) - } + ).singleOutput() + + let managedObjectContext = self.backgroundManagedObjectContext + try await managedObjectContext.performChanges { + let me = authenticationBox.authenticationRecord.object(in: managedObjectContext)?.user + + for entity in response.value { + let result = Persistence.MastodonUser.createOrMerge( + in: managedObjectContext, + context: Persistence.MastodonUser.PersistContext( + domain: domain, + entity: entity, + cache: nil, + networkDate: response.networkDate + ) + ) + + if let me = me { + let user = result.user + user.update(isFollowing: true, by: me) } - .tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Account]> in - switch result { - case .success: - return response - case .failure(let error): - throw error - } - } - .eraseToAnyPublisher() } - .eraseToAnyPublisher() + + } + + return response } } diff --git a/Mastodon/Service/APIService/APIService+HashtagTimeline.swift b/Mastodon/Service/APIService/APIService+HashtagTimeline.swift index 241c78853..ce8783895 100644 --- a/Mastodon/Service/APIService/APIService+HashtagTimeline.swift +++ b/Mastodon/Service/APIService/APIService+HashtagTimeline.swift @@ -22,10 +22,11 @@ extension APIService { limit: Int = onceRequestStatusMaxCount, local: Bool? = nil, hashtag: String, - authorizationBox: MastodonAuthenticationBox - ) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Status]>, Error> { - let authorization = authorizationBox.userAuthorization - let requestMastodonUserID = authorizationBox.userID + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Status]> { + let domain = authenticationBox.domain + let authorization = authenticationBox.userAuthorization + let query = Mastodon.API.Timeline.HashtagTimelineQuery( maxID: maxID, sinceID: sinceID, @@ -34,36 +35,35 @@ extension APIService { local: local, onlyMedia: false ) - - return Mastodon.API.Timeline.hashtag( + + let response = try await Mastodon.API.Timeline.hashtag( session: session, domain: domain, query: query, hashtag: hashtag, authorization: authorization - ) - .flatMap { response -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Status]>, Error> in - return APIService.Persist.persistStatus( - managedObjectContext: self.backgroundManagedObjectContext, - domain: domain, - query: query, - response: response, - persistType: .lookUp, - requestMastodonUserID: requestMastodonUserID, - log: OSLog.api - ) - .setFailureType(to: Error.self) - .tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Status]> in - switch result { - case .success: - return response - case .failure(let error): - throw error - } + ).singleOutput() + + let managedObjectContext = self.backgroundManagedObjectContext + try await managedObjectContext.performChanges { + let me = authenticationBox.authenticationRecord.object(in: managedObjectContext)?.user + + for entity in response.value { + _ = Persistence.Status.createOrMerge( + in: managedObjectContext, + context: Persistence.Status.PersistContext( + domain: domain, + entity: entity, + me: me, + statusCache: nil, + userCache: nil, + networkDate: response.networkDate + ) + ) } - .eraseToAnyPublisher() } - .eraseToAnyPublisher() + + return response } } diff --git a/Mastodon/Service/APIService/APIService+HomeTimeline.swift b/Mastodon/Service/APIService/APIService+HomeTimeline.swift index 28f68274c..39d4cf6e1 100644 --- a/Mastodon/Service/APIService/APIService+HomeTimeline.swift +++ b/Mastodon/Service/APIService/APIService+HomeTimeline.swift @@ -16,15 +16,14 @@ import MastodonSDK extension APIService { func homeTimeline( - domain: String, sinceID: Mastodon.Entity.Status.ID? = nil, maxID: Mastodon.Entity.Status.ID? = nil, limit: Int = onceRequestStatusMaxCount, local: Bool? = nil, - authorizationBox: MastodonAuthenticationBox - ) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Status]>, Error> { - let authorization = authorizationBox.userAuthorization - let requestMastodonUserID = authorizationBox.userID + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Status]> { + let domain = authenticationBox.domain + let authorization = authenticationBox.userAuthorization let query = Mastodon.API.Timeline.HomeTimelineQuery( maxID: maxID, sinceID: sinceID, @@ -33,34 +32,80 @@ extension APIService { local: local ) - return Mastodon.API.Timeline.home( + let response = try await Mastodon.API.Timeline.home( session: session, domain: domain, query: query, authorization: authorization - ) - .flatMap { response -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Status]>, Error> in - return APIService.Persist.persistStatus( - managedObjectContext: self.backgroundManagedObjectContext, - domain: domain, - query: query, - response: response, - persistType: .home, - requestMastodonUserID: requestMastodonUserID, - log: OSLog.api - ) - .setFailureType(to: Error.self) - .tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Status]> in - switch result { - case .success: - return response - case .failure(let error): - throw error + ).singleOutput() + + let managedObjectContext = self.backgroundManagedObjectContext + try await managedObjectContext.performChanges { + guard let me = authenticationBox.authenticationRecord.object(in: managedObjectContext)?.user else { + assertionFailure() + return + } + + // persist status + var statuses: [Status] = [] + for entity in response.value { + let result = Persistence.Status.createOrMerge( + in: managedObjectContext, + context: Persistence.Status.PersistContext( + domain: domain, + entity: entity, + me: me, + statusCache: nil, // TODO: add cache + userCache: nil, // TODO: add cache + networkDate: response.networkDate + ) + ) + statuses.append(result.status) + } + + // locate anchor status + let anchorStatus: Status? = { + guard let maxID = maxID else { return nil } + let request = Status.sortedFetchRequest + request.predicate = Status.predicate(domain: domain, id: maxID) + request.fetchLimit = 1 + return try? managedObjectContext.fetch(request).first + }() + + // update hasMore flag for anchor status + let acct = Feed.Acct.mastodon(domain: authenticationBox.domain, userID: authenticationBox.userID) + if let anchorStatus = anchorStatus, + let feed = anchorStatus.feed(kind: .home, acct: acct) { + feed.update(hasMore: false) + } + + // persist Feed relationship + let sortedStatuses = statuses.sorted(by: { $0.createdAt < $1.createdAt }) + let oldestStatus = sortedStatuses.first + for status in sortedStatuses { + let _feed = status.feed(kind: .home, acct: acct) + if let feed = _feed { + feed.update(updatedAt: response.networkDate) + } else { + let feedProperty = Feed.Property( + acct: acct, + kind: .home, + hasMore: false, + createdAt: status.createdAt, + updatedAt: response.networkDate + ) + let feed = Feed.insert(into: managedObjectContext, property: feedProperty) + status.attach(feed: feed) + + // set hasMore on oldest status if is new feed + if status === oldestStatus { + feed.update(hasMore: true) + } } } - .eraseToAnyPublisher() } - .eraseToAnyPublisher() + + return response } } diff --git a/Mastodon/Service/APIService/APIService+Mute.swift b/Mastodon/Service/APIService/APIService+Mute.swift index 40f97acdc..c93dbcf6f 100644 --- a/Mastodon/Service/APIService/APIService+Mute.swift +++ b/Mastodon/Service/APIService/APIService+Mute.swift @@ -14,153 +14,92 @@ import MastodonSDK extension APIService { + private struct MastodonMuteContext { + let sourceUserID: MastodonUser.ID + let targetUserID: MastodonUser.ID + let targetUsername: String + let isMuting: Bool + } + func toggleMute( - for mastodonUser: MastodonUser, - activeMastodonAuthenticationBox: MastodonAuthenticationBox - ) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> { - let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light) - let notificationFeedbackGenerator = UINotificationFeedbackGenerator() + user: ManagedObjectRecord<MastodonUser>, + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content<Mastodon.Entity.Relationship> { + let logger = Logger(subsystem: "APIService", category: "Mute") - return muteUpdateLocal( - mastodonUserObjectID: mastodonUser.objectID, - mastodonAuthenticationBox: activeMastodonAuthenticationBox - ) - .receive(on: DispatchQueue.main) - .handleEvents { _ in - impactFeedbackGenerator.prepare() - } receiveOutput: { _ in - impactFeedbackGenerator.impactOccurred() - } receiveCompletion: { completion in - switch completion { - case .failure(let error): - // TODO: handle error - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] local relationship update fail", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) - assertionFailure(error.localizedDescription) - case .finished: - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] local relationship update success", ((#file as NSString).lastPathComponent), #line, #function) - break + let managedObjectContext = backgroundManagedObjectContext + let muteContext: MastodonMuteContext = try await managedObjectContext.performChanges { + guard let user = user.object(in: managedObjectContext), + let authentication = authenticationBox.authenticationRecord.object(in: managedObjectContext) + else { + throw APIError.implicit(.badRequest) } - } - .flatMap { muteQueryType, mastodonUserID -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> in - return self.muteUpdateRemote( - muteQueryType: muteQueryType, - mastodonUserID: mastodonUserID, - mastodonAuthenticationBox: activeMastodonAuthenticationBox + + let me = authentication.user + let isMuting = user.mutingBy.contains(me) + + // toggle mute state + user.update(isMuting: !isMuting, by: me) + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [Local] update user[\(user.id)](\(user.username)) mute state: \(!isMuting)") + return MastodonMuteContext( + sourceUserID: me.id, + targetUserID: user.id, + targetUsername: user.username, + isMuting: isMuting ) } - .receive(on: DispatchQueue.main) - .handleEvents(receiveCompletion: { [weak self] completion in - guard let self = self else { return } - switch completion { - case .failure(let error): - os_log("%{public}s[%{public}ld], %{public}s: [Relationship] remote friendship update fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) - // TODO: handle error - - // rollback - - self.muteUpdateLocal( - mastodonUserObjectID: mastodonUser.objectID, - mastodonAuthenticationBox: activeMastodonAuthenticationBox - ) - .sink { completion in - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Friendship] rollback finish", ((#file as NSString).lastPathComponent), #line, #function) - } receiveValue: { _ in - // do nothing - notificationFeedbackGenerator.prepare() - notificationFeedbackGenerator.notificationOccurred(.error) - } - .store(in: &self.disposeBag) - - case .finished: - notificationFeedbackGenerator.notificationOccurred(.success) - os_log("%{public}s[%{public}ld], %{public}s: [Friendship] remote friendship update success", ((#file as NSString).lastPathComponent), #line, #function) - } - }) - .eraseToAnyPublisher() - } - -} - -extension APIService { - - // update database local and return mute query update type for remote request - func muteUpdateLocal( - mastodonUserObjectID: NSManagedObjectID, - mastodonAuthenticationBox: MastodonAuthenticationBox - ) -> AnyPublisher<(Mastodon.API.Account.MuteQueryType, MastodonUser.ID), Error> { - let domain = mastodonAuthenticationBox.domain - let requestMastodonUserID = mastodonAuthenticationBox.userID - var _targetMastodonUserID: MastodonUser.ID? - var _queryType: Mastodon.API.Account.MuteQueryType? - let managedObjectContext = backgroundManagedObjectContext - - return managedObjectContext.performChanges { - let request = MastodonUser.sortedFetchRequest - request.predicate = MastodonUser.predicate(domain: domain, id: requestMastodonUserID) - request.fetchLimit = 1 - request.returnsObjectsAsFaults = false - guard let _requestMastodonUser = managedObjectContext.safeFetch(request).first else { - assertionFailure() - return + let result: Result<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> + do { + if muteContext.isMuting { + let response = try await Mastodon.API.Account.unmute( + session: session, + domain: authenticationBox.domain, + accountID: muteContext.targetUserID, + authorization: authenticationBox.userAuthorization + ).singleOutput() + result = .success(response) + } else { + let response = try await Mastodon.API.Account.mute( + session: session, + domain: authenticationBox.domain, + accountID: muteContext.targetUserID, + authorization: authenticationBox.userAuthorization + ).singleOutput() + result = .success(response) } - - let mastodonUser = managedObjectContext.object(with: mastodonUserObjectID) as! MastodonUser - _targetMastodonUserID = mastodonUser.id - - let isMuting = (mastodonUser.mutingBy ?? Set()).contains(_requestMastodonUser) - _queryType = isMuting ? .unmute : .mute - mastodonUser.update(isMuting: !isMuting, by: _requestMastodonUser) + } catch { + result = .failure(error) + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [Remote] update user[\(muteContext.targetUserID)](\(muteContext.targetUsername)) mute failure: \(error.localizedDescription)") } - .tryMap { result in + + try await managedObjectContext.performChanges { + guard let user = user.object(in: managedObjectContext), + let authentication = authenticationBox.authenticationRecord.object(in: managedObjectContext) + else { return } + let me = authentication.user + switch result { - case .success: - guard let targetMastodonUserID = _targetMastodonUserID, - let queryType = _queryType else { - throw APIError.implicit(.badRequest) - } - return (queryType, targetMastodonUserID) - - case .failure(let error): - assertionFailure(error.localizedDescription) - throw error + case .success(let response): + let relationship = response.value + Persistence.MastodonUser.update( + mastodonUser: user, + context: Persistence.MastodonUser.RelationshipContext( + entity: relationship, + me: me, + networkDate: response.networkDate + ) + ) + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [Remote] update user[\(muteContext.targetUserID)](\(muteContext.targetUsername)) mute state: \(relationship.muting.debugDescription)") + case .failure: + // rollback + user.update(isMuting: muteContext.isMuting, by: me) + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [Remote] rollback user[\(muteContext.targetUserID)](\(muteContext.targetUsername)) mute state") } } - .eraseToAnyPublisher() - } - - func muteUpdateRemote( - muteQueryType: Mastodon.API.Account.MuteQueryType, - mastodonUserID: MastodonUser.ID, - mastodonAuthenticationBox: MastodonAuthenticationBox - ) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> { - let domain = mastodonAuthenticationBox.domain - let authorization = mastodonAuthenticationBox.userAuthorization - return Mastodon.API.Account.mute( - session: session, - domain: domain, - accountID: mastodonUserID, - muteQueryType: muteQueryType, - authorization: authorization - ) - .handleEvents(receiveCompletion: { [weak self] completion in - guard let _ = self else { return } - switch completion { - case .failure(let error): - // TODO: handle error - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] Mute update fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) - case .finished: - // TODO: update relationship - switch muteQueryType { - case .mute: - break - case .unmute: - break - } - } - }) - .eraseToAnyPublisher() + let response = try result.get() + return response } } diff --git a/Mastodon/Service/APIService/APIService+Notification.swift b/Mastodon/Service/APIService/APIService+Notification.swift index 9f7d3bb54..6cc0dbba3 100644 --- a/Mastodon/Service/APIService/APIService+Notification.swift +++ b/Mastodon/Service/APIService/APIService+Notification.swift @@ -11,109 +11,137 @@ import CoreDataStack import Foundation import MastodonSDK import OSLog +import class CoreDataStack.Notification extension APIService { - func allNotifications( - domain: String, - query: Mastodon.API.Notifications.Query, - mastodonAuthenticationBox: MastodonAuthenticationBox - ) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Notification]>, Error> { - let authorization = mastodonAuthenticationBox.userAuthorization - let userID = mastodonAuthenticationBox.userID - return Mastodon.API.Notifications.getNotifications( + func notifications( + maxID: Mastodon.Entity.Status.ID?, + scope: NotificationTimelineViewModel.Scope, + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Notification]> { + let authorization = authenticationBox.userAuthorization + + let query = Mastodon.API.Notifications.Query( + maxID: maxID, + excludeTypes: { + switch scope { + case .everything: + return nil + case .mentions: + return [.follow, .followRequest, .reblog, .favourite, .poll] + } + }() + ) + + let response = try await Mastodon.API.Notifications.getNotifications( session: session, - domain: domain, + domain: authenticationBox.domain, query: query, authorization: authorization - ) - .flatMap { response -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Notification]>, Error> in - let log = OSLog.api - return self.backgroundManagedObjectContext.performChanges { - if query.maxID == nil { - let requestMastodonNotificationRequest = MastodonNotification.sortedFetchRequest - requestMastodonNotificationRequest.predicate = MastodonNotification.predicate(domain: domain, userID: userID) - let oldNotifications = self.backgroundManagedObjectContext.safeFetch(requestMastodonNotificationRequest) - oldNotifications.forEach { notification in - self.backgroundManagedObjectContext.delete(notification) + ).singleOutput() + + let managedObjectContext = self.backgroundManagedObjectContext + try await managedObjectContext.performChanges { + guard let me = authenticationBox.authenticationRecord.object(in: managedObjectContext)?.user else { + assertionFailure() + return + } + + var notifications: [Notification] = [] + for entity in response.value { + let result = Persistence.Notification.createOrMerge( + in: managedObjectContext, + context: Persistence.Notification.PersistContext( + domain: authenticationBox.domain, + entity: entity, + me: me, + networkDate: response.networkDate + ) + ) + notifications.append(result.notification) + } + + // locate anchor notification + let anchorNotification: Notification? = { + guard let maxID = query.maxID else { return nil } + let request = Notification.sortedFetchRequest + request.predicate = Notification.predicate( + domain: authenticationBox.domain, + userID: authenticationBox.userID, + id: maxID + ) + request.fetchLimit = 1 + return try? managedObjectContext.fetch(request).first + }() + + // update hasMore flag for anchor status + let acct = Feed.Acct.mastodon(domain: authenticationBox.domain, userID: authenticationBox.userID) + let kind: Feed.Kind = scope == .everything ? .notificationAll : .notificationMentions + if let anchorNotification = anchorNotification, + let feed = anchorNotification.feed(kind: kind, acct: acct) { + feed.update(hasMore: false) + } + + // persist Feed relationship + let sortedNotifications = notifications.sorted(by: { $0.createAt < $1.createAt }) + let oldestNotification = sortedNotifications.first + for notification in notifications { + let _feed = notification.feed(kind: kind, acct: acct) + if let feed = _feed { + feed.update(updatedAt: response.networkDate) + } else { + let feedProperty = Feed.Property( + acct: acct, + kind: kind, + hasMore: false, + createdAt: notification.createAt, + updatedAt: response.networkDate + ) + let feed = Feed.insert(into: managedObjectContext, property: feedProperty) + notification.attach(feed: feed) + + // set hasMore on oldest notification if is new feed + if notification === oldestNotification { + feed.update(hasMore: true) } } - response.value.forEach { notification in - let (mastodonUser, _) = APIService.CoreData.createOrMergeMastodonUser(into: self.backgroundManagedObjectContext, for: nil, in: domain, entity: notification.account, userCache: nil, networkDate: Date(), log: log) - var status: Status? - if let statusEntity = notification.status { - let (statusInCoreData, _, _) = APIService.CoreData.createOrMergeStatus( - into: self.backgroundManagedObjectContext, - for: nil, - domain: domain, - entity: statusEntity, - statusCache: nil, - userCache: nil, - networkDate: Date(), - log: log - ) - status = statusInCoreData - } - // use constrain to avoid repeated save - let property = MastodonNotification.Property(id: notification.id, typeRaw: notification.type.rawValue, account: mastodonUser, status: status, createdAt: notification.createdAt) - let notification = MastodonNotification.insert(into: self.backgroundManagedObjectContext, domain: domain, userID: userID, networkDate: response.networkDate, property: property) - os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: fetch mastodon user [%s](%s)", (#file as NSString).lastPathComponent, #line, #function, notification.typeRaw, notification.account.username) - } } - .setFailureType(to: Error.self) - .tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Notification]> in - switch result { - case .success: - return response - case .failure(let error): - throw error - } - } - .eraseToAnyPublisher() } - .eraseToAnyPublisher() + + return response } - +} + +extension APIService { func notification( notificationID: Mastodon.Entity.Notification.ID, - mastodonAuthenticationBox: MastodonAuthenticationBox - ) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Notification>, Error> { - let domain = mastodonAuthenticationBox.domain - let authorization = mastodonAuthenticationBox.userAuthorization + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content<Mastodon.Entity.Notification> { + let domain = authenticationBox.domain + let authorization = authenticationBox.userAuthorization - return Mastodon.API.Notifications.getNotification( + let response = try await Mastodon.API.Notifications.getNotification( session: session, domain: domain, notificationID: notificationID, authorization: authorization - ) - .flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Notification>, Error> in - guard let status = response.value.status else { - return Just(response) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - - return APIService.Persist.persistStatus( - managedObjectContext: self.backgroundManagedObjectContext, - domain: domain, - query: nil, - response: response.map { _ in [status] }, - persistType: .lookUp, - requestMastodonUserID: nil, - log: OSLog.api + ).singleOutput() + + let managedObjectContext = self.backgroundManagedObjectContext + try await managedObjectContext.performChanges { + guard let me = authenticationBox.authenticationRecord.object(in: managedObjectContext)?.user else { return } + _ = Persistence.Notification.createOrMerge( + in: managedObjectContext, + context: Persistence.Notification.PersistContext( + domain: domain, + entity: response.value, + me: me, + networkDate: response.networkDate + ) ) - .setFailureType(to: Error.self) - .tryMap { result -> Mastodon.Response.Content<Mastodon.Entity.Notification> in - switch result { - case .success: - return response - case .failure(let error): - throw error - } - } - .eraseToAnyPublisher() } - .eraseToAnyPublisher() + + return response } } diff --git a/Mastodon/Service/APIService/APIService+Poll.swift b/Mastodon/Service/APIService/APIService+Poll.swift index ca091161f..15a6847c7 100644 --- a/Mastodon/Service/APIService/APIService+Poll.swift +++ b/Mastodon/Service/APIService/APIService+Poll.swift @@ -16,182 +16,83 @@ import MastodonSDK extension APIService { func poll( - domain: String, - pollID: Mastodon.Entity.Poll.ID, - pollObjectID: NSManagedObjectID, - mastodonAuthenticationBox: MastodonAuthenticationBox - ) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Poll>, Error> { - let authorization = mastodonAuthenticationBox.userAuthorization - let requestMastodonUserID = mastodonAuthenticationBox.userID + poll: ManagedObjectRecord<Poll>, + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content<Mastodon.Entity.Poll> { + let authorization = authenticationBox.userAuthorization - return Mastodon.API.Polls.poll( + let managedObjectContext = self.backgroundManagedObjectContext + let pollID: Poll.ID = try await managedObjectContext.perform { + guard let poll = poll.object(in: managedObjectContext) else { + throw APIError.implicit(.badRequest) + } + return poll.id + } + + let response = try await Mastodon.API.Polls.poll( session: session, - domain: domain, + domain: authenticationBox.domain, pollID: pollID, authorization: authorization - ) - .flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Poll>, Error> in - let entity = response.value - let managedObjectContext = self.backgroundManagedObjectContext - - return managedObjectContext.performChanges { - let _requestMastodonUser: MastodonUser? = { - let request = MastodonUser.sortedFetchRequest - request.predicate = MastodonUser.predicate(domain: mastodonAuthenticationBox.domain, id: requestMastodonUserID) - request.fetchLimit = 1 - request.returnsObjectsAsFaults = false - do { - return try managedObjectContext.fetch(request).first - } catch { - assertionFailure(error.localizedDescription) - return nil - } - }() - guard let requestMastodonUser = _requestMastodonUser else { - assertionFailure() - return - } - guard let poll = managedObjectContext.object(with: pollObjectID) as? Poll else { return } - APIService.CoreData.merge(poll: poll, entity: entity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: response.networkDate) - } - .setFailureType(to: Error.self) - .tryMap { result -> Mastodon.Response.Content<Mastodon.Entity.Poll> in - switch result { - case .success: - return response - case .failure(let error): - throw error - } - } - .eraseToAnyPublisher() + ).singleOutput() + + try await managedObjectContext.performChanges { + let me = authenticationBox.authenticationRecord.object(in: managedObjectContext)?.user + _ = Persistence.Poll.createOrMerge( + in: managedObjectContext, + context: Persistence.Poll.PersistContext( + domain: authenticationBox.domain, + entity: response.value, + me: me, + networkDate: response.networkDate + ) + ) } - .eraseToAnyPublisher() + + return response } } extension APIService { - - /// vote local - /// # Note - /// Not mark the poll voted so that view model could know when to reveal the results + func vote( - pollObjectID: NSManagedObjectID, - mastodonUserObjectID: NSManagedObjectID, - choices: [Int] - ) -> AnyPublisher<Mastodon.Entity.Poll.ID, Error> { - var _targetPollID: Mastodon.Entity.Poll.ID? - var isPollExpired = false - var didVotedLocal = false - - let managedObjectContext = backgroundManagedObjectContext - return managedObjectContext.performChanges { - let poll = managedObjectContext.object(with: pollObjectID) as! Poll - let mastodonUser = managedObjectContext.object(with: mastodonUserObjectID) as! MastodonUser - - _targetPollID = poll.id - - if let expiresAt = poll.expiresAt, Date().timeIntervalSince(expiresAt) > 0 { - isPollExpired = true - poll.update(expired: true) - return - } - - let options = poll.options.sorted(by: { $0.index.intValue < $1.index.intValue }) - let votedOptions = poll.options.filter { option in - (option.votedBy ?? Set()).map { $0.id }.contains(mastodonUser.id) - } - - if !poll.multiple, !votedOptions.isEmpty { - // if did voted for single poll. Do not allow vote again - didVotedLocal = true - return - } - - for option in options { - let voted = choices.contains(option.index.intValue) - option.update(voted: voted, by: mastodonUser) - option.didUpdate(at: option.updatedAt) // trigger update without change anything - } - poll.didUpdate(at: poll.updatedAt) // trigger update without change anything - } - .tryMap { result in - guard !isPollExpired else { - throw APIError.explicit(APIError.ErrorReason.voteExpiredPoll) - } - guard !didVotedLocal else { - throw APIError.implicit(APIError.ErrorReason.badRequest) - } - switch result { - case .success: - guard let targetPollID = _targetPollID else { - throw APIError.implicit(.badRequest) - } - return targetPollID - - case .failure(let error): - assertionFailure(error.localizedDescription) - throw error - } - } - .eraseToAnyPublisher() - } - - /// send vote request to remote - func vote( - domain: String, - pollID: Mastodon.Entity.Poll.ID, - pollObjectID: NSManagedObjectID, + poll: ManagedObjectRecord<Poll>, choices: [Int], - mastodonAuthenticationBox: MastodonAuthenticationBox - ) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Poll>, Error> { - let authorization = mastodonAuthenticationBox.userAuthorization - let requestMastodonUserID = mastodonAuthenticationBox.userID - - let query = Mastodon.API.Polls.VoteQuery(choices: choices) - return Mastodon.API.Polls.vote( - session: session, - domain: domain, - pollID: pollID, - query: query, - authorization: authorization - ) - .flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Poll>, Error> in - let entity = response.value - let managedObjectContext = self.backgroundManagedObjectContext - - return managedObjectContext.performChanges { - let _requestMastodonUser: MastodonUser? = { - let request = MastodonUser.sortedFetchRequest - request.predicate = MastodonUser.predicate(domain: mastodonAuthenticationBox.domain, id: requestMastodonUserID) - request.fetchLimit = 1 - request.returnsObjectsAsFaults = false - do { - return try managedObjectContext.fetch(request).first - } catch { - assertionFailure(error.localizedDescription) - return nil - } - }() - guard let requestMastodonUser = _requestMastodonUser else { - assertionFailure() - return - } - guard let poll = managedObjectContext.object(with: pollObjectID) as? Poll else { return } - APIService.CoreData.merge(poll: poll, entity: entity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: response.networkDate) - } - .setFailureType(to: Error.self) - .tryMap { result -> Mastodon.Response.Content<Mastodon.Entity.Poll> in - switch result { - case .success: - return response - case .failure(let error): - throw error - } - } - .eraseToAnyPublisher() + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content<Mastodon.Entity.Poll> { + let managedObjectContext = backgroundManagedObjectContext + let _pollID: Poll.ID? = try await managedObjectContext.perform { + guard let poll = poll.object(in: managedObjectContext) else { return nil } + return poll.id } - .eraseToAnyPublisher() + + guard let pollID = _pollID else { + throw APIError.implicit(.badRequest) + } + + let response = try await Mastodon.API.Polls.vote( + session: session, + domain: authenticationBox.domain, + pollID: pollID, + query: Mastodon.API.Polls.VoteQuery(choices: choices), + authorization: authenticationBox.userAuthorization + ).singleOutput() + + try await managedObjectContext.performChanges { + let me = authenticationBox.authenticationRecord.object(in: managedObjectContext)?.user + _ = Persistence.Poll.createOrMerge( + in: managedObjectContext, + context: Persistence.Poll.PersistContext( + domain: authenticationBox.domain, + entity: response.value, + me: me, + networkDate: response.networkDate + ) + ) + } + + return response } } diff --git a/Mastodon/Service/APIService/APIService+PublicTimeline.swift b/Mastodon/Service/APIService/APIService+PublicTimeline.swift deleted file mode 100644 index bd176f311..000000000 --- a/Mastodon/Service/APIService/APIService+PublicTimeline.swift +++ /dev/null @@ -1,65 +0,0 @@ -// -// APIService+PublicTimeline.swift -// Mastodon -// -// Created by sxiaojian on 2021/1/28. -// - -import Foundation -import Combine -import CoreData -import CoreDataStack -import CommonOSLog -import DateToolsSwift -import MastodonSDK - -extension APIService { - - static let publicTimelineRequestWindowInSec: TimeInterval = 15 * 60 - - func publicTimeline( - domain: String, - sinceID: Mastodon.Entity.Status.ID? = nil, - maxID: Mastodon.Entity.Status.ID? = nil, - limit: Int = onceRequestStatusMaxCount - ) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Status]>, Error> { - let query = Mastodon.API.Timeline.PublicTimelineQuery( - local: nil, - remote: nil, - onlyMedia: nil, - maxID: maxID, - sinceID: sinceID, - minID: nil, // prefer sinceID - limit: limit - ) - - return Mastodon.API.Timeline.public( - session: session, - domain: domain, - query: query - ) - .flatMap { response -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Status]>, Error> in - return APIService.Persist.persistStatus( - managedObjectContext: self.backgroundManagedObjectContext, - domain: domain, - query: query, - response: response, - persistType: .public, - requestMastodonUserID: nil, - log: OSLog.api - ) - .setFailureType(to: Error.self) - .tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Status]> in - switch result { - case .success: - return response - case .failure(let error): - throw error - } - } - .eraseToAnyPublisher() - } - .eraseToAnyPublisher() - } - -} diff --git a/Mastodon/Service/APIService/APIService+Reblog.swift b/Mastodon/Service/APIService/APIService+Reblog.swift index 88da60f25..c8dde08bb 100644 --- a/Mastodon/Service/APIService/APIService+Reblog.swift +++ b/Mastodon/Service/APIService/APIService+Reblog.swift @@ -14,134 +14,95 @@ import CommonOSLog extension APIService { - // make local state change only - func reblog( - statusObjectID: NSManagedObjectID, - mastodonUserObjectID: NSManagedObjectID, - reblogKind: Mastodon.API.Reblog.ReblogKind - ) -> AnyPublisher<Status.ID, Error> { - var _targetStatusID: Status.ID? - let managedObjectContext = backgroundManagedObjectContext - return managedObjectContext.performChanges { - let status = managedObjectContext.object(with: statusObjectID) as! Status - let mastodonUser = managedObjectContext.object(with: mastodonUserObjectID) as! MastodonUser - let targetStatus = status.reblog ?? status - let targetStatusID = targetStatus.id - _targetStatusID = targetStatusID - - let reblogsCount: NSNumber - switch reblogKind { - case .reblog: - targetStatus.update(reblogged: true, by: mastodonUser) - reblogsCount = NSNumber(value: targetStatus.reblogsCount.intValue + 1) - case .undoReblog: - targetStatus.update(reblogged: false, by: mastodonUser) - reblogsCount = NSNumber(value: max(0, targetStatus.reblogsCount.intValue - 1)) - } - - targetStatus.update(reblogsCount: reblogsCount) - - } - .tryMap { result in - switch result { - case .success: - guard let targetStatusID = _targetStatusID else { - throw APIError.implicit(.badRequest) - } - return targetStatusID - - case .failure(let error): - assertionFailure(error.localizedDescription) - throw error - } - } - .eraseToAnyPublisher() + private struct MastodonReblogContext { + let statusID: Status.ID + let isReblogged: Bool + let rebloggedCount: Int64 } - - // send reblog request to remote + func reblog( - statusID: Mastodon.Entity.Status.ID, - reblogKind: Mastodon.API.Reblog.ReblogKind, - mastodonAuthenticationBox: MastodonAuthenticationBox - ) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Status>, Error> { - let domain = mastodonAuthenticationBox.domain - let authorization = mastodonAuthenticationBox.userAuthorization - let requestMastodonUserID = mastodonAuthenticationBox.userID - return Mastodon.API.Reblog.reblog( - session: session, - domain: domain, - statusID: statusID, - reblogKind: reblogKind, - authorization: authorization - ) - .map { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Status>, Error> in - let log = OSLog.api - let entity = response.value - let managedObjectContext = self.backgroundManagedObjectContext - - return managedObjectContext.performChanges { - guard let requestMastodonUser: MastodonUser = { - let request = MastodonUser.sortedFetchRequest - request.predicate = MastodonUser.predicate(domain: mastodonAuthenticationBox.domain, id: requestMastodonUserID) - request.fetchLimit = 1 - request.returnsObjectsAsFaults = false - return managedObjectContext.safeFetch(request).first - }() else { - return - } - - guard let oldStatus: Status = { - let request = Status.sortedFetchRequest - request.predicate = Status.predicate(domain: domain, id: statusID) - request.fetchLimit = 1 - request.returnsObjectsAsFaults = false - request.relationshipKeyPathsForPrefetching = [#keyPath(Status.reblog)] - return managedObjectContext.safeFetch(request).first - }() else { - return - } - - APIService.CoreData.merge(status: oldStatus, entity: entity.reblog ?? entity, requestMastodonUser: requestMastodonUser, domain: mastodonAuthenticationBox.domain, networkDate: response.networkDate) - switch reblogKind { - case .undoReblog: - // update reblogged status - oldStatus.update(reblogsCount: NSNumber(value: max(0, oldStatus.reblogsCount.intValue - 1))) - - // remove reblog from statuses - let reblogFroms = oldStatus.reblogFrom?.filter { status in - return status.author.domain == domain && status.author.id == requestMastodonUserID - } ?? Set() - reblogFroms.forEach { reblogFrom in - managedObjectContext.delete(reblogFrom) - } - - default: - break - } - os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: did update status %{public}s reblog status to: %{public}s. now %ld reblog", ((#file as NSString).lastPathComponent), #line, #function, entity.id, entity.reblogged.flatMap { $0 ? "reblog" : "unreblog" } ?? "<nil>", entity.reblogsCount ) - } - .setFailureType(to: Error.self) - .tryMap { result -> Mastodon.Response.Content<Mastodon.Entity.Status> in - switch result { - case .success: - return response - case .failure(let error): - throw error - } - } - .eraseToAnyPublisher() + record: ManagedObjectRecord<Status>, + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content<Mastodon.Entity.Status> { + let logger = Logger(subsystem: "APIService", category: "Reblog") + let managedObjectContext = backgroundManagedObjectContext + + // update repost state and retrieve repost context + let _reblogContext: MastodonReblogContext? = try await managedObjectContext.performChanges { + guard let authentication = authenticationBox.authenticationRecord.object(in: managedObjectContext), + let _status = record.object(in: managedObjectContext) + else { return nil } + + let me = authentication.user + let status = _status.reblog ?? _status + let isReblogged = status.rebloggedBy.contains(me) + let rebloggedCount = status.reblogsCount + let reblogCount = isReblogged ? rebloggedCount - 1 : rebloggedCount + 1 + status.update(reblogged: !isReblogged, by: me) + status.update(reblogsCount: Int64(max(0, reblogCount))) + let reblogContext = MastodonReblogContext( + statusID: status.id, + isReblogged: isReblogged, + rebloggedCount: rebloggedCount + ) + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): update status reblog: \(!isReblogged), \(reblogCount)") + return reblogContext } - .switchToLatest() - .handleEvents(receiveCompletion: { completion in - switch completion { - case .failure(let error): - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: error:", ((#file as NSString).lastPathComponent), #line, #function) - debugPrint(error) - case .finished: - break + guard let reblogContext = _reblogContext else { + throw APIError.implicit(.badRequest) + } + + // request repost or undo repost + let result: Result<Mastodon.Response.Content<Mastodon.Entity.Status>, Error> + do { + let response = try await Mastodon.API.Reblog.reblog( + session: session, + domain: authenticationBox.domain, + statusID: reblogContext.statusID, + reblogKind: reblogContext.isReblogged ? .undoReblog : .reblog(query: Mastodon.API.Reblog.ReblogQuery(visibility: .public)), + authorization: authenticationBox.userAuthorization + ).singleOutput() + result = .success(response) + } catch { + result = .failure(error) + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): update reblog failure: \(error.localizedDescription)") + } + + // update repost state + try await managedObjectContext.performChanges { + guard let authentication = authenticationBox.authenticationRecord.object(in: managedObjectContext), + let _status = record.object(in: managedObjectContext) + else { return } + let me = authentication.user + let status = _status.reblog ?? _status + + switch result { + case .success(let response): + _ = Persistence.Status.createOrMerge( + in: managedObjectContext, + context: Persistence.Status.PersistContext( + domain: authentication.domain, + entity: response.value, + me: me, + statusCache: nil, + userCache: nil, + networkDate: response.networkDate + ) + ) + if reblogContext.isReblogged { + status.update(reblogsCount: max(0, status.reblogsCount - 1)) // undo API return count has delay. Needs -1 local + } + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): update status reblog: \(!reblogContext.isReblogged)") + case .failure: + // rollback + status.update(reblogged: reblogContext.isReblogged, by: me) + status.update(reblogsCount: reblogContext.rebloggedCount) + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): rollback status reblog") } - }) - .eraseToAnyPublisher() + } + + let response = try result.get() + return response } } diff --git a/Mastodon/Service/APIService/APIService+Recommend.swift b/Mastodon/Service/APIService/APIService+Recommend.swift index 458cb7402..cb195b608 100644 --- a/Mastodon/Service/APIService/APIService+Recommend.swift +++ b/Mastodon/Service/APIService/APIService+Recommend.swift @@ -14,72 +14,62 @@ import OSLog extension APIService { func suggestionAccount( - domain: String, query: Mastodon.API.Suggestions.Query?, - mastodonAuthenticationBox: MastodonAuthenticationBox - ) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Account]>, Error> { - let authorization = mastodonAuthenticationBox.userAuthorization - - return Mastodon.API.Suggestions.get(session: session, domain: domain, query: query, authorization: authorization) - .flatMap { response -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Account]>, Error> in - let log = OSLog.api - return self.backgroundManagedObjectContext.performChanges { - response.value.forEach { user in - let (mastodonUser,isCreated) = APIService.CoreData.createOrMergeMastodonUser(into: self.backgroundManagedObjectContext, for: nil, in: domain, entity: user, userCache: nil, networkDate: Date(), log: log) - let flag = isCreated ? "+" : "-" - os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: fetch mastodon user [%s](%s)%s", (#file as NSString).lastPathComponent, #line, #function, flag, mastodonUser.id, mastodonUser.username) - } - } - .setFailureType(to: Error.self) - .tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Account]> in - switch result { - case .success: - return response - case .failure(let error): - throw error - } - } - .eraseToAnyPublisher() - } - .eraseToAnyPublisher() + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Account]> { + + let response = try await Mastodon.API.Suggestions.get( + session: session, + domain: authenticationBox.domain, + query: query, + authorization: authenticationBox.userAuthorization + ).singleOutput() + + let managedObjectContext = backgroundManagedObjectContext + try await managedObjectContext.performChanges { + for entity in response.value { + _ = Persistence.MastodonUser.createOrMerge( + in: managedObjectContext, + context: Persistence.MastodonUser.PersistContext( + domain: authenticationBox.domain, + entity: entity, + cache: nil, + networkDate: response.networkDate + ) + ) + } // end for … in + } + + return response } func suggestionAccountV2( - domain: String, query: Mastodon.API.Suggestions.Query?, - mastodonAuthenticationBox: MastodonAuthenticationBox - ) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.V2.SuggestionAccount]>, Error> { - let authorization = mastodonAuthenticationBox.userAuthorization + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content<[Mastodon.Entity.V2.SuggestionAccount]> { + let response = try await Mastodon.API.V2.Suggestions.get( + session: session, + domain: authenticationBox.domain, + query: query, + authorization: authenticationBox.userAuthorization + ).singleOutput() - return Mastodon.API.V2.Suggestions.get(session: session, domain: domain, query: query, authorization: authorization) - .flatMap { response -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.V2.SuggestionAccount]>, Error> in - let log = OSLog.api - return self.backgroundManagedObjectContext.performChanges { - response.value.forEach { suggestionAccount in - let user = suggestionAccount.account - let (mastodonUser,isCreated) = APIService.CoreData.createOrMergeMastodonUser(into: self.backgroundManagedObjectContext, for: nil, in: domain, entity: user, userCache: nil, networkDate: Date(), log: log) - let flag = isCreated ? "+" : "-" - os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: fetch mastodon user [%s](%s)%s", (#file as NSString).lastPathComponent, #line, #function, flag, mastodonUser.id, mastodonUser.username) - } - } - .setFailureType(to: Error.self) - .tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.V2.SuggestionAccount]> in - switch result { - case .success: - return response - case .failure(let error): - throw error - } - } - .eraseToAnyPublisher() - } - .eraseToAnyPublisher() - } - - func recommendTrends( - domain: String, - query: Mastodon.API.Trends.Query? - ) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Tag]>, Error> { - Mastodon.API.Trends.get(session: session, domain: domain, query: query) + let managedObjectContext = backgroundManagedObjectContext + try await managedObjectContext.performChanges { + for entity in response.value { + _ = Persistence.MastodonUser.createOrMerge( + in: managedObjectContext, + context: Persistence.MastodonUser.PersistContext( + domain: authenticationBox.domain, + entity: entity.account, + cache: nil, + networkDate: response.networkDate + ) + ) + } // end for … in + } + + return response } + } diff --git a/Mastodon/Service/APIService/APIService+Relationship.swift b/Mastodon/Service/APIService/APIService+Relationship.swift index 7efd2b396..a852eaf67 100644 --- a/Mastodon/Service/APIService/APIService+Relationship.swift +++ b/Mastodon/Service/APIService/APIService+Relationship.swift @@ -15,51 +15,55 @@ import MastodonSDK extension APIService { func relationship( - domain: String, - accountIDs: [Mastodon.Entity.Account.ID], - authorizationBox: MastodonAuthenticationBox - ) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Relationship]>, Error> { - let authorization = authorizationBox.userAuthorization - let requestMastodonUserID = authorizationBox.userID - let query = Mastodon.API.Account.RelationshipQuery( - ids: accountIDs - ) - - return Mastodon.API.Account.relationships( - session: session, - domain: domain, - query: query, - authorization: authorization - ) - .flatMap { response -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Relationship]>, 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 } - - let lookUpMastodonUserRequest = MastodonUser.sortedFetchRequest - lookUpMastodonUserRequest.predicate = MastodonUser.predicate(domain: domain, ids: accountIDs) - lookUpMastodonUserRequest.fetchLimit = accountIDs.count - let lookUpMastodonusers = managedObjectContext.safeFetch(lookUpMastodonUserRequest) - - for user in lookUpMastodonusers { - guard let entity = response.value.first(where: { $0.id == user.id }) else { continue } - APIService.CoreData.update(user: user, entity: entity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: response.networkDate) - } + records: [ManagedObjectRecord<MastodonUser>], + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Relationship]> { + let managedObjectContext = backgroundManagedObjectContext + + let _query: Mastodon.API.Account.RelationshipQuery? = try? await managedObjectContext.perform { + var ids: [MastodonUser.ID] = [] + for record in records { + guard let user = record.object(in: managedObjectContext) else { continue } + guard user.id != authenticationBox.userID else { continue } + ids.append(user.id) } - .tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Relationship]> in - switch result { - case .success: - return response - case .failure(let error): - throw error - } - } - .eraseToAnyPublisher() + guard !ids.isEmpty else { return nil } + return Mastodon.API.Account.RelationshipQuery(ids: ids) } - .eraseToAnyPublisher() + guard let query = _query else { + throw APIError.implicit(.badRequest) + } + + let response = try await Mastodon.API.Account.relationships( + session: session, + domain: authenticationBox.domain, + query: query, + authorization: authenticationBox.userAuthorization + ).singleOutput() + + try await managedObjectContext.performChanges { + guard let me = authenticationBox.authenticationRecord.object(in: managedObjectContext)?.user else { + // assertionFailure() + return + } + + let relationships = response.value + for record in records { + guard let user = record.object(in: managedObjectContext) else { continue } + guard let relationship = relationships.first(where: { $0.id == user.id }) else { continue } + + Persistence.MastodonUser.update( + mastodonUser: user, + context: Persistence.MastodonUser.RelationshipContext( + entity: relationship, + me: me, + networkDate: response.networkDate + ) + ) + } // end for in + } + + return response } } diff --git a/Mastodon/Service/APIService/APIService+Report.swift b/Mastodon/Service/APIService/APIService+Report.swift index 531c72185..aa7393070 100644 --- a/Mastodon/Service/APIService/APIService+Report.swift +++ b/Mastodon/Service/APIService/APIService+Report.swift @@ -12,12 +12,17 @@ import Combine extension APIService { func report( - domain: String, query: Mastodon.API.Reports.FileReportQuery, - mastodonAuthenticationBox: MastodonAuthenticationBox - ) -> AnyPublisher<Mastodon.Response.Content<Bool>, Error> { - let authorization = mastodonAuthenticationBox.userAuthorization - - return Mastodon.API.Reports.fileReport(session: session, domain: domain, query: query, authorization: authorization) + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content<Bool> { + let response = try await Mastodon.API.Reports.fileReport( + session: session, + domain: authenticationBox.domain, + query: query, + authorization: authenticationBox.userAuthorization + ).singleOutput() + + return response } + } diff --git a/Mastodon/Service/APIService/APIService+Search.swift b/Mastodon/Service/APIService/APIService+Search.swift index 4b636806f..724d7f611 100644 --- a/Mastodon/Service/APIService/APIService+Search.swift +++ b/Mastodon/Service/APIService/APIService+Search.swift @@ -13,37 +13,52 @@ import CommonOSLog extension APIService { func search( - domain: String, query: Mastodon.API.V2.Search.Query, - mastodonAuthenticationBox: MastodonAuthenticationBox - ) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.SearchResult>, Error> { - let authorization = mastodonAuthenticationBox.userAuthorization - let requestMastodonUserID = mastodonAuthenticationBox.userID + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content<Mastodon.Entity.SearchResult> { + let domain = authenticationBox.domain + let authorization = authenticationBox.userAuthorization - return Mastodon.API.V2.Search.search(session: session, domain: domain, query: query, authorization: authorization) - .flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.SearchResult>, Error> in - // persist status - let statusResponse = response.map { $0.statuses } - return APIService.Persist.persistStatus( - managedObjectContext: self.backgroundManagedObjectContext, - domain: domain, - query: nil, - response: statusResponse, - persistType: .lookUp, - requestMastodonUserID: requestMastodonUserID, - log: OSLog.api + let response = try await Mastodon.API.V2.Search.search( + session: session, + domain: domain, + query: query, + authorization: authorization + ).singleOutput() + + let managedObjectContext = self.backgroundManagedObjectContext + try await managedObjectContext.performChanges { + let me = authenticationBox.authenticationRecord.object(in: managedObjectContext)?.user + + // user + for entity in response.value.accounts { + _ = Persistence.MastodonUser.createOrMerge( + in: managedObjectContext, + context: Persistence.MastodonUser.PersistContext( + domain: domain, + entity: entity, + cache: nil, + networkDate: response.networkDate + ) ) - .setFailureType(to: Error.self) - .tryMap { result -> Mastodon.Response.Content<Mastodon.Entity.SearchResult> in - switch result { - case .success: - return response - case .failure(let error): - throw error - } - } - .eraseToAnyPublisher() } - .eraseToAnyPublisher() + + // statuses + for entity in response.value.statuses { + _ = Persistence.Status.createOrMerge( + in: managedObjectContext, + context: Persistence.Status.PersistContext( + domain: domain, + entity: entity, + me: me, + statusCache: nil, + userCache: nil, + networkDate: response.networkDate + ) + ) + } + } // ent try await managedObjectContext.performChanges { … } + + return response } } diff --git a/Mastodon/Service/APIService/APIService+Status+Publish.swift b/Mastodon/Service/APIService/APIService+Status+Publish.swift index 1bd3363cf..2b49584f1 100644 --- a/Mastodon/Service/APIService/APIService+Status+Publish.swift +++ b/Mastodon/Service/APIService/APIService+Status+Publish.swift @@ -18,45 +18,38 @@ extension APIService { domain: String, idempotencyKey: String?, query: Mastodon.API.Statuses.PublishStatusQuery, - mastodonAuthenticationBox: MastodonAuthenticationBox - ) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Status>, Error> { - let authorization = mastodonAuthenticationBox.userAuthorization + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content<Mastodon.Entity.Status> { + let domain = authenticationBox.domain + let authorization = authenticationBox.userAuthorization - return Mastodon.API.Statuses.publishStatus( + let response = try await Mastodon.API.Statuses.publishStatus( session: session, domain: domain, idempotencyKey: idempotencyKey, query: query, authorization: authorization - ) - .flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Status>, Error> in - #if APP_EXTENSION - return Just(response) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - #else - return APIService.Persist.persistStatus( - managedObjectContext: self.backgroundManagedObjectContext, - domain: domain, - query: nil, - response: response.map { [$0] }, - persistType: .lookUp, - requestMastodonUserID: nil, - log: OSLog.api + ).singleOutput() + + #if !APP_EXTENSION + let managedObjectContext = self.backgroundManagedObjectContext + try await managedObjectContext.performChanges { + let me = authenticationBox.authenticationRecord.object(in: managedObjectContext)?.user + _ = Persistence.Status.createOrMerge( + in: managedObjectContext, + context: Persistence.Status.PersistContext( + domain: domain, + entity: response.value, + me: me, + statusCache: nil, + userCache: nil, + networkDate: response.networkDate + ) ) - .setFailureType(to: Error.self) - .tryMap { result -> Mastodon.Response.Content<Mastodon.Entity.Status> in - switch result { - case .success: - return response - case .failure(let error): - throw error - } - } - .eraseToAnyPublisher() - #endif } - .eraseToAnyPublisher() + #endif + + return response } } diff --git a/Mastodon/Service/APIService/APIService+Status.swift b/Mastodon/Service/APIService/APIService+Status.swift index 7f82406f7..3d764663c 100644 --- a/Mastodon/Service/APIService/APIService+Status.swift +++ b/Mastodon/Service/APIService/APIService+Status.swift @@ -16,93 +16,67 @@ import MastodonSDK extension APIService { func status( - domain: String, statusID: Mastodon.Entity.Status.ID, - authorizationBox: MastodonAuthenticationBox - ) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Status>, Error> { - let authorization = authorizationBox.userAuthorization - return Mastodon.API.Statuses.status( + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content<Mastodon.Entity.Status> { + let domain = authenticationBox.domain + let authorization = authenticationBox.userAuthorization + + let response = try await Mastodon.API.Statuses.status( session: session, domain: domain, statusID: statusID, authorization: authorization - ) - .flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Status>, Error> in - return APIService.Persist.persistStatus( - managedObjectContext: self.backgroundManagedObjectContext, - domain: domain, - query: nil, - response: response.map { [$0] }, - persistType: .lookUp, - requestMastodonUserID: nil, - log: OSLog.api + ).singleOutput() + + let managedObjectContext = self.backgroundManagedObjectContext + try await managedObjectContext.performChanges { + let me = authenticationBox.authenticationRecord.object(in: managedObjectContext)?.user + _ = Persistence.Status.createOrMerge( + in: managedObjectContext, + context: Persistence.Status.PersistContext( + domain: domain, + entity: response.value, + me: me, + statusCache: nil, + userCache: nil, + networkDate: response.networkDate + ) ) - .setFailureType(to: Error.self) - .tryMap { result -> Mastodon.Response.Content<Mastodon.Entity.Status> in - switch result { - case .success: - return response - case .failure(let error): - throw error - } - } - .eraseToAnyPublisher() } - .eraseToAnyPublisher() + + return response } func deleteStatus( - domain: String, - statusID: Mastodon.Entity.Status.ID, - authorizationBox: MastodonAuthenticationBox - ) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Status>, Error> { - let authorization = authorizationBox.userAuthorization - let query = Mastodon.API.Statuses.DeleteStatusQuery(id: statusID) - return Mastodon.API.Statuses.deleteStatus( + status: ManagedObjectRecord<Status>, + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content<Mastodon.Entity.Status> { + let authorization = authenticationBox.userAuthorization + + let managedObjectContext = backgroundManagedObjectContext + let _query: Mastodon.API.Statuses.DeleteStatusQuery? = try? await managedObjectContext.perform { + guard let _status = status.object(in: managedObjectContext) else { return nil } + let status = _status.reblog ?? _status + return Mastodon.API.Statuses.DeleteStatusQuery(id: status.id) + } + guard let query = _query else { + throw APIError.implicit(.badRequest) + } + + let response = try await Mastodon.API.Statuses.deleteStatus( session: session, - domain: domain, + domain: authenticationBox.domain, query: query, authorization: authorization - ) - .flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Status>, Error> in - return self.backgroundManagedObjectContext.performChanges{ - // fetch old Status - let oldStatus: Status? = { - let request = Status.sortedFetchRequest - request.predicate = Status.predicate(domain: domain, id: response.value.id) - request.fetchLimit = 1 - request.returnsObjectsAsFaults = false - do { - return try self.backgroundManagedObjectContext.fetch(request).first - } catch { - assertionFailure(error.localizedDescription) - return nil - } - }() - if let status = oldStatus { - let homeTimelineIndexes = status.homeTimelineIndexes ?? Set() - for homeTimelineIndex in homeTimelineIndexes { - self.backgroundManagedObjectContext.delete(homeTimelineIndex) - } - let inNotifications = status.inNotifications ?? Set() - for notification in inNotifications { - self.backgroundManagedObjectContext.delete(notification) - } - self.backgroundManagedObjectContext.delete(status) - } - } - .setFailureType(to: Error.self) - .tryMap { result -> Mastodon.Response.Content<Mastodon.Entity.Status> in - switch result { - case .success: - return response - case .failure(let error): - throw error - } - } - .eraseToAnyPublisher() + ).singleOutput() + + try await managedObjectContext.performChanges { + guard let status = status.object(in: managedObjectContext) else { return } + managedObjectContext.delete(status) } - .eraseToAnyPublisher() + + return response } } diff --git a/Mastodon/Service/APIService/APIService+Subscriptions.swift b/Mastodon/Service/APIService/APIService+Subscriptions.swift index e9df2bc57..825bdcd4c 100644 --- a/Mastodon/Service/APIService/APIService+Subscriptions.swift +++ b/Mastodon/Service/APIService/APIService+Subscriptions.swift @@ -50,20 +50,18 @@ extension APIService { } func cancelSubscription( - mastodonAuthenticationBox: MastodonAuthenticationBox - ) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.EmptySubscription>, Error> { - let authorization = mastodonAuthenticationBox.userAuthorization - let domain = mastodonAuthenticationBox.domain - - return Mastodon.API.Subscriptions.removeSubscription( + domain: String, + authorization: Mastodon.API.OAuth.Authorization + ) async throws -> Mastodon.Response.Content<Mastodon.Entity.EmptySubscription> { + let response = try await Mastodon.API.Subscriptions.removeSubscription( session: session, domain: domain, authorization: authorization - ) - .handleEvents(receiveOutput: { _ in - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: cancel subscription successful", ((#file as NSString).lastPathComponent), #line, #function) - }) - .eraseToAnyPublisher() + ).singleOutput() + + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: cancel subscription successful", ((#file as NSString).lastPathComponent), #line, #function) + + return response } } diff --git a/Mastodon/Service/APIService/APIService+Thread.swift b/Mastodon/Service/APIService/APIService+Thread.swift index 3bebdffe0..782da5886 100644 --- a/Mastodon/Service/APIService/APIService+Thread.swift +++ b/Mastodon/Service/APIService/APIService+Thread.swift @@ -15,43 +15,40 @@ import MastodonSDK extension APIService { func statusContext( - domain: String, statusID: Mastodon.Entity.Status.ID, - mastodonAuthenticationBox: MastodonAuthenticationBox - ) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Context>, Error> { - let authorization = mastodonAuthenticationBox.userAuthorization - guard domain == mastodonAuthenticationBox.domain else { - return Fail(error: APIError.implicit(.badRequest)).eraseToAnyPublisher() - } + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content<Mastodon.Entity.Context> { + let domain = authenticationBox.domain + let authorization = authenticationBox.userAuthorization - return Mastodon.API.Statuses.statusContext( + let response = try await Mastodon.API.Statuses.statusContext( session: session, domain: domain, statusID: statusID, authorization: authorization - ) - .flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Context>, Error> in - return APIService.Persist.persistStatus( - managedObjectContext: self.backgroundManagedObjectContext, - domain: domain, - query: nil, - response: response.map { $0.ancestors + $0.descendants }, - persistType: .lookUp, - requestMastodonUserID: nil, - log: OSLog.api - ) - .setFailureType(to: Error.self) - .tryMap { result -> Mastodon.Response.Content<Mastodon.Entity.Context> in - switch result { - case .success: - return response - case .failure(let error): - throw error - } + ).singleOutput() + + let managedObjectContext = self.backgroundManagedObjectContext + try await managedObjectContext.performChanges { + let me = authenticationBox.authenticationRecord.object(in: managedObjectContext)?.user + let value = response.value.ancestors + response.value.descendants + + for entity in value { + Persistence.Status.createOrMerge( + in: managedObjectContext, + context: Persistence.Status.PersistContext( + domain: domain, + entity: entity, + me: me, + statusCache: nil, + userCache: nil, + networkDate: response.networkDate + ) + ) } - .eraseToAnyPublisher() } - .eraseToAnyPublisher() - } + + return response + } // end func } diff --git a/Mastodon/Service/APIService/APIService+Trend.swift b/Mastodon/Service/APIService/APIService+Trend.swift new file mode 100644 index 000000000..0ce2a86a8 --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Trend.swift @@ -0,0 +1,24 @@ +// +// APIService+Trend.swift +// Mastodon +// +// Created by MainasuK on 2022-1-18. +// + +import Foundation +import MastodonSDK + +extension APIService { + func trends( + domain: String, + query: Mastodon.API.Trends.Query? + ) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Tag]> { + let response = try await Mastodon.API.Trends.get( + session: session, + domain: domain, + query: query + ).singleOutput() + + return response + } +} diff --git a/Mastodon/Service/APIService/APIService+UserTimeline.swift b/Mastodon/Service/APIService/APIService+UserTimeline.swift index 7a449d37e..c5cb63180 100644 --- a/Mastodon/Service/APIService/APIService+UserTimeline.swift +++ b/Mastodon/Service/APIService/APIService+UserTimeline.swift @@ -15,7 +15,6 @@ import MastodonSDK extension APIService { func userTimeline( - domain: String, accountID: String, maxID: Mastodon.Entity.Status.ID? = nil, sinceID: Mastodon.Entity.Status.ID? = nil, @@ -23,10 +22,11 @@ extension APIService { excludeReplies: Bool? = nil, excludeReblogs: Bool? = nil, onlyMedia: Bool? = nil, - authorizationBox: MastodonAuthenticationBox - ) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Status]>, Error> { - let authorization = authorizationBox.userAuthorization - let requestMastodonUserID = authorizationBox.userID + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Status]> { + let domain = authenticationBox.domain + let authorization = authenticationBox.userAuthorization + let query = Mastodon.API.Account.AccountStatusesQuery( maxID: maxID, sinceID: sinceID, @@ -36,35 +36,33 @@ extension APIService { limit: limit ) - return Mastodon.API.Account.statuses( + let response = try await Mastodon.API.Account.statuses( session: session, domain: domain, accountID: accountID, query: query, authorization: authorization - ) - .flatMap { response -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Status]>, Error> in - return APIService.Persist.persistStatus( - managedObjectContext: self.backgroundManagedObjectContext, - domain: domain, - query: nil, - response: response, - persistType: .user, - requestMastodonUserID: requestMastodonUserID, - log: OSLog.api - ) - .setFailureType(to: Error.self) - .tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Status]> in - switch result { - case .success: - return response - case .failure(let error): - throw error - } + ).singleOutput() + + let managedObjectContext = self.backgroundManagedObjectContext + try await managedObjectContext.performChanges { + let me = authenticationBox.authenticationRecord.object(in: managedObjectContext)?.user + for entity in response.value { + Persistence.Status.createOrMerge( + in: managedObjectContext, + context: Persistence.Status.PersistContext( + domain: domain, + entity: entity, + me: me, + statusCache: nil, + userCache: nil, + networkDate: response.networkDate + ) + ) } - .eraseToAnyPublisher() } - .eraseToAnyPublisher() - } + + return response + } // end func } diff --git a/Mastodon/Service/APIService/APIService+WebFinger.swift b/Mastodon/Service/APIService/APIService+WebFinger.swift index 7cc0425dc..b49ad9e31 100644 --- a/Mastodon/Service/APIService/APIService+WebFinger.swift +++ b/Mastodon/Service/APIService/APIService+WebFinger.swift @@ -16,7 +16,8 @@ import MastodonSDK extension APIService { private static func webFingerEndpointURL(domain: String) -> URL { - return URL(string: "https://\(domain)/")! + + return URL(string: "\(URL.httpScheme(domain: domain))://\(domain)/")! .appendingPathComponent(".well-known") .appendingPathComponent("webfinger") } diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift deleted file mode 100644 index 90d482bca..000000000 --- a/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift +++ /dev/null @@ -1,133 +0,0 @@ -// -// APIService+CoreData+MastodonUser.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021/2/3. -// - -import os.log -import Foundation -import CoreData -import CoreDataStack -import MastodonSDK - -extension APIService.CoreData { - - static func createOrMergeMastodonUser( - into managedObjectContext: NSManagedObjectContext, - for requestMastodonUser: MastodonUser?, - in domain: String, - entity: Mastodon.Entity.Account, - userCache: APIService.Persist.PersistCache<MastodonUser>?, - networkDate: Date, - log: OSLog - ) -> (user: MastodonUser, isCreated: Bool) { - let processEntityTaskSignpostID = OSSignpostID(log: log) - os_signpost(.begin, log: log, name: "update database - process entity: createOrMergeMastodonUser", signpostID: processEntityTaskSignpostID, "process mastodon user %{public}s", entity.id) - defer { - os_signpost(.end, log: log, name: "update database - process entity: createOrMergeMastodonUser", signpostID: processEntityTaskSignpostID, "process msstodon user %{public}s", entity.id) - } - - // fetch old mastodon user - let oldMastodonUser: MastodonUser? = { - if let userCache = userCache { - return userCache.dictionary[entity.id] - } else { - let request = MastodonUser.sortedFetchRequest - request.predicate = MastodonUser.predicate(domain: domain, id: entity.id) - request.fetchLimit = 1 - request.returnsObjectsAsFaults = false - do { - return try managedObjectContext.fetch(request).first - } catch { - assertionFailure(error.localizedDescription) - return nil - } - } - }() - - if let oldMastodonUser = oldMastodonUser { - // merge old mastodon usre - APIService.CoreData.merge( - user: oldMastodonUser, - entity: entity, - requestMastodonUser: requestMastodonUser, - domain: domain, - networkDate: networkDate - ) - return (oldMastodonUser, false) - } else { - let mastodonUserProperty = MastodonUser.Property(entity: entity, domain: domain, networkDate: networkDate) - let mastodonUser = MastodonUser.insert( - into: managedObjectContext, - property: mastodonUserProperty - ) - userCache?.dictionary[entity.id] = mastodonUser - os_signpost(.event, log: log, name: "update database - process entity: createOrMergeMastodonUser", signpostID: processEntityTaskSignpostID, "did insert new mastodon user %{public}s: name %s", mastodonUser.identifier, mastodonUser.username) - return (mastodonUser, true) - } - } - -} - -extension APIService.CoreData { - - static func merge( - user: MastodonUser, - entity: Mastodon.Entity.Account, - requestMastodonUser: MastodonUser?, - domain: String, - networkDate: Date - ) { - guard networkDate > user.updatedAt else { return } - let property = MastodonUser.Property(entity: entity, domain: domain, networkDate: networkDate) - - // only fulfill API supported fields - user.update(acct: property.acct) - user.update(username: property.username) - user.update(displayName: property.displayName) - user.update(avatar: property.avatar) - user.update(avatarStatic: property.avatarStatic) - user.update(header: property.header) - user.update(headerStatic: property.headerStatic) - user.update(note: property.note) - user.update(url: property.url) - user.update(statusesCount: property.statusesCount) - user.update(followingCount: property.followingCount) - user.update(followersCount: property.followersCount) - user.update(locked: property.locked) - property.bot.flatMap { user.update(bot: $0) } - property.suspended.flatMap { user.update(suspended: $0) } - property.emojisData.flatMap { user.update(emojisData: $0) } - property.fieldsData.flatMap { user.update(fieldsData: $0) } - - user.didUpdate(at: networkDate) - } - -} - -extension APIService.CoreData { - - static func update( - user: MastodonUser, - entity: Mastodon.Entity.Relationship, - requestMastodonUser: MastodonUser, - domain: String, - networkDate: Date - ) { - guard networkDate > user.updatedAt else { return } - guard entity.id != requestMastodonUser.id else { return } // not update relationship for self - - user.update(isFollowing: entity.following, by: requestMastodonUser) - entity.requested.flatMap { user.update(isFollowRequested: $0, by: requestMastodonUser) } - entity.endorsed.flatMap { user.update(isEndorsed: $0, by: requestMastodonUser) } - requestMastodonUser.update(isFollowing: entity.followedBy, by: user) - entity.muting.flatMap { user.update(isMuting: $0, by: requestMastodonUser) } - user.update(isBlocking: entity.blocking, by: requestMastodonUser) - entity.domainBlocking.flatMap { user.update(isDomainBlocking: $0, by: requestMastodonUser) } - entity.blockedBy.flatMap { requestMastodonUser.update(isBlocking: $0, by: user) } - - user.didUpdate(at: networkDate) - } - -} diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Status.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Status.swift deleted file mode 100644 index 673cb4de3..000000000 --- a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Status.swift +++ /dev/null @@ -1,225 +0,0 @@ -// -// APIService+CoreData+Status.swift -// Mastodon -// -// Created by sxiaojian on 2021/2/3. -// - -import Foundation -import CoreData -import CoreDataStack -import CommonOSLog -import MastodonSDK - -extension APIService.CoreData { - - static func createOrMergeStatus( - into managedObjectContext: NSManagedObjectContext, - for requestMastodonUser: MastodonUser?, - domain: String, - entity: Mastodon.Entity.Status, - statusCache: APIService.Persist.PersistCache<Status>?, - userCache: APIService.Persist.PersistCache<MastodonUser>?, - networkDate: Date, - log: OSLog - ) -> (status: Status, isStatusCreated: Bool, isMastodonUserCreated: Bool) { - let processEntityTaskSignpostID = OSSignpostID(log: log) - os_signpost(.begin, log: log, name: "update database - process entity: createOrMergeStatus", signpostID: processEntityTaskSignpostID, "process status %{public}s", entity.id) - defer { - os_signpost(.end, log: log, name: "update database - process entity: createOrMergeStatus", signpostID: processEntityTaskSignpostID, "process status %{public}s", entity.id) - } - - // build tree - let reblog = entity.reblog.flatMap { entity -> Status in - let (status, _, _) = createOrMergeStatus( - into: managedObjectContext, - for: requestMastodonUser, - domain: domain, - entity: entity, - statusCache: statusCache, - userCache: userCache, - networkDate: networkDate, - log: log - ) - return status - } - - // fetch old Status - let oldStatus: Status? = { - if let statusCache = statusCache { - return statusCache.dictionary[entity.id] - } else { - let request = Status.sortedFetchRequest - request.predicate = Status.predicate(domain: domain, id: entity.id) - request.fetchLimit = 1 - request.returnsObjectsAsFaults = false - do { - return try managedObjectContext.fetch(request).first - } catch { - assertionFailure(error.localizedDescription) - return nil - } - } - }() - - if let oldStatus = oldStatus { - // merge old Status - APIService.CoreData.merge(status: oldStatus, entity: entity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: networkDate) - return (oldStatus, false, false) - } else { - let (mastodonUser, isMastodonUserCreated) = createOrMergeMastodonUser(into: managedObjectContext, for: requestMastodonUser,in: domain, entity: entity.account, userCache: userCache, networkDate: networkDate, log: log) - let application = entity.application.flatMap { app -> Application? in - Application.insert(into: managedObjectContext, property: Application.Property(name: app.name, website: app.website, vapidKey: app.vapidKey)) - } - let replyTo: Status? = { - // could be nil if target replyTo status's persist task in the queue - guard let inReplyToID = entity.inReplyToID, - let replyTo = statusCache?.dictionary[inReplyToID] else { return nil } - return replyTo - }() - let poll = entity.poll.flatMap { poll -> Poll in - let options = poll.options.enumerated().map { i, option -> PollOption in - let votedBy: MastodonUser? = (poll.ownVotes ?? []).contains(i) ? requestMastodonUser : nil - return PollOption.insert(into: managedObjectContext, property: PollOption.Property(index: i, title: option.title, votesCount: option.votesCount, networkDate: networkDate), votedBy: votedBy) - } - let votedBy: MastodonUser? = (poll.voted ?? false) ? requestMastodonUser : nil - 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 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 mediaAttachments: [Attachment]? = { - let encoder = JSONEncoder() - var attachments: [Attachment] = [] - for (index, attachment) in (entity.mediaAttachments ?? []).enumerated() { - let metaData = attachment.meta.flatMap { meta in - try? encoder.encode(meta) - } - let property = Attachment.Property(domain: domain, index: index, id: attachment.id, typeRaw: attachment.type.rawValue, url: attachment.url ?? "", previewURL: attachment.previewURL, remoteURL: attachment.remoteURL, metaData: metaData, textURL: attachment.textURL, descriptionString: attachment.description, blurhash: attachment.blurhash, networkDate: networkDate) - attachments.append(Attachment.insert(into: managedObjectContext, property: property)) - } - guard !attachments.isEmpty else { return nil } - return attachments - }() - let statusProperty = Status.Property(entity: entity, domain: domain, networkDate: networkDate) - let status = Status.insert( - into: managedObjectContext, - property: statusProperty, - author: mastodonUser, - reblog: reblog, - application: application, - replyTo: replyTo, - poll: poll, - mentions: mentions, - mediaAttachments: mediaAttachments, - favouritedBy: (entity.favourited ?? false) ? requestMastodonUser : nil, - rebloggedBy: (entity.reblogged ?? false) ? requestMastodonUser : nil, - mutedBy: (entity.muted ?? false) ? requestMastodonUser : nil, - bookmarkedBy: (entity.bookmarked ?? false) ? requestMastodonUser : nil, - pinnedBy: (entity.pinned ?? false) ? requestMastodonUser : nil - ) - statusCache?.dictionary[entity.id] = status - os_signpost(.event, log: log, name: "update database - process entity: createOrMergeStatus", signpostID: processEntityTaskSignpostID, "did insert new tweet %{public}s: %s", mastodonUser.identifier, entity.id) - return (status, true, isMastodonUserCreated) - } - } - -} - -extension APIService.CoreData { - static func merge( - status: Status, - entity: Mastodon.Entity.Status, - requestMastodonUser: MastodonUser?, - domain: String, - networkDate: Date - ) { - guard networkDate > status.updatedAt else { return } - - // merge poll - if let poll = status.poll, let entity = entity.poll { - merge(poll: poll, entity: entity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: networkDate) - } - - // merge metrics - if entity.favouritesCount != status.favouritesCount.intValue { - status.update(favouritesCount:NSNumber(value: entity.favouritesCount)) - } - if let repliesCount = entity.repliesCount { - if (repliesCount != status.repliesCount?.intValue) { - status.update(repliesCount:NSNumber(value: repliesCount)) - } - } - if entity.reblogsCount != status.reblogsCount.intValue { - status.update(reblogsCount:NSNumber(value: entity.reblogsCount)) - } - - // merge relationship - if let mastodonUser = requestMastodonUser { - if let favourited = entity.favourited { - status.update(liked: favourited, by: mastodonUser) - } - if let reblogged = entity.reblogged { - status.update(reblogged: reblogged, by: mastodonUser) - } - if let muted = entity.muted { - status.update(muted: muted, by: mastodonUser) - } - if let bookmarked = entity.bookmarked { - status.update(bookmarked: bookmarked, by: mastodonUser) - } - } - - // set updateAt - status.didUpdate(at: networkDate) - - // merge user - merge( - user: status.author, - entity: entity.account, - requestMastodonUser: requestMastodonUser, - domain: domain, - networkDate: networkDate - ) - - // merge indirect reblog - if let reblog = status.reblog, let reblogEntity = entity.reblog { - merge( - status: reblog, - entity: reblogEntity, - requestMastodonUser: requestMastodonUser, - domain: domain, - networkDate: networkDate - ) - } - } -} - -extension APIService.CoreData { - static func merge( - poll: Poll, - entity: Mastodon.Entity.Poll, - requestMastodonUser: MastodonUser?, - domain: String, - networkDate: Date - ) { - poll.update(expiresAt: entity.expiresAt) - poll.update(expired: entity.expired) - poll.update(votesCount: entity.votesCount) - poll.update(votersCount: entity.votersCount) - requestMastodonUser.flatMap { - poll.update(voted: entity.voted ?? false, by: $0) - } - - let oldOptions = poll.options.sorted(by: { $0.index.intValue < $1.index.intValue }) - for (i, (optionEntity, option)) in zip(entity.options, oldOptions).enumerated() { - let voted: Bool = (entity.ownVotes ?? []).contains(i) - option.update(votesCount: optionEntity.votesCount) - requestMastodonUser.flatMap { option.update(voted: voted, by: $0) } - option.didUpdate(at: networkDate) - } - - poll.didUpdate(at: networkDate) - } -} diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Tag.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Tag.swift deleted file mode 100644 index bc5718bc0..000000000 --- a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Tag.swift +++ /dev/null @@ -1,66 +0,0 @@ -// -// APIService+CoreData+Tag.swift -// Mastodon -// -// Created by sxiaojian on 2021/4/8. -// - -import CoreData -import CoreDataStack -import Foundation -import MastodonSDK - -extension APIService.CoreData { - static func createOrMergeTag( - into managedObjectContext: NSManagedObjectContext, - entity: Mastodon.Entity.Tag - ) -> (Tag: Tag, isCreated: Bool) { - // fetch old hashtag  - let oldTag: Tag? = { - let request = Tag.sortedFetchRequest - request.predicate = Tag.predicate(name: entity.name) - request.fetchLimit = 1 - request.returnsObjectsAsFaults = false - do { - return try managedObjectContext.fetch(request).first - } catch { - assertionFailure(error.localizedDescription) - return nil - } - }() - - if let oldTag = oldTag { - APIService.CoreData.merge(tag: oldTag, entity: entity, into: managedObjectContext) - return (oldTag, false) - } else { - let histories = entity.history?.prefix(2).compactMap { history -> History in - History.insert(into: managedObjectContext, property: History.Property(day: history.day, uses: history.uses, accounts: history.accounts)) - } - let tagInCoreData = Tag.insert(into: managedObjectContext, property: Tag.Property(name: entity.name, url: entity.url, histories: histories)) - return (tagInCoreData, true) - } - } - - static func merge(tag: Tag, entity: Mastodon.Entity.Tag, into managedObjectContext: NSManagedObjectContext) { - tag.update(url: tag.url) - guard let tagHistories = tag.histories else { return } - guard let entityHistories = entity.history?.prefix(2) else { return } - let entityHistoriesCount = entityHistories.count - if entityHistoriesCount == 0 { - return - } - for n in 0 ..< tagHistories.count { - if n < entityHistories.count { - let entityHistory = entityHistories[n] - tag.updateHistory(index: n, day: entityHistory.day, uses: entityHistory.uses, account: entityHistory.accounts) - } - } - if entityHistoriesCount <= tagHistories.count { - return - } - for n in 1 ... (entityHistoriesCount - tagHistories.count) { - let entityHistory = entityHistories[entityHistoriesCount - n] - tag.appendHistory(history: History.insert(into: managedObjectContext, property: History.Property(day: entityHistory.day, uses: entityHistory.uses, accounts: entityHistory.accounts))) - } - } -} diff --git a/Mastodon/Service/APIService/Persist/APIService+Persist+PersistCache.swift b/Mastodon/Service/APIService/Persist/APIService+Persist+PersistCache.swift deleted file mode 100644 index eb354035f..000000000 --- a/Mastodon/Service/APIService/Persist/APIService+Persist+PersistCache.swift +++ /dev/null @@ -1,66 +0,0 @@ -// -// APIService+Persist+PersistCache.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-3-10. -// - -import Foundation -import CoreDataStack -import MastodonSDK - -extension APIService.Persist { - - class PersistCache<T> { - var dictionary: [String : T] = [:] - } - -} - -extension APIService.Persist.PersistCache where T == Status { - - static func ids(for statuses: [Mastodon.Entity.Status]) -> Set<Mastodon.Entity.Status.ID> { - var value = Set<String>() - for status in statuses { - value = value.union(ids(for: status)) - } - return value - } - - static func ids(for status: Mastodon.Entity.Status) -> Set<Mastodon.Entity.Status.ID> { - var value = Set<String>() - value.insert(status.id) - if let inReplyToID = status.inReplyToID { - value.insert(inReplyToID) - } - if let reblog = status.reblog { - value = value.union(ids(for: reblog)) - } - return value - } - -} - -extension APIService.Persist.PersistCache where T == MastodonUser { - - static func ids(for statuses: [Mastodon.Entity.Status]) -> Set<Mastodon.Entity.Account.ID> { - var value = Set<String>() - for status in statuses { - value = value.union(ids(for: status)) - } - return value - } - - static func ids(for status: Mastodon.Entity.Status) -> Set<Mastodon.Entity.Account.ID> { - var value = Set<String>() - value.insert(status.account.id) - if let inReplyToAccountID = status.inReplyToAccountID { - value.insert(inReplyToAccountID) - } - if let reblog = status.reblog { - value = value.union(ids(for: reblog)) - } - return value - } - -} diff --git a/Mastodon/Service/APIService/Persist/APIService+Persist+PersistMemo.swift b/Mastodon/Service/APIService/Persist/APIService+Persist+PersistMemo.swift deleted file mode 100644 index dab4ba6ad..000000000 --- a/Mastodon/Service/APIService/Persist/APIService+Persist+PersistMemo.swift +++ /dev/null @@ -1,226 +0,0 @@ -// -// APIService+Persist+PersistMemo.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-3-10. -// - -import os.log -import Foundation -import CoreData -import CoreDataStack -import MastodonSDK - -extension APIService.Persist { - - class PersistMemo<T, U> { - - let status: T - let children: [PersistMemo<T, U>] - let memoType: MemoType - let statusProcessType: ProcessType - let authorProcessType: ProcessType - - enum MemoType { - case homeTimeline - case mentionTimeline - case userTimeline - case publicTimeline - case likeList - case searchList - case lookUp - - case reblog - - var flag: String { - switch self { - case .homeTimeline: return "H" - case .mentionTimeline: return "M" - case .userTimeline: return "U" - case .publicTimeline: return "P" - case .likeList: return "L" - case .searchList: return "S" - case .lookUp: return "LU" - case .reblog: return "R" - } - } - } - - enum ProcessType { - case create - case merge - - var flag: String { - switch self { - case .create: return "+" - case .merge: return "~" - } - } - } - - init( - status: T, - children: [PersistMemo<T, U>], - memoType: MemoType, - statusProcessType: ProcessType, - authorProcessType: ProcessType - ) { - self.status = status - self.children = children - self.memoType = memoType - self.statusProcessType = statusProcessType - self.authorProcessType = authorProcessType - } - - } - -} - -extension APIService.Persist.PersistMemo { - - struct Counting { - var status = Counter() - var user = Counter() - - static func + (left: Counting, right: Counting) -> Counting { - return Counting( - status: left.status + right.status, - user: left.user + right.user - ) - } - - struct Counter { - var create = 0 - var merge = 0 - - static func + (left: Counter, right: Counter) -> Counter { - return Counter( - create: left.create + right.create, - merge: left.merge + right.merge - ) - } - } - } - - func count() -> Counting { - var counting = Counting() - - switch statusProcessType { - case .create: counting.status.create += 1 - case .merge: counting.status.merge += 1 - } - - switch authorProcessType { - case .create: counting.user.create += 1 - case .merge: counting.user.merge += 1 - } - - for child in children { - let childCounting = child.count() - counting = counting + childCounting - } - - return counting - } - -} - -extension APIService.Persist.PersistMemo where T == Status, U == MastodonUser { - - static func createOrMergeStatus( - into managedObjectContext: NSManagedObjectContext, - for requestMastodonUser: MastodonUser?, - requestMastodonUserID: MastodonUser.ID?, - domain: String, - entity: Mastodon.Entity.Status, - memoType: MemoType, - statusCache: APIService.Persist.PersistCache<T>?, - userCache: APIService.Persist.PersistCache<U>?, - networkDate: Date, - log: OSLog - ) -> APIService.Persist.PersistMemo<T, U> { - let processEntityTaskSignpostID = OSSignpostID(log: log) - os_signpost(.begin, log: log, name: "update database - process entity: createOrMergeStatus", signpostID: processEntityTaskSignpostID, "process status %{public}s", entity.id) - defer { - os_signpost(.end, log: log, name: "update database - process entity: createOrMergeStatus", signpostID: processEntityTaskSignpostID, "finish process status %{public}s", entity.id) - } - - // build tree - let reblogMemo = entity.reblog.flatMap { entity -> APIService.Persist.PersistMemo<T, U> in - createOrMergeStatus( - into: managedObjectContext, - for: requestMastodonUser, - requestMastodonUserID: requestMastodonUserID, - domain: domain, - entity: entity, - memoType: .reblog, - statusCache: statusCache, - userCache: userCache, - networkDate: networkDate, - log: log - ) - } - let children = [reblogMemo].compactMap { $0 } - - - let (status, isStatusCreated, isMastodonUserCreated) = APIService.CoreData.createOrMergeStatus( - into: managedObjectContext, - for: requestMastodonUser, - domain: domain, - entity: entity, - statusCache: statusCache, - userCache: userCache, - networkDate: networkDate, - log: log - ) - let memo = APIService.Persist.PersistMemo<T, U>( - status: status, - children: children, - memoType: memoType, - statusProcessType: isStatusCreated ? .create : .merge, - authorProcessType: isMastodonUserCreated ? .create : .merge - ) - - switch (memo.statusProcessType, memoType) { - case (.create, .homeTimeline), (.merge, .homeTimeline): - let timelineIndex = status.homeTimelineIndexes? - .first { $0.userID == requestMastodonUserID } - guard let requestMastodonUserID = requestMastodonUserID else { - assertionFailure() - break - } - if timelineIndex == nil { - // make it indexed - let timelineIndexProperty = HomeTimelineIndex.Property(domain: domain, userID: requestMastodonUserID) - let _ = HomeTimelineIndex.insert(into: managedObjectContext, property: timelineIndexProperty, status: status) - } else { - // enity already in home timeline - } - case (.create, .mentionTimeline), (.merge, .mentionTimeline): - break - // TODO: - default: - break - } - - return memo - } - - func log(indentLevel: Int = 0) -> String { - let indent = Array(repeating: " ", count: indentLevel).joined() - let preview = status.content.prefix(32).replacingOccurrences(of: "\n", with: " ") - let message = "\(indent)[\(statusProcessType.flag)\(memoType.flag)](\(status.id)) [\(authorProcessType.flag)](\(status.author.id))@\(status.author.username) ~> \(preview)" - - var childrenMessages: [String] = [] - for child in children { - childrenMessages.append(child.log(indentLevel: indentLevel + 1)) - } - let result = [[message] + childrenMessages] - .flatMap { $0 } - .joined(separator: "\n") - - return result - } - -} - diff --git a/Mastodon/Service/APIService/Persist/APIService+Persist+Status.swift b/Mastodon/Service/APIService/Persist/APIService+Persist+Status.swift deleted file mode 100644 index f5bb4ea3d..000000000 --- a/Mastodon/Service/APIService/Persist/APIService+Persist+Status.swift +++ /dev/null @@ -1,263 +0,0 @@ -// -// APIService+Persist+Status.swift -// Mastodon -// -// Created by sxiaojian on 2021/1/27. -// - -import os.log -import func QuartzCore.CACurrentMediaTime -import Foundation -import Combine -import CoreData -import CoreDataStack -import MastodonSDK - -extension APIService.Persist { - - enum PersistTimelineType { - case `public` - case home - case user - case likeList - case lookUp - } - - static func persistStatus( - managedObjectContext: NSManagedObjectContext, - domain: String, - query: Mastodon.API.Timeline.TimelineQuery?, - response: Mastodon.Response.Content<[Mastodon.Entity.Status]>, - persistType: PersistTimelineType, - requestMastodonUserID: MastodonUser.ID?, // could be nil when response from public endpoint - log: OSLog - ) -> AnyPublisher<Result<Void, Error>, Never> { - return managedObjectContext.performChanges { - let statuses = response.value - os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: persist %{public}ld statuses…", ((#file as NSString).lastPathComponent), #line, #function, statuses.count) - - let contextTaskSignpostID = OSSignpostID(log: log) - let start = CACurrentMediaTime() - os_signpost(.begin, log: log, name: #function, signpostID: contextTaskSignpostID) - defer { - os_signpost(.end, log: .api, name: #function, signpostID: contextTaskSignpostID) - let end = CACurrentMediaTime() - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: persist cost %.2fs", ((#file as NSString).lastPathComponent), #line, #function, end - start) - } - - // load request mastodon user - let requestMastodonUser: MastodonUser? = { - guard let requestMastodonUserID = requestMastodonUserID else { return nil } - let request = MastodonUser.sortedFetchRequest - request.predicate = MastodonUser.predicate(domain: domain, id: requestMastodonUserID) - request.fetchLimit = 1 - request.returnsObjectsAsFaults = false - do { - return try managedObjectContext.fetch(request).first - } catch { - assertionFailure(error.localizedDescription) - return nil - } - }() - - // load working set into context to avoid cache miss - let cacheTaskSignpostID = OSSignpostID(log: log) - os_signpost(.begin, log: log, name: "load statuses & users into cache", signpostID: cacheTaskSignpostID) - - // contains reblog - let statusCache: PersistCache<Status> = { - let cache = PersistCache<Status>() - let cacheIDs = PersistCache<Status>.ids(for: statuses) - let cachedStatuses: [Status] = { - let request = Status.sortedFetchRequest - let ids = Array(cacheIDs) - request.predicate = Status.predicate(domain: domain, ids: ids) - request.returnsObjectsAsFaults = false - request.relationshipKeyPathsForPrefetching = [#keyPath(Status.reblog)] - do { - return try managedObjectContext.fetch(request) - } catch { - assertionFailure(error.localizedDescription) - return [] - } - }() - for status in cachedStatuses { - cache.dictionary[status.id] = status - } - os_signpost(.event, log: log, name: "load status into cache", signpostID: cacheTaskSignpostID, "cached %{public}ld statuses", cachedStatuses.count) - return cache - }() - - let userCache: PersistCache<MastodonUser> = { - let cache = PersistCache<MastodonUser>() - let cacheIDs = PersistCache<MastodonUser>.ids(for: statuses) - let cachedMastodonUsers: [MastodonUser] = { - let request = MastodonUser.sortedFetchRequest - let ids = Array(cacheIDs) - request.predicate = MastodonUser.predicate(domain: domain, ids: ids) - //request.returnsObjectsAsFaults = false - do { - return try managedObjectContext.fetch(request) - } catch { - assertionFailure(error.localizedDescription) - return [] - } - }() - for mastodonuser in cachedMastodonUsers { - cache.dictionary[mastodonuser.id] = mastodonuser - } - os_signpost(.event, log: log, name: "load user into cache", signpostID: cacheTaskSignpostID, "cached %{public}ld users", cachedMastodonUsers.count) - return cache - }() - - os_signpost(.end, log: log, name: "load statuses & users into cache", signpostID: cacheTaskSignpostID) - - // remote timeline merge local timeline record set - // declare it before persist - let mergedOldStatusesInTimeline = statusCache.dictionary.values.filter { - return $0.homeTimelineIndexes?.contains(where: { $0.userID == requestMastodonUserID }) ?? false - } - - let updateDatabaseTaskSignpostID = OSSignpostID(log: log) - let memoType: PersistMemo<Status, MastodonUser>.MemoType = { - switch persistType { - case .home: return .homeTimeline - case .public: return .publicTimeline - case .user: return .userTimeline - case .likeList: return .likeList - case .lookUp: return .lookUp - } - }() - - var persistMemos: [PersistMemo<Status, MastodonUser>] = [] - os_signpost(.begin, log: log, name: "update database", signpostID: updateDatabaseTaskSignpostID) - for entity in statuses { - let processEntityTaskSignpostID = OSSignpostID(log: log) - os_signpost(.begin, log: log, name: "update database - process entity", signpostID: processEntityTaskSignpostID, "process entity %{public}s", entity.id) - defer { - os_signpost(.end, log: log, name: "update database - process entity", signpostID: processEntityTaskSignpostID, "process entity %{public}s", entity.id) - } - let memo = PersistMemo.createOrMergeStatus( - into: managedObjectContext, - for: requestMastodonUser, - requestMastodonUserID: requestMastodonUserID, - domain: domain, - entity: entity, - memoType: memoType, - statusCache: statusCache, - userCache: userCache, - networkDate: response.networkDate, - log: log - ) - persistMemos.append(memo) - } // end for… - os_signpost(.end, log: log, name: "update database", signpostID: updateDatabaseTaskSignpostID) - - // home timeline tasks - switch persistType { - case .home: - guard let query = query, - let requestMastodonUserID = requestMastodonUserID else { - assertionFailure() - return - } - // Task 1: update anchor hasMore - // update maxID anchor hasMore attribute when fetching on home timeline - // do not use working records due to anchor status is removable on the remote - var anchorStatus: Status? - if let maxID = query.maxID { - do { - // load anchor status from database - let request = Status.sortedFetchRequest - request.predicate = Status.predicate(domain: domain, id: maxID) - request.returnsObjectsAsFaults = false - request.fetchLimit = 1 - anchorStatus = try managedObjectContext.fetch(request).first - if persistType == .home { - let timelineIndex = anchorStatus.flatMap { status in - status.homeTimelineIndexes?.first(where: { $0.userID == requestMastodonUserID }) - } - timelineIndex?.update(hasMore: false) - } else { - assertionFailure() - } - } catch { - assertionFailure(error.localizedDescription) - } - } - - // Task 2: set last status hasMore when fetched statuses not overlap with the timeline in the local database - let _oldestMemo = persistMemos - .sorted(by: { $0.status.createdAt < $1.status.createdAt }) - .first - if let oldestMemo = _oldestMemo { - if let anchorStatus = anchorStatus { - // using anchor. set hasMore when (overlap itself OR no overlap) AND oldest record NOT anchor - let isNoOverlap = mergedOldStatusesInTimeline.isEmpty - let isOnlyOverlapItself = mergedOldStatusesInTimeline.count == 1 && mergedOldStatusesInTimeline.first?.id == anchorStatus.id - let isAnchorEqualOldestRecord = oldestMemo.status.id == anchorStatus.id - if (isNoOverlap || isOnlyOverlapItself) && !isAnchorEqualOldestRecord { - if persistType == .home { - let timelineIndex = oldestMemo.status.homeTimelineIndexes? - .first(where: { $0.userID == requestMastodonUserID }) - timelineIndex?.update(hasMore: true) - } else { - assertionFailure() - } - } - - } else if mergedOldStatusesInTimeline.isEmpty { - // no anchor. set hasMore when no overlap - if persistType == .home { - let timelineIndex = oldestMemo.status.homeTimelineIndexes? - .first(where: { $0.userID == requestMastodonUserID }) - timelineIndex?.update(hasMore: true) - } - } - } else { - // empty working record. mark anchor hasMore in the task 1 - } - default: - break - } - - // reply relationship link - for (_, status) in statusCache.dictionary { - guard let replyToID = status.inReplyToID, status.replyTo == nil else { continue } - guard let replyTo = statusCache.dictionary[replyToID] else { continue } - status.update(replyTo: replyTo) - } - - // print working record tree map - #if DEBUG - DispatchQueue.global(qos: .utility).async { - let logs = persistMemos - .map { record in record.log() } - .joined(separator: "\n") - os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: working status: \n%s", ((#file as NSString).lastPathComponent), #line, #function, logs) - let counting = persistMemos - .map { record in record.count() } - .reduce(into: PersistMemo.Counting(), { result, next in result = result + next }) - let newTweetsInTimeLineCount = persistMemos.reduce(0, { result, next in - return next.statusProcessType == .create ? result + 1 : result - }) - os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: status: insert %{public}ldT(%{public}ldTRQ), merge %{public}ldT(%{public}ldTRQ)", ((#file as NSString).lastPathComponent), #line, #function, newTweetsInTimeLineCount, counting.status.create, mergedOldStatusesInTimeline.count, counting.status.merge) - os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: mastodon user: insert %{public}ld, merge %{public}ld", ((#file as NSString).lastPathComponent), #line, #function, counting.user.create, counting.user.merge) - } - #endif - } - .eraseToAnyPublisher() - .handleEvents(receiveOutput: { result in - switch result { - case .success: - break - case .failure(let error): - #if DEBUG - debugPrint(error) - #endif - assertionFailure(error.localizedDescription) - } - }) - .eraseToAnyPublisher() - } -} diff --git a/Mastodon/Service/AudioPlaybackService.swift b/Mastodon/Service/AudioPlaybackService.swift deleted file mode 100644 index 42d3edf7d..000000000 --- a/Mastodon/Service/AudioPlaybackService.swift +++ /dev/null @@ -1,150 +0,0 @@ -// -// AudioPlayer.swift -// Mastodon -// -// Created by sxiaojian on 2021/3/8. -// - -import AVFoundation -import Combine -import CoreDataStack -import Foundation -import UIKit -import os.log - -final class AudioPlaybackService: NSObject { - - static let appWillPlayAudioNotification = NSNotification.Name(rawValue: "org.joinmastodon.app.audio-playback-service.appWillPlayAudio") - - var disposeBag = Set<AnyCancellable>() - - var player = AVPlayer() - var timeObserver: Any? - var statusObserver: Any? - var attachment: Attachment? - - let playbackState = CurrentValueSubject<PlaybackState, Never>(PlaybackState.unknown) - - let currentTimeSubject = CurrentValueSubject<TimeInterval, Never>(0) - - override init() { - super.init() - addObserver() - - playbackState - .receive(on: RunLoop.main) - .sink { status in - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: audio status: %s", ((#file as NSString).lastPathComponent), #line, #function, status.description) - switch status { - case .unknown, .buffering, .readyToPlay: - break - case .playing: - try? AVAudioSession.sharedInstance().setCategory(.playback) - try? AVAudioSession.sharedInstance().setActive(true) - case .paused, .stopped, .failed: - try? AVAudioSession.sharedInstance().setCategory(.ambient) // set to ambient to allow mixed (needed for GIFV) - try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) - } - } - .store(in: &disposeBag) - } -} - -extension AudioPlaybackService { - func playAudio(audioAttachment: Attachment) { - guard let url = URL(string: audioAttachment.url) else { - return - } - - notifyWillPlayAudioNotification() - if audioAttachment == attachment { - if self.playbackState.value == .stopped { - self.seekToTime(time: .zero) - } - player.play() - self.playbackState.value = .playing - return - } - player.pause() - let playerItem = AVPlayerItem(url: url) - player.replaceCurrentItem(with: playerItem) - attachment = audioAttachment - player.play() - playbackState.value = .playing - } - - func addObserver() { - NotificationCenter.default.publisher(for: VideoPlayerViewModel.appWillPlayVideoNotification) - .sink { [weak self] _ in - guard let self = self else { return } - self.pauseIfNeed() - } - .store(in: &disposeBag) - - timeObserver = player.addPeriodicTimeObserver(forInterval: CMTimeMake(value: 1, timescale: CMTimeScale(NSEC_PER_SEC)), queue: DispatchQueue.main, using: { [weak self] time in - guard let self = self else { return } - self.currentTimeSubject.value = time.seconds - }) - player.publisher(for: \.status, options: [.initial, .new]) - .sink(receiveValue: { [weak self] status in - guard let self = self else { return } - switch status { - case .failed: - self.playbackState.value = .failed - case .readyToPlay: - self.playbackState.value = .readyToPlay - case .unknown: - self.playbackState.value = .unknown - @unknown default: - assertionFailure() - } - }) - .store(in: &disposeBag) - NotificationCenter.default.publisher(for: .AVPlayerItemDidPlayToEndTime, object: nil) - .sink { [weak self] _ in - guard let self = self else { return } - self.player.seek(to: .zero) - self.playbackState.value = .stopped - self.currentTimeSubject.value = 0 - } - .store(in: &disposeBag) - NotificationCenter.default.publisher(for: AVAudioSession.interruptionNotification, object: nil) - .sink { [weak self] _ in - guard let self = self else { return } - self.pause() - } - .store(in: &disposeBag) - } - - func notifyWillPlayAudioNotification() { - NotificationCenter.default.post(name: AudioPlaybackService.appWillPlayAudioNotification, object: nil) - } - func isPlaying() -> Bool { - return playbackState.value == .readyToPlay || playbackState.value == .playing - } - func resume() { - notifyWillPlayAudioNotification() - player.play() - playbackState.value = .playing - } - - func pause() { - player.pause() - playbackState.value = .paused - } - func pauseIfNeed() { - if isPlaying() { - pause() - } - } - func seekToTime(time: TimeInterval) { - player.seek(to: CMTimeMake(value:Int64(time), timescale: 1)) - } -} - -extension AudioPlaybackService { - func viewDidDisappear(from viewController: UIViewController?) { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function) - pause() - } -} diff --git a/Mastodon/Service/AuthenticationService.swift b/Mastodon/Service/AuthenticationService.swift index 9e27caab6..97afde932 100644 --- a/Mastodon/Service/AuthenticationService.swift +++ b/Mastodon/Service/AuthenticationService.swift @@ -66,6 +66,7 @@ final class AuthenticationService: NSObject { .sorted(by: { $0.activedAt > $1.activedAt }) .compactMap { authentication -> MastodonAuthenticationBox? in return MastodonAuthenticationBox( + authenticationRecord: .init(objectID: authentication.objectID), domain: authentication.domain, userID: authentication.userID, appAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.appAccessToken), @@ -121,6 +122,7 @@ extension AuthenticationService { // force set to avoid delay self.activeMastodonAuthentication.value = mastodonAuthentication self.activeMastodonAuthenticationBox.value = MastodonAuthenticationBox( + authenticationRecord: .init(objectID: mastodonAuthentication.objectID), domain: mastodonAuthentication.domain, userID: mastodonAuthentication.userID, appAuthorization: Mastodon.API.OAuth.Authorization(accessToken: mastodonAuthentication.appAccessToken), @@ -135,57 +137,41 @@ extension AuthenticationService { .eraseToAnyPublisher() } - func signOutMastodonUser(domain: String, userID: MastodonUser.ID) -> AnyPublisher<Result<Bool, Error>, Never> { - var isSignOut = false - - var _mastodonAuthenticationBox: MastodonAuthenticationBox? + func signOutMastodonUser( + authenticationBox: MastodonAuthenticationBox + ) async throws { let managedObjectContext = backgroundManagedObjectContext - return managedObjectContext.performChanges { - let request = MastodonAuthentication.sortedFetchRequest - request.predicate = MastodonAuthentication.predicate(domain: domain, userID: userID) - request.fetchLimit = 1 - guard let mastodonAuthentication = try? managedObjectContext.fetch(request).first else { - return - } - _mastodonAuthenticationBox = MastodonAuthenticationBox( - domain: mastodonAuthentication.domain, - userID: mastodonAuthentication.userID, - appAuthorization: Mastodon.API.OAuth.Authorization(accessToken: mastodonAuthentication.appAccessToken), - userAuthorization: Mastodon.API.OAuth.Authorization(accessToken: mastodonAuthentication.userAccessToken) + try await managedObjectContext.performChanges { + // remove Feed + let request = Feed.sortedFetchRequest + request.predicate = Feed.predicate( + acct: .mastodon( + domain: authenticationBox.domain, + userID: authenticationBox.userID + ) ) - - // remove home timeline indexes - let homeTimelineIndexRequest = HomeTimelineIndex.sortedFetchRequest - homeTimelineIndexRequest.predicate = HomeTimelineIndex.predicate( - domain: mastodonAuthentication.domain, - userID: mastodonAuthentication.userID - ) - let homeTimelineIndexes = managedObjectContext.safeFetch(homeTimelineIndexRequest) - for homeTimelineIndex in homeTimelineIndexes { - managedObjectContext.delete(homeTimelineIndex) - } - - // remove user authentication - managedObjectContext.delete(mastodonAuthentication) - isSignOut = true - } - .flatMap { result -> AnyPublisher<Result<Void, Error>, Never> in - guard let apiService = self.apiService, - let mastodonAuthenticationBox = _mastodonAuthenticationBox else { - return Just(result).eraseToAnyPublisher() + let feeds = managedObjectContext.safeFetch(request) + for feed in feeds { + managedObjectContext.delete(feed) } - return apiService.cancelSubscription( - mastodonAuthenticationBox: mastodonAuthenticationBox + guard let authentication = authenticationBox.authenticationRecord.object(in: managedObjectContext) else { + assertionFailure() + throw APIService.APIError.implicit(.authenticationMissing) + } + + managedObjectContext.delete(authentication) + } + + // cancel push notification subscription + do { + _ = try await apiService?.cancelSubscription( + domain: authenticationBox.domain, + authorization: authenticationBox.userAuthorization ) - .map { _ in result } - .catch { _ in Just(result).eraseToAnyPublisher() } - .eraseToAnyPublisher() + } catch { + // do nothing } - .map { result in - return result.map { isSignOut } - } - .eraseToAnyPublisher() } } diff --git a/Mastodon/Service/BlockDomainService.swift b/Mastodon/Service/BlockDomainService.swift index 036083e60..90d860143 100644 --- a/Mastodon/Service/BlockDomainService.swift +++ b/Mastodon/Service/BlockDomainService.swift @@ -44,79 +44,79 @@ final class BlockDomainService { } } - func blockDomain( - userProvider: UserProvider, - cell: UITableViewCell? - ) { - guard let activeMastodonAuthenticationBox = userProvider.context.authenticationService.activeMastodonAuthenticationBox.value else { return } - guard let context = userProvider.context else { - return - } - var mastodonUser: AnyPublisher<MastodonUser?, Never> - if let cell = cell { - mastodonUser = userProvider.mastodonUser(for: cell).eraseToAnyPublisher() - } else { - mastodonUser = userProvider.mastodonUser().eraseToAnyPublisher() - } - mastodonUser - .compactMap { mastodonUser -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Empty>, Error>? in - guard let mastodonUser = mastodonUser else { - return nil - } - return context.apiService.blockDomain(user: mastodonUser, authorizationBox: activeMastodonAuthenticationBox) - } - .switchToLatest() - .flatMap { _ -> AnyPublisher<Mastodon.Response.Content<[String]>, Error> in - context.apiService.getDomainblocks(domain: activeMastodonAuthenticationBox.domain, authorizationBox: activeMastodonAuthenticationBox) - } - .sink { completion in - switch completion { - case .finished: - break - case .failure(let error): - print(error) - } - } receiveValue: { [weak self] response in - self?.blockedDomains.value = response.value - } - .store(in: &userProvider.disposeBag) - } - - func unblockDomain( - userProvider: UserProvider, - cell: UITableViewCell? - ) { - guard let activeMastodonAuthenticationBox = userProvider.context.authenticationService.activeMastodonAuthenticationBox.value else { return } - guard let context = userProvider.context else { - return - } - var mastodonUser: AnyPublisher<MastodonUser?, Never> - if let cell = cell { - mastodonUser = userProvider.mastodonUser(for: cell).eraseToAnyPublisher() - } else { - mastodonUser = userProvider.mastodonUser().eraseToAnyPublisher() - } - mastodonUser - .compactMap { mastodonUser -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Empty>, Error>? in - guard let mastodonUser = mastodonUser else { - return nil - } - return context.apiService.unblockDomain(user: mastodonUser, authorizationBox: activeMastodonAuthenticationBox) - } - .switchToLatest() - .flatMap { _ -> AnyPublisher<Mastodon.Response.Content<[String]>, Error> in - context.apiService.getDomainblocks(domain: activeMastodonAuthenticationBox.domain, authorizationBox: activeMastodonAuthenticationBox) - } - .sink { completion in - switch completion { - case .finished: - break - case .failure(let error): - print(error) - } - } receiveValue: { [weak self] response in - self?.blockedDomains.value = response.value - } - .store(in: &userProvider.disposeBag) - } +// func blockDomain( +// userProvider: UserProvider, +// cell: UITableViewCell? +// ) { +// guard let activeMastodonAuthenticationBox = userProvider.context.authenticationService.activeMastodonAuthenticationBox.value else { return } +// guard let context = userProvider.context else { +// return +// } +// var mastodonUser: AnyPublisher<MastodonUser?, Never> +// if let cell = cell { +// mastodonUser = userProvider.mastodonUser(for: cell).eraseToAnyPublisher() +// } else { +// mastodonUser = userProvider.mastodonUser().eraseToAnyPublisher() +// } +// mastodonUser +// .compactMap { mastodonUser -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Empty>, Error>? in +// guard let mastodonUser = mastodonUser else { +// return nil +// } +// return context.apiService.blockDomain(user: mastodonUser, authorizationBox: activeMastodonAuthenticationBox) +// } +// .switchToLatest() +// .flatMap { _ -> AnyPublisher<Mastodon.Response.Content<[String]>, Error> in +// context.apiService.getDomainblocks(domain: activeMastodonAuthenticationBox.domain, authorizationBox: activeMastodonAuthenticationBox) +// } +// .sink { completion in +// switch completion { +// case .finished: +// break +// case .failure(let error): +// print(error) +// } +// } receiveValue: { [weak self] response in +// self?.blockedDomains.value = response.value +// } +// .store(in: &userProvider.disposeBag) +// } +// +// func unblockDomain( +// userProvider: UserProvider, +// cell: UITableViewCell? +// ) { +// guard let activeMastodonAuthenticationBox = userProvider.context.authenticationService.activeMastodonAuthenticationBox.value else { return } +// guard let context = userProvider.context else { +// return +// } +// var mastodonUser: AnyPublisher<MastodonUser?, Never> +// if let cell = cell { +// mastodonUser = userProvider.mastodonUser(for: cell).eraseToAnyPublisher() +// } else { +// mastodonUser = userProvider.mastodonUser().eraseToAnyPublisher() +// } +// mastodonUser +// .compactMap { mastodonUser -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Empty>, Error>? in +// guard let mastodonUser = mastodonUser else { +// return nil +// } +// return context.apiService.unblockDomain(user: mastodonUser, authorizationBox: activeMastodonAuthenticationBox) +// } +// .switchToLatest() +// .flatMap { _ -> AnyPublisher<Mastodon.Response.Content<[String]>, Error> in +// context.apiService.getDomainblocks(domain: activeMastodonAuthenticationBox.domain, authorizationBox: activeMastodonAuthenticationBox) +// } +// .sink { completion in +// switch completion { +// case .finished: +// break +// case .failure(let error): +// print(error) +// } +// } receiveValue: { [weak self] response in +// self?.blockedDomains.value = response.value +// } +// .store(in: &userProvider.disposeBag) +// } } diff --git a/Mastodon/Service/BlurhashImageCacheService.swift b/Mastodon/Service/BlurhashImageCacheService.swift index be729a2f8..b15a9750b 100644 --- a/Mastodon/Service/BlurhashImageCacheService.swift +++ b/Mastodon/Service/BlurhashImageCacheService.swift @@ -8,13 +8,19 @@ import UIKit import Combine -final class BlurhashImageCacheService { +public final class BlurhashImageCacheService { + + static let edgeMaxLength: CGFloat = 20 let cache = NSCache<Key, UIImage>() let workingQueue = DispatchQueue(label: "org.joinmastodon.app.BlurhashImageCacheService.working-queue", qos: .userInitiated, attributes: .concurrent) - func image(blurhash: String, size: CGSize, url: URL) -> AnyPublisher<UIImage?, Never> { + public func image( + blurhash: String, + size: CGSize, + url: String + ) -> AnyPublisher<UIImage?, Never> { let key = Key(blurhash: blurhash, size: size, url: url) if let image = self.cache.object(forKey: key) { @@ -23,7 +29,7 @@ final class BlurhashImageCacheService { return Future { promise in self.workingQueue.async { - guard let image = BlurhashImageCacheService.blurhashImage(blurhash: blurhash, size: size, url: url) else { + guard let image = BlurhashImageCacheService.blurhashImage(blurhash: blurhash, size: size) else { promise(.success(nil)) return } @@ -33,18 +39,17 @@ final class BlurhashImageCacheService { } .receive(on: RunLoop.main) .eraseToAnyPublisher() - } - static func blurhashImage(blurhash: String, size: CGSize, url: URL) -> UIImage? { + static func blurhashImage(blurhash: String, size: CGSize) -> UIImage? { let imageSize: CGSize = { let aspectRadio = size.width / size.height if size.width > size.height { - let width: CGFloat = MosaicMeta.edgeMaxLength + let width: CGFloat = BlurhashImageCacheService.edgeMaxLength let height = width / aspectRadio return CGSize(width: width, height: height) } else { - let height: CGFloat = MosaicMeta.edgeMaxLength + let height: CGFloat = BlurhashImageCacheService.edgeMaxLength let width = height * aspectRadio return CGSize(width: width, height: height) } @@ -61,9 +66,9 @@ extension BlurhashImageCacheService { class Key: NSObject { let blurhash: String let size: CGSize - let url: URL + let url: String - init(blurhash: String, size: CGSize, url: URL) { + init(blurhash: String, size: CGSize, url: String) { self.blurhash = blurhash self.size = size self.url = url @@ -82,6 +87,5 @@ extension BlurhashImageCacheService { size.height.hashValue ^ url.hashValue } - } } diff --git a/Mastodon/Service/NotificationService.swift b/Mastodon/Service/NotificationService.swift index 6eb3120c7..e4e7508a3 100644 --- a/Mastodon/Service/NotificationService.swift +++ b/Mastodon/Service/NotificationService.swift @@ -121,58 +121,20 @@ extension NotificationService { return _notificationSubscription } - func handle(mastodonPushNotification: MastodonPushNotification) { + func handle( + pushNotification: MastodonPushNotification + ) { defer { unreadNotificationCountDidUpdate.send() } - - // Subscription maybe failed to cancel when sign-out - // Try cancel again if receive that kind push notification - guard let managedObjectContext = authenticationService?.managedObjectContext else { return } - guard let apiService = apiService else { return } - managedObjectContext.perform { - let subscriptionRequest = NotificationSubscription.sortedFetchRequest - subscriptionRequest.predicate = NotificationSubscription.predicate(userToken: mastodonPushNotification.accessToken) - let subscriptions = managedObjectContext.safeFetch(subscriptionRequest) + Task { + // trigger notification timeline update + try? await fetchLatestNotifications(pushNotification: pushNotification) - // note: assert setting remove after cancel subscription - guard let subscription = subscriptions.first else { return } - guard let setting = subscription.setting else { return } - let domain = setting.domain - let userID = setting.userID - - let authenticationRequest = MastodonAuthentication.sortedFetchRequest - authenticationRequest.predicate = MastodonAuthentication.predicate(domain: domain, userID: userID) - let authentication = managedObjectContext.safeFetch(authenticationRequest).first - - guard authentication == nil else { - // do nothing if still sign-in - return - } - - // cancel subscription if sign-out - let accessToken = mastodonPushNotification.accessToken - let mastodonAuthenticationBox = MastodonAuthenticationBox( - domain: domain, - userID: userID, - appAuthorization: .init(accessToken: accessToken), - userAuthorization: .init(accessToken: accessToken) - ) - apiService - .cancelSubscription(mastodonAuthenticationBox: mastodonAuthenticationBox) - .sink { completion in - switch completion { - case .failure(let error): - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification] failed to cancel sign-out user subscription: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) - case .finished: - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification] cancel sign-out user subscription", ((#file as NSString).lastPathComponent), #line, #function) - } - } receiveValue: { _ in - // do nothing - } - .store(in: &self.disposeBag) - } + // cancel sign-out account push notification subscription + try? await cancelSubscriptionForDetachedAccount(pushNotification: pushNotification) + } // end Task } } @@ -188,6 +150,92 @@ extension NotificationService { } } +extension NotificationService { + private func fetchLatestNotifications( + pushNotification: MastodonPushNotification + ) async throws { + guard let apiService = apiService else { return } + guard let authenticationBox = try await authenticationBox(for: pushNotification) else { return } + + _ = try await apiService.notifications( + maxID: nil, + scope: .everything, + authenticationBox: authenticationBox + ) + } + + private func cancelSubscriptionForDetachedAccount( + pushNotification: MastodonPushNotification + ) async throws { + // Subscription maybe failed to cancel when sign-out + // Try cancel again if receive that kind push notification + guard let managedObjectContext = authenticationService?.managedObjectContext else { return } + guard let apiService = apiService else { return } + + let userAccessToken = pushNotification.accessToken + + let needsCancelSubscription: Bool = try await managedObjectContext.perform { + // check authentication exists + let authenticationRequest = MastodonAuthentication.sortedFetchRequest + authenticationRequest.predicate = MastodonAuthentication.predicate(userAccessToken: userAccessToken) + return managedObjectContext.safeFetch(authenticationRequest).first == nil + } + + guard needsCancelSubscription else { + return + } + + guard let domain = try await domain(for: pushNotification) else { return } + + do { + _ = try await apiService.cancelSubscription( + domain: domain, + authorization: .init(accessToken: userAccessToken) + ) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification] cancel sign-out user subscription", ((#file as NSString).lastPathComponent), #line, #function) + } catch { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification] failed to cancel sign-out user subscription: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + } + } + + private func domain(for pushNotification: MastodonPushNotification) async throws -> String? { + guard let authenticationService = self.authenticationService else { return nil } + let managedObjectContext = authenticationService.managedObjectContext + return try await managedObjectContext.perform { + let subscriptionRequest = NotificationSubscription.sortedFetchRequest + subscriptionRequest.predicate = NotificationSubscription.predicate(userToken: pushNotification.accessToken) + let subscriptions = managedObjectContext.safeFetch(subscriptionRequest) + + // note: assert setting not remove after sign-out + guard let subscription = subscriptions.first else { return nil } + guard let setting = subscription.setting else { return nil } + let domain = setting.domain + + return domain + } + } + + private func authenticationBox(for pushNotification: MastodonPushNotification) async throws -> MastodonAuthenticationBox? { + guard let authenticationService = self.authenticationService else { return nil } + let managedObjectContext = authenticationService.managedObjectContext + return try await managedObjectContext.perform { + let request = MastodonAuthentication.sortedFetchRequest + request.predicate = MastodonAuthentication.predicate(userAccessToken: pushNotification.accessToken) + request.fetchLimit = 1 + guard let authentication = managedObjectContext.safeFetch(request).first else { return nil } + + return MastodonAuthenticationBox( + authenticationRecord: .init(objectID: authentication.objectID), + domain: authentication.domain, + userID: authentication.userID, + appAuthorization: .init(accessToken: authentication.appAccessToken), + userAuthorization: .init(accessToken: authentication.userAccessToken) + ) + } + } + +} + // MARK: - NotificationViewModel extension NotificationService { diff --git a/Mastodon/Service/SettingService.swift b/Mastodon/Service/SettingService.swift index 79ed47abf..1e8022c59 100644 --- a/Mastodon/Service/SettingService.swift +++ b/Mastodon/Service/SettingService.swift @@ -10,6 +10,8 @@ import UIKit import Combine import CoreDataStack import MastodonSDK +import MastodonAsset +import MastodonLocalization final class SettingService { diff --git a/Mastodon/Service/StatusFilterService.swift b/Mastodon/Service/StatusFilterService.swift index 38a1a17c4..b5afd08ab 100644 --- a/Mastodon/Service/StatusFilterService.swift +++ b/Mastodon/Service/StatusFilterService.swift @@ -23,7 +23,7 @@ final class StatusFilterService { let filterUpdatePublisher = PassthroughSubject<Void, Never>() // output - let activeFilters = CurrentValueSubject<[Mastodon.Entity.Filter], Never>([]) + @Published var activeFilters: [Mastodon.Entity.Filter] = [] init( apiService: APIService, @@ -57,7 +57,14 @@ final class StatusFilterService { .map { response in let now = Date() let newResponse = response.map { filters in - return filters.filter { $0.expiresAt > now } // filter out expired rules + return filters.filter { filter in + if let expiresAt = filter.expiresAt { + // filter out expired rules + return expiresAt > now + } else { + return true + } + } } return Result<Mastodon.Response.Content<[Mastodon.Entity.Filter]>, Error>.success(newResponse) } @@ -70,7 +77,7 @@ final class StatusFilterService { switch result { case .success(let response): os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch account filters success. %ld items", ((#file as NSString).lastPathComponent), #line, #function, response.value.count) - self.activeFilters.value = response.value + self.activeFilters = response.value case .failure(let error): os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch account filters fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) diff --git a/Mastodon/Service/StatusPrefetchingService.swift b/Mastodon/Service/StatusPrefetchingService.swift deleted file mode 100644 index e22ba69f0..000000000 --- a/Mastodon/Service/StatusPrefetchingService.swift +++ /dev/null @@ -1,152 +0,0 @@ -// -// StatusPrefetchingService.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-3-10. -// - -import os.log -import Foundation -import Combine -import CoreData -import CoreDataStack -import MastodonSDK -import MastodonMeta - -final class StatusPrefetchingService { - - typealias TaskID = String - typealias StatusObjectID = NSManagedObjectID - - let workingQueue = DispatchQueue(label: "org.joinmastodon.app.StatusPrefetchingService.working-queue") - - // StatusContentOperation - let statusContentOperationQueue: OperationQueue = { - let queue = OperationQueue() - queue.name = "org.joinmastodon.app.StatusPrefetchingService.statusContentOperationQueue" - queue.maxConcurrentOperationCount = 2 - return queue - }() - var statusContentOperations: [StatusObjectID: StatusContentOperation] = [:] - - var disposeBag = Set<AnyCancellable>() - private(set) var statusPrefetchingDisposeBagDict: [TaskID: AnyCancellable] = [:] - - // input - weak var apiService: APIService? - let managedObjectContext: NSManagedObjectContext - let backgroundManagedObjectContext: NSManagedObjectContext // read-only - - init( - managedObjectContext: NSManagedObjectContext, - backgroundManagedObjectContext: NSManagedObjectContext, - apiService: APIService - ) { - self.managedObjectContext = managedObjectContext - self.backgroundManagedObjectContext = backgroundManagedObjectContext - self.apiService = apiService - } - - private func status(from statusObjectItem: StatusObjectItem) -> Status? { - assert(Thread.isMainThread) - switch statusObjectItem { - case .homeTimelineIndex(let objectID): - let homeTimelineIndex = try? managedObjectContext.existingObject(with: objectID) as? HomeTimelineIndex - return homeTimelineIndex?.status - case .mastodonNotification(let objectID): - let mastodonNotification = try? managedObjectContext.existingObject(with: objectID) as? MastodonNotification - return mastodonNotification?.status - case .status(let objectID): - let status = try? managedObjectContext.existingObject(with: objectID) as? Status - return status - } - - } - -} - -extension StatusPrefetchingService { - func prefetch(statusObjectItems items: [StatusObjectItem]) { - for item in items { - guard let status = status(from: item), !status.isDeleted else { continue } - - // status content parser task - if statusContentOperations[status.objectID] == nil { - let mastodonContent = MastodonContent( - content: (status.reblog ?? status).content, - emojis: (status.reblog ?? status).emojiMeta - ) - let operation = StatusContentOperation( - statusObjectID: status.objectID, - mastodonContent: mastodonContent - ) - statusContentOperations[status.objectID] = operation - statusContentOperationQueue.addOperation(operation) - } - } - } - - func cancelPrefetch(statusObjectItems items: [StatusObjectItem]) { - for item in items { - guard let status = status(from: item), !status.isDeleted else { continue } - - // cancel status content parser task - statusContentOperations.removeValue(forKey: status.objectID)?.cancel() - } - } - -} - -extension StatusPrefetchingService { - - func prefetchReplyTo( - domain: String, - statusObjectID: NSManagedObjectID, - statusID: Mastodon.Entity.Status.ID, - replyToStatusID: Mastodon.Entity.Status.ID, - authorizationBox: MastodonAuthenticationBox - ) { - workingQueue.async { [weak self] in - guard let self = self, let apiService = self.apiService else { return } - let taskID = domain + "@" + statusID + "->" + replyToStatusID - guard self.statusPrefetchingDisposeBagDict[taskID] == nil else { return } - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: prefetching replyTo: %s", ((#file as NSString).lastPathComponent), #line, #function, taskID) - - self.statusPrefetchingDisposeBagDict[taskID] = apiService.status( - domain: domain, - statusID: replyToStatusID, - authorizationBox: authorizationBox - ) - .sink(receiveCompletion: { [weak self] completion in - // remove task when completed - guard let self = self else { return } - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: prefeched replyTo: %s", ((#file as NSString).lastPathComponent), #line, #function, taskID) - self.statusPrefetchingDisposeBagDict[taskID] = nil - }, receiveValue: { [weak self] _ in - guard let self = self else { return } - let backgroundManagedObjectContext = apiService.backgroundManagedObjectContext - backgroundManagedObjectContext.performChanges { - guard let status = backgroundManagedObjectContext.object(with: statusObjectID) as? Status else { return } - do { - let predicate = Status.predicate(domain: domain, id: replyToStatusID) - let request = Status.sortedFetchRequest - request.predicate = predicate - request.returnsObjectsAsFaults = false - request.fetchLimit = 1 - guard let replyTo = try backgroundManagedObjectContext.fetch(request).first else { return } - status.update(replyTo: replyTo) - } catch { - assertionFailure(error.localizedDescription) - } - } - .sink { _ in - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: update status replyTo: %s", ((#file as NSString).lastPathComponent), #line, #function, taskID) - } receiveValue: { _ in - // do nothing - } - .store(in: &self.disposeBag) - }) - } - } - -} diff --git a/Mastodon/Service/ThemeService/MastodonTheme.swift b/Mastodon/Service/ThemeService/MastodonTheme.swift index 1f0fd4e38..0dad463b6 100644 --- a/Mastodon/Service/ThemeService/MastodonTheme.swift +++ b/Mastodon/Service/ThemeService/MastodonTheme.swift @@ -6,6 +6,7 @@ // import UIKit +import MastodonAsset struct MastodonTheme: Theme { diff --git a/Mastodon/Service/ThemeService/SystemTheme.swift b/Mastodon/Service/ThemeService/SystemTheme.swift index 26673d57d..7796fde7b 100644 --- a/Mastodon/Service/ThemeService/SystemTheme.swift +++ b/Mastodon/Service/ThemeService/SystemTheme.swift @@ -6,6 +6,7 @@ // import UIKit +import MastodonAsset struct SystemTheme: Theme { diff --git a/Mastodon/Service/ThemeService/ThemeService.swift b/Mastodon/Service/ThemeService/ThemeService.swift index e3bd7c4ab..b356d3469 100644 --- a/Mastodon/Service/ThemeService/ThemeService.swift +++ b/Mastodon/Service/ThemeService/ThemeService.swift @@ -7,6 +7,7 @@ import UIKit import Combine +import AppShared // ref: https://zamzam.io/protocol-oriented-themes-for-ios-apps/ final class ThemeService { diff --git a/Mastodon/Service/VideoPlaybackService.swift b/Mastodon/Service/VideoPlaybackService.swift deleted file mode 100644 index f1e289926..000000000 --- a/Mastodon/Service/VideoPlaybackService.swift +++ /dev/null @@ -1,141 +0,0 @@ -// -// ViedeoPlaybackService.swift -// Mastodon -// -// Created by xiaojian sun on 2021/3/10. -// - -import AVKit -import Combine -import CoreDataStack -import Foundation -import os.log - -final class VideoPlaybackService { - var disposeBag = Set<AnyCancellable>() - - let workingQueue = DispatchQueue(label: "org.joinmastodon.app.VideoPlaybackService.working-queue") - private(set) var viewPlayerViewModelDict: [URL: VideoPlayerViewModel] = [:] - - // only for video kind - weak var latestPlayingVideoPlayerViewModel: VideoPlayerViewModel? -} - -extension VideoPlaybackService { - private func playerViewModel(_ playerViewModel: VideoPlayerViewModel, didUpdateTimeControlStatus: AVPlayer.TimeControlStatus) { - switch playerViewModel.videoKind { - case .gif: - // do nothing - return - case .video: - if playerViewModel.timeControlStatus.value != .paused { - latestPlayingVideoPlayerViewModel = playerViewModel - - // pause other player - for viewModel in viewPlayerViewModelDict.values { - guard viewModel.timeControlStatus.value != .paused else { continue } - guard viewModel !== playerViewModel else { continue } - viewModel.pause() - } - } else { - if latestPlayingVideoPlayerViewModel === playerViewModel { - latestPlayingVideoPlayerViewModel = nil - } - } - } - } -} - -extension VideoPlaybackService { - func dequeueVideoPlayerViewModel(for media: Attachment) -> VideoPlayerViewModel? { - // Core Data entity not thread-safe. Save attribute before enter working queue - guard let height = media.meta?.original?.height, - let width = media.meta?.original?.width, - let url = URL(string: media.url), - media.type == .gifv || media.type == .video - else { return nil } - - let previewImageURL = media.previewURL.flatMap { URL(string: $0) } - let videoKind: VideoPlayerViewModel.Kind = media.type == .gifv ? .gif : .video - - var _viewModel: VideoPlayerViewModel? - workingQueue.sync { - if let viewModel = viewPlayerViewModelDict[url] { - _viewModel = viewModel - } else { - let viewModel = VideoPlayerViewModel( - previewImageURL: previewImageURL, - videoURL: url, - videoSize: CGSize(width: width, height: height), - videoKind: videoKind - ) - viewPlayerViewModelDict[url] = viewModel - setupListener(for: viewModel) - _viewModel = viewModel - } - } - return _viewModel - } - - func playerViewModel(for playerViewController: AVPlayerViewController) -> VideoPlayerViewModel? { - guard let url = (playerViewController.player?.currentItem?.asset as? AVURLAsset)?.url else { return nil } - return viewPlayerViewModelDict[url] - } - - private func setupListener(for viewModel: VideoPlayerViewModel) { - viewModel.timeControlStatus - .sink { [weak self] timeControlStatus in - guard let self = self else { return } - self.playerViewModel(viewModel, didUpdateTimeControlStatus: timeControlStatus) - } - .store(in: &disposeBag) - - NotificationCenter.default.publisher(for: AudioPlaybackService.appWillPlayAudioNotification) - .sink { [weak self] _ in - guard let self = self else { return } - self.pauseWhenPlayAudio() - } - .store(in: &disposeBag) - } -} - -extension VideoPlaybackService { - func markTransitioning(for status: Status) { - guard let videoAttachment = status.mediaAttachments?.filter({ $0.type == .gifv || $0.type == .video }).first else { return } - guard let videoPlayerViewModel = dequeueVideoPlayerViewModel(for: videoAttachment) else { return } - videoPlayerViewModel.isTransitioning = true - } - - func viewDidDisappear(from viewController: UIViewController?) { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function) - - // note: do not retain view controller - // pause all player when view disappear exclude full screen player and other transitioning scene - for viewModel in viewPlayerViewModelDict.values { - guard !viewModel.isTransitioning else { - viewModel.isTransitioning = false - continue - } - guard !viewModel.isFullScreenPresentationing else { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: isFullScreenPresentationing", (#file as NSString).lastPathComponent, #line, #function) - continue - } - guard viewModel.videoKind == .video else { continue } - viewModel.pause() - } - } - - func pauseWhenPlayAudio() { - for viewModel in viewPlayerViewModelDict.values { - guard !viewModel.isTransitioning else { - viewModel.isTransitioning = false - continue - } - guard !viewModel.isFullScreenPresentationing else { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: isFullScreenPresentationing", (#file as NSString).lastPathComponent, #line, #function) - continue - } - viewModel.pause() - } - } -} diff --git a/Mastodon/State/AppContext.swift b/Mastodon/State/AppContext.swift index d7c08d47f..9de19c44f 100644 --- a/Mastodon/State/AppContext.swift +++ b/Mastodon/State/AppContext.swift @@ -25,9 +25,6 @@ class AppContext: ObservableObject { let apiService: APIService let authenticationService: AuthenticationService let emojiService: EmojiService - let audioPlaybackService = AudioPlaybackService() - let videoPlaybackService = VideoPlaybackService() - let statusPrefetchingService: StatusPrefetchingService let statusPublishService = StatusPublishService() let notificationService: NotificationService let settingService: SettingService @@ -71,11 +68,6 @@ class AppContext: ObservableObject { apiService: apiService ) - statusPrefetchingService = StatusPrefetchingService( - managedObjectContext: _managedObjectContext, - backgroundManagedObjectContext: _backgroundManagedObjectContext, - apiService: _apiService - ) let _notificationService = NotificationService( apiService: _apiService, authenticationService: _authenticationService diff --git a/Mastodon/Supporting Files/AppDelegate.swift b/Mastodon/Supporting Files/AppDelegate.swift index e2cb7c41b..7b1185f84 100644 --- a/Mastodon/Supporting Files/AppDelegate.swift +++ b/Mastodon/Supporting Files/AppDelegate.swift @@ -12,10 +12,6 @@ import AppShared import AVFoundation @_exported import MastodonUI -#if ASDK -import AsyncDisplayKit -#endif - @main class AppDelegate: UIResponder, UIApplicationDelegate { @@ -28,6 +24,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // configure appearance ThemeService.shared.apply(theme: ThemeService.shared.currentTheme.value) + // configure AudioSession + try? AVAudioSession.sharedInstance().setCategory(.ambient) + // Update app version info. See: `Settings.bundle` UserDefaults.standard.setValue(UIApplication.appVersion(), forKey: "Mastodon.appVersion") UserDefaults.standard.setValue(UIApplication.appBuild(), forKey: "Mastodon.appBundle") @@ -41,13 +40,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { count += 1 // Int64. could ignore overflow here UserDefaults.shared.processCompletedCount = count - #if ASDK && DEBUG - // PerformanceMonitor.shared().start() - // ASDisplayNode.shouldShowRangeDebugOverlay = true - // ASControlNode.enableHitTestDebug = true - // ASImageNode.shouldShowImageScalingOverlay = true - #endif - return true } @@ -98,19 +90,19 @@ extension AppDelegate: UNUserNotificationCenterDelegate { withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void ) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification]", ((#file as NSString).lastPathComponent), #line, #function) - guard let mastodonPushNotification = AppDelegate.mastodonPushNotification(from: notification) else { + guard let pushNotification = AppDelegate.mastodonPushNotification(from: notification) else { completionHandler([]) return } - let notificationID = String(mastodonPushNotification.notificationID) + let notificationID = String(pushNotification.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 + let accessToken = pushNotification.accessToken UserDefaults.shared.increaseNotificationCount(accessToken: accessToken) appContext.notificationService.applicationIconBadgeNeedsUpdate.send() - appContext.notificationService.handle(mastodonPushNotification: mastodonPushNotification) + appContext.notificationService.handle(pushNotification: pushNotification) completionHandler([.sound]) } @@ -122,15 +114,15 @@ extension AppDelegate: UNUserNotificationCenterDelegate { ) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification]", ((#file as NSString).lastPathComponent), #line, #function) - guard let mastodonPushNotification = AppDelegate.mastodonPushNotification(from: response.notification) else { + guard let pushNotification = AppDelegate.mastodonPushNotification(from: response.notification) else { completionHandler() return } - let notificationID = String(mastodonPushNotification.notificationID) + let notificationID = String(pushNotification.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(mastodonPushNotification) + appContext.notificationService.handle(pushNotification: pushNotification) + appContext.notificationService.requestRevealNotificationPublisher.send(pushNotification) completionHandler() } diff --git a/Mastodon/Supporting Files/SceneDelegate.swift b/Mastodon/Supporting Files/SceneDelegate.swift index 4809fe5f9..d178cd0d3 100644 --- a/Mastodon/Supporting Files/SceneDelegate.swift +++ b/Mastodon/Supporting Files/SceneDelegate.swift @@ -59,6 +59,14 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { sceneCoordinator.setup() sceneCoordinator.setupOnboardingIfNeeds(animated: false) window.makeKeyAndVisible() + + #if SNAPSHOT + // speedup animation + // window.layer.speed = 999 + + // disable animation + UIView.setAnimationsEnabled(false) + #endif if let shortcutItem = connectionOptions.shortcutItem { // Save it off for later when we become active. @@ -67,12 +75,20 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { UserDefaults.shared.observe(\.customUserInterfaceStyle, options: [.initial, .new]) { [weak self] defaults, _ in guard let self = self else { return } + #if SNAPSHOT + // toggle Dark Mode + // https://stackoverflow.com/questions/32988241/how-to-access-launchenvironment-and-launcharguments-set-in-xcuiapplication-runn + if ProcessInfo.processInfo.arguments.contains("UIUserInterfaceStyleForceDark") { + self.window?.overrideUserInterfaceStyle = .dark + } + #else self.window?.overrideUserInterfaceStyle = defaults.customUserInterfaceStyle + #endif } .store(in: &observations) #if DEBUG - fpsIndicator = FPSIndicator(windowScene: windowScene) + // fpsIndicator = FPSIndicator(windowScene: windowScene) #endif } @@ -113,7 +129,6 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { // Called as the scene transitions from the foreground to the background. // Use this method to save data, release shared resources, and store enough scene-specific state information // to restore the scene back to its current state. - AppContext.shared.audioPlaybackService.pauseIfNeed() } } @@ -131,12 +146,16 @@ extension SceneDelegate { if coordinator?.tabBarController.topMost is ComposeViewController { logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): composing…") } else { - if AppContext.shared.authenticationService.activeMastodonAuthenticationBox.value == nil { - logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): not authenticated") - } else { - let composeViewModel = ComposeViewModel(context: AppContext.shared, composeKind: .post) + if let authenticationBox = AppContext.shared.authenticationService.activeMastodonAuthenticationBox.value { + let composeViewModel = ComposeViewModel( + context: AppContext.shared, + composeKind: .post, + authenticationBox: authenticationBox + ) coordinator?.present(scene: .compose(viewModel: composeViewModel), from: nil, transition: .modal(animated: true, completion: nil)) logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): present compose scene") + } else { + logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): not authenticated") } } case "org.joinmastodon.app.search": diff --git a/Mastodon/Template/AutoGenerateProtocolDelegate.swift b/Mastodon/Template/AutoGenerateProtocolDelegate.swift new file mode 100644 index 000000000..421abab8c --- /dev/null +++ b/Mastodon/Template/AutoGenerateProtocolDelegate.swift @@ -0,0 +1,10 @@ +// +// AutoGenerateProtocolDelegate.swift +// Mastodon +// +// Created by MainasuK on 2022-1-13. +// + +import Foundation + +protocol AutoGenerateProtocolDelegate { } diff --git a/Mastodon/Template/AutoGenerateProtocolDelegate.swifttemplate b/Mastodon/Template/AutoGenerateProtocolDelegate.swifttemplate new file mode 100644 index 000000000..47eb4ce19 --- /dev/null +++ b/Mastodon/Template/AutoGenerateProtocolDelegate.swifttemplate @@ -0,0 +1,11 @@ +<% for type in types.implementing["AutoGenerateProtocolDelegate"] { + guard let replaceOf = type.annotations["replaceOf"] as? String else { continue } + guard let replaceWith = type.annotations["replaceWith"] as? String else { continue } + guard let protocolToGenerate = type.annotations["protocolName"] as? String else { continue } + guard let aProtocol = types.protocols.first(where: { $0.name == protocolToGenerate }) else { continue } -%> +// sourcery:inline:<%= type.name %>.AutoGenerateProtocolDelegate +<% for method in aProtocol.methods { -%> +<%= method.name.replacingOccurrences(of: replaceOf, with: replaceWith) %> +<% } -%> +// sourcery:end +<% } %> diff --git a/Mastodon/Template/AutoGenerateProtocolRelayDelegate.swift b/Mastodon/Template/AutoGenerateProtocolRelayDelegate.swift new file mode 100644 index 000000000..585eb0074 --- /dev/null +++ b/Mastodon/Template/AutoGenerateProtocolRelayDelegate.swift @@ -0,0 +1,10 @@ +// +// AutoGenerateProtocolRelayDelegate.swift +// Mastodon +// +// Created by MainasuK on 2022-1-13. +// + +import Foundation + +protocol AutoGenerateProtocolRelayDelegate { } diff --git a/Mastodon/Template/AutoGenerateProtocolRelayDelegate.swifttemplate b/Mastodon/Template/AutoGenerateProtocolRelayDelegate.swifttemplate new file mode 100644 index 000000000..b57f26038 --- /dev/null +++ b/Mastodon/Template/AutoGenerateProtocolRelayDelegate.swifttemplate @@ -0,0 +1,51 @@ +<% +func methodDeclaration(_ method: SourceryRuntime.Method) -> String { + var result = method.name + if method.throws { + result = result + " throws" + } else if method.rethrows { + result = result + " rethrows" + } + return result + " -> \(method.returnTypeName)" +} +-%> +<%# Constructs method call string passing in parameters with their local names -%> +<% +func methodCall( + _ method: SourceryRuntime.Method, + replaceOf: String, + replaceWith: String +) -> String { + let params = method.parameters.map({ + if let label = $0.argumentLabel { + return "\(label): \($0.name)" + } else { + return $0.name + } + }).joined(separator: ", ") + var result = "\(method.callName)(\(params))" + + if method.throws { + result = "try " + result + } + if !method.returnTypeName.isVoid { + result = "return " + result + } + result = result.replacingOccurrences(of: replaceOf, with: replaceWith) + return result +} +-%> +<% for type in types.implementing["AutoGenerateProtocolRelayDelegate"] { + guard let replaceOf = type.annotations["replaceOf"] as? String else { continue } + guard let replaceWith = type.annotations["replaceWith"] as? String else { continue } + guard let protocolToGenerate = type.annotations["protocolName"] as? String else { continue } + guard let aProtocol = types.protocols.first(where: { $0.name == protocolToGenerate }) else { continue } -%> +// sourcery:inline:<%= type.name %>.AutoGenerateProtocolRelayDelegate +<% for method in aProtocol.methods { -%> +func <%= method.name -%> { + <%= methodCall(method, replaceOf: replaceOf, replaceWith: replaceWith) %> +} + +<% } -%> +// sourcery:end +<% } %> diff --git a/Mastodon/Template/AutoGenerateTableViewDelegate.stencil b/Mastodon/Template/AutoGenerateTableViewDelegate.stencil new file mode 100644 index 000000000..68516a762 --- /dev/null +++ b/Mastodon/Template/AutoGenerateTableViewDelegate.stencil @@ -0,0 +1,29 @@ +{% for type in types.implementing.AutoGenerateTableViewDelegate %} +// sourcery:inline:{{type.name}}.AutoGenerateTableViewDelegate + +// Generated using Sourcery +// DO NOT EDIT +func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + aspectTableView(tableView, didSelectRowAt: indexPath) +} + +{% if type.based.MediaPreviewableViewController %} +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) +} +{% endif %} +// sourcery:end +{% endfor %} + diff --git a/Mastodon/Template/AutoGenerateTableViewDelegate.swift b/Mastodon/Template/AutoGenerateTableViewDelegate.swift new file mode 100644 index 000000000..6110535cd --- /dev/null +++ b/Mastodon/Template/AutoGenerateTableViewDelegate.swift @@ -0,0 +1,10 @@ +// +// AutoGenerateTableViewDelegate.swift +// Mastodon +// +// Created by MainasuK on 2022-1-13. +// + +import Foundation + +protocol AutoGenerateTableViewDelegate { } diff --git a/Mastodon/Vender/ActivityIndicatorNode.swift b/Mastodon/Vender/ActivityIndicatorNode.swift deleted file mode 100644 index 8778f5ec2..000000000 --- a/Mastodon/Vender/ActivityIndicatorNode.swift +++ /dev/null @@ -1,75 +0,0 @@ -// ref: https://github.com/Adlai-Holler/ASDKPlaceholderTest/blob/eea9fa7cff2d16a57efb47d208422ea9b49a630a/ASDKPlaceholderTest/ASDisplayNodeSubclasses.swift - -#if ASDK - -import Foundation -import AsyncDisplayKit -import UIKit - -/** - A node that shows a `UIActivityIndicatorView`. Does not support layer backing. - Note: You must not change the style to or from `.WhiteLarge` after init, or the node's size will not update. - */ -class ActivityIndicatorNode: ASDisplayNode { - - private static let defaultSize = CGSize(width: 20, height: 20) - private static let largeSize = CGSize(width: 37, height: 37) - - init(style: UIActivityIndicatorView.Style = .medium) { - super.init() - setViewBlock { - UIActivityIndicatorView(style: style) - } - - self.style.preferredSize = style == .large ? ActivityIndicatorNode.defaultSize : ActivityIndicatorNode.largeSize - } - - var activityIndicatorView: UIActivityIndicatorView { - return view as! UIActivityIndicatorView - } - - override func didLoad() { - super.didLoad() - if animating { - activityIndicatorView.startAnimating() - } - activityIndicatorView.color = color - activityIndicatorView.hidesWhenStopped = hidesWhenStopped - } - - /// Wrapper for `UIActivityIndicatorView.hidesWhenStopped`. NOTE: You must respect thread affinity. - var hidesWhenStopped = true { - didSet { - if isNodeLoaded { - assert(Thread.isMainThread) - activityIndicatorView.hidesWhenStopped = hidesWhenStopped - } - } - } - - /// Wrapper for `UIActivityIndicatorView.color`. NOTE: You must respect thread affinity. - var color: UIColor? { - didSet { - if isNodeLoaded { - assert(Thread.isMainThread) - activityIndicatorView.color = color - } - } - } - - /// Wrapper for `UIActivityIndicatorView.animating`. NOTE: You must respect thread affinity. - var animating = false { - didSet { - if isNodeLoaded { - assert(Thread.isMainThread) - if animating { - activityIndicatorView.startAnimating() - } else { - activityIndicatorView.stopAnimating() - } - } - } - } -} - -#endif diff --git a/MastodonIntent/Info.plist b/MastodonIntent/Info.plist index 8ac3d165b..02385f4e6 100644 --- a/MastodonIntent/Info.plist +++ b/MastodonIntent/Info.plist @@ -17,9 +17,9 @@ <key>CFBundlePackageType</key> <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> <key>CFBundleShortVersionString</key> - <string>1.2.0</string> + <string>1.3.0</string> <key>CFBundleVersion</key> - <string>88</string> + <string>109</string> <key>NSExtension</key> <dict> <key>NSExtensionAttributes</key> diff --git a/MastodonIntent/SendPostIntentHandler.swift b/MastodonIntent/SendPostIntentHandler.swift index 75e7049aa..1ad843088 100644 --- a/MastodonIntent/SendPostIntentHandler.swift +++ b/MastodonIntent/SendPostIntentHandler.swift @@ -32,6 +32,7 @@ final class SendPostIntentHandler: NSObject, SendPostIntentHandling { } let box = MastodonAuthenticationBox( + authenticationRecord: .init(objectID: authentication.objectID), domain: authentication.domain, userID: authentication.userID, appAuthorization: .init(accessToken: authentication.appAccessToken), @@ -58,28 +59,31 @@ final class SendPostIntentHandler: NSObject, SendPostIntentHandling { let idempotencyKey = UUID().uuidString - APIService.shared.publishStatus( - domain: box.domain, - idempotencyKey: idempotencyKey, - query: query, - mastodonAuthenticationBox: box - ) - .sink { _completion in - switch _completion { - case .failure(let error): - let failureReason = error.localizedDescription - completion(SendPostIntentResponse.failure(failureReason: failureReason)) - case .finished: - break + Just(Void()) + .asyncMap { + try await APIService.shared.publishStatus( + domain: box.domain, + idempotencyKey: idempotencyKey, + query: query, + authenticationBox: box + ) } - } receiveValue: { response in - let post = Post(identifier: response.value.id, display: intent.content ?? "") - post.url = URL(string: response.value.url ?? response.value.uri) - let result = SendPostIntentResponse(code: .success, userActivity: nil) - result.post = post - completion(result) - } - .store(in: &disposeBag) + .sink { _completion in + switch _completion { + case .failure(let error): + let failureReason = error.localizedDescription + completion(SendPostIntentResponse.failure(failureReason: failureReason)) + case .finished: + break + } + } receiveValue: { response in + let post = Post(identifier: response.value.id, display: intent.content ?? "") + post.url = URL(string: response.value.url ?? response.value.uri) + let result = SendPostIntentResponse(code: .success, userActivity: nil) + result.post = post + completion(result) + } + .store(in: &disposeBag) } } diff --git a/MastodonIntent/eu-ES.lproj/Intents.strings b/MastodonIntent/eu-ES.lproj/Intents.strings new file mode 100644 index 000000000..dbc27c1cf --- /dev/null +++ b/MastodonIntent/eu-ES.lproj/Intents.strings @@ -0,0 +1,51 @@ +"16wxgf" = "Argitaratu Mastodonen"; + +"751xkl" = "Testu-edukia"; + +"CsR7G2" = "Argitaratu Mastodonen"; + +"HZSGTr" = "Ze eduki argitaratu?"; + +"HdGikU" = "Argitaratzeak huts egin du"; + +"KDNTJ4" = "Hutsegitearen arrazoia"; + +"RHxKOw" = "Argitaratu bidalketa testu-edukiarekin"; + +"RxSqsb" = "Bidali"; + +"WCIR3D" = "Argitaratu ${content} Mastodonen"; + +"ZKJSNu" = "Bidali"; + +"ZS1XaK" = "${content}"; + +"ZbSjzC" = "Ikusgaitasuna"; + +"Zo4jgJ" = "Bidalketaren ikusgaitasuna"; + +"apSxMG-dYQ5NN" = "'Publikoa'-rekin bat datozen ${count} aukera daude."; + +"apSxMG-ehFLjY" = "'Jarraitzaileak soilik'-ekin bat datozen ${count} aukera daude."; + +"ayoYEb-dYQ5NN" = "${content}, publikoa"; + +"ayoYEb-ehFLjY" = "${content}, jarraitzaileak besterik ez"; + +"dUyuGg" = "Argitaratu Mastodonen"; + +"dYQ5NN" = "Publikoa"; + +"ehFLjY" = "Jarraitzaileak soilik"; + +"gfePDu" = "Argitaratzeak huts egin du. ${failureReason}"; + +"k7dbKQ" = "Bidalketa behar bezala bidali da."; + +"oGiqmY-dYQ5NN" = "Berresteagatik, 'Publikoa' izatea nahi duzu?"; + +"oGiqmY-ehFLjY" = "Berresteagatik, 'Jarraitzaileak soilik' izatea nahi duzu?"; + +"rM6dvp" = "URLa"; + +"ryJLwG" = "Bidalketa behar bezala bidali da. "; diff --git a/MastodonIntent/ku-TR.lproj/Intents.stringsdict b/MastodonIntent/eu-ES.lproj/Intents.stringsdict similarity index 100% rename from MastodonIntent/ku-TR.lproj/Intents.stringsdict rename to MastodonIntent/eu-ES.lproj/Intents.stringsdict diff --git a/MastodonIntent/fr.lproj/Intents.strings b/MastodonIntent/fr.lproj/Intents.strings index f4fec3000..2703edd42 100644 --- a/MastodonIntent/fr.lproj/Intents.strings +++ b/MastodonIntent/fr.lproj/Intents.strings @@ -12,7 +12,7 @@ "RHxKOw" = "Envoyer une publication avec du contenu texte"; -"RxSqsb" = "Post"; +"RxSqsb" = "Publication"; "WCIR3D" = "Publier du ${content} sur Mastodon"; @@ -24,9 +24,9 @@ "Zo4jgJ" = "Visibilité de la publication"; -"apSxMG-dYQ5NN" = "There are ${count} options matching ‘Public’."; +"apSxMG-dYQ5NN" = "Il y a ${count} options correspondant à « Public »."; -"apSxMG-ehFLjY" = "There are ${count} options matching ‘Followers Only’."; +"apSxMG-ehFLjY" = "Il y a ${count} options correspondant à « Abonnés uniquement »."; "ayoYEb-dYQ5NN" = "${content}, Public"; diff --git a/MastodonIntent/ja.lproj/Intents.strings b/MastodonIntent/ja.lproj/Intents.strings index 6877490ba..411b35c2e 100644 --- a/MastodonIntent/ja.lproj/Intents.strings +++ b/MastodonIntent/ja.lproj/Intents.strings @@ -1,51 +1,51 @@ -"16wxgf" = "Post on Mastodon"; +"16wxgf" = "Mastodonに投稿"; -"751xkl" = "Text Content"; +"751xkl" = "テキストコンテンツ"; -"CsR7G2" = "Post on Mastodon"; +"CsR7G2" = "Mastodonに投稿"; "HZSGTr" = "What content to post?"; -"HdGikU" = "Posting failed"; +"HdGikU" = "投稿に失敗しました"; -"KDNTJ4" = "Failure Reason"; +"KDNTJ4" = "失敗の理由"; "RHxKOw" = "Send Post with text content"; -"RxSqsb" = "Post"; +"RxSqsb" = "投稿"; -"WCIR3D" = "Post ${content} on Mastodon"; +"WCIR3D" = "Mastodonに ${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" = "Mastodonに投稿"; -"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"; -"ryJLwG" = "Post was sent successfully. "; +"ryJLwG" = "投稿に成功しました。 "; diff --git a/MastodonIntent/ku-TR.lproj/Intents.strings b/MastodonIntent/ku.lproj/Intents.strings similarity index 100% rename from MastodonIntent/ku-TR.lproj/Intents.strings rename to MastodonIntent/ku.lproj/Intents.strings diff --git a/MastodonIntent/ku.lproj/Intents.stringsdict b/MastodonIntent/ku.lproj/Intents.stringsdict new file mode 100644 index 000000000..5a39d5e64 --- /dev/null +++ b/MastodonIntent/ku.lproj/Intents.stringsdict @@ -0,0 +1,54 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>There are ${count} options matching ‘${content}’. - 2</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>There are %#@count_option@ matching ‘${content}’.</string> + <key>count_option</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>%ld</string> + <key>zero</key> + <string>0 options</string> + <key>one</key> + <string>1 option</string> + <key>two</key> + <string>2 options</string> + <key>few</key> + <string>%ld options</string> + <key>many</key> + <string>%ld options</string> + <key>other</key> + <string>%ld options</string> + </dict> + </dict> + <key>There are ${count} options matching ‘${visibility}’.</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>There are %#@count_option@ matching ‘${visibility}’.</string> + <key>count_option</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>%ld</string> + <key>zero</key> + <string>0 options</string> + <key>one</key> + <string>1 option</string> + <key>two</key> + <string>2 options</string> + <key>few</key> + <string>%ld options</string> + <key>many</key> + <string>%ld options</string> + <key>other</key> + <string>%ld options</string> + </dict> + </dict> +</dict> +</plist> diff --git a/MastodonIntent/sv-FI.lproj/Intents.strings b/MastodonIntent/sv-FI.lproj/Intents.strings new file mode 100644 index 000000000..b85bec4c5 --- /dev/null +++ b/MastodonIntent/sv-FI.lproj/Intents.strings @@ -0,0 +1,52 @@ +"16wxgf" = "Post on Mastodon"; + +"751xkl" = "Text Content"; + +"CsR7G2" = "Post on Mastodon"; + +"HZSGTr" = "What content to post?"; + +"HdGikU" = "Posting failed"; + +"KDNTJ4" = "Failure Reason"; + +"RHxKOw" = "Send Post with text content"; + +"RxSqsb" = "Post"; + +"WCIR3D" = "Post ${content} on Mastodon"; + +"ZKJSNu" = "Post"; + +"ZS1XaK" = "${content}"; + +"ZbSjzC" = "Visibility"; + +"Zo4jgJ" = "Post Visibility"; + +"apSxMG-dYQ5NN" = "There are ${count} options matching ‘Public’."; + +"apSxMG-ehFLjY" = "There are ${count} options matching ‘Followers Only’."; + +"ayoYEb-dYQ5NN" = "${content}, Public"; + +"ayoYEb-ehFLjY" = "${content}, Followers Only"; + +"dUyuGg" = "Post on Mastodon"; + +"dYQ5NN" = "Public"; + +"ehFLjY" = "Followers Only"; + +"gfePDu" = "Posting failed. ${failureReason}"; + +"k7dbKQ" = "Post was sent successfully."; + +"oGiqmY-dYQ5NN" = "Just to confirm, you wanted ‘Public’?"; + +"oGiqmY-ehFLjY" = "Just to confirm, you wanted ‘Followers Only’?"; + +"rM6dvp" = "URL"; + +"ryJLwG" = "Post was sent successfully."; + diff --git a/MastodonIntent/sv-FI.lproj/Intents.stringsdict b/MastodonIntent/sv-FI.lproj/Intents.stringsdict new file mode 100644 index 000000000..5a39d5e64 --- /dev/null +++ b/MastodonIntent/sv-FI.lproj/Intents.stringsdict @@ -0,0 +1,54 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>There are ${count} options matching ‘${content}’. - 2</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>There are %#@count_option@ matching ‘${content}’.</string> + <key>count_option</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>%ld</string> + <key>zero</key> + <string>0 options</string> + <key>one</key> + <string>1 option</string> + <key>two</key> + <string>2 options</string> + <key>few</key> + <string>%ld options</string> + <key>many</key> + <string>%ld options</string> + <key>other</key> + <string>%ld options</string> + </dict> + </dict> + <key>There are ${count} options matching ‘${visibility}’.</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>There are %#@count_option@ matching ‘${visibility}’.</string> + <key>count_option</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>%ld</string> + <key>zero</key> + <string>0 options</string> + <key>one</key> + <string>1 option</string> + <key>two</key> + <string>2 options</string> + <key>few</key> + <string>%ld options</string> + <key>many</key> + <string>%ld options</string> + <key>other</key> + <string>%ld options</string> + </dict> + </dict> +</dict> +</plist> diff --git a/MastodonIntent/sv_FI.lproj/Intents.strings b/MastodonIntent/sv_FI.lproj/Intents.strings new file mode 100644 index 000000000..1be213d45 --- /dev/null +++ b/MastodonIntent/sv_FI.lproj/Intents.strings @@ -0,0 +1,51 @@ +"16wxgf" = "Julkaise Mastodonissa"; + +"751xkl" = "Tekstisisältö"; + +"CsR7G2" = "Julkaise Mastodonissa"; + +"HZSGTr" = "Mitä sisältöä julkaista?"; + +"HdGikU" = "Julkaiseminen epäonnistui"; + +"KDNTJ4" = "Epäonnistumisen syy"; + +"RHxKOw" = "Lähetä julkaisu teksisisällöllä"; + +"RxSqsb" = "Julkaisu"; + +"WCIR3D" = "Julkaise ${content} Mastodonissa"; + +"ZKJSNu" = "Julkaisu"; + +"ZS1XaK" = "${content}"; + +"ZbSjzC" = "Näkyvyys"; + +"Zo4jgJ" = "Julkaisun näkyvyys"; + +"apSxMG-dYQ5NN" = "On ${count} vaihtoehtoa, jotka vastaavat ‘Julkinen’."; + +"apSxMG-ehFLjY" = "On ${count} vaihtoehtoa, jotka vastaavat ‘Vain seuraajat’."; + +"ayoYEb-dYQ5NN" = "${content}, julkinen"; + +"ayoYEb-ehFLjY" = "${content}, vain seuraajat"; + +"dUyuGg" = "Julkaise Mastodonissa"; + +"dYQ5NN" = "Julkinen"; + +"ehFLjY" = "Vain seuraajat"; + +"gfePDu" = "Julkaiseminen epäonnistui. ${failureReason}"; + +"k7dbKQ" = "Julkaisu lähetettiin onnistuneesti."; + +"oGiqmY-dYQ5NN" = "Vahvitukseksi, halusit ‘Julkinen’?"; + +"oGiqmY-ehFLjY" = "Vahvitstukseksi, halusit ‘Vain seuraajat’?"; + +"rM6dvp" = "URL"; + +"ryJLwG" = "Julkaisu lähetettiin onnistuneesti. "; diff --git a/MastodonSDK/Package.swift b/MastodonSDK/Package.swift index ef5f93131..af27091fa 100644 --- a/MastodonSDK/Package.swift +++ b/MastodonSDK/Package.swift @@ -5,24 +5,36 @@ import PackageDescription let package = Package( name: "MastodonSDK", + defaultLocalization: "en", platforms: [ .iOS(.v14), ], products: [ .library( name: "MastodonSDK", - targets: ["MastodonSDK"]), + targets: [ + "CoreDataStack", + "MastodonAsset", + "MastodonCommon", + "MastodonExtension", + "MastodonLocalization", + "MastodonSDK", + "MastodonUI", + ]), .library( - name: "MastodonUI", - targets: ["MastodonUI"]), - .library( - name: "MastodonExtension", - targets: ["MastodonExtension"]), + name: "MastodonCommon", + targets: [ + "MastodonCommon", + ]), ], dependencies: [ .package(url: "https://github.com/SwiftyJSON/SwiftyJSON.git", from: "5.0.0"), .package(url: "https://github.com/apple/swift-nio.git", from: "1.0.0"), .package(url: "https://github.com/kean/Nuke.git", from: "10.3.1"), + .package(url: "https://github.com/Flipboard/FLAnimatedImage.git", from: "1.0.0"), + .package(url: "https://github.com/TwidereProject/MetaTextKit.git", .exact("2.2.1")), + .package(url: "https://github.com/Alamofire/Alamofire.git", from: "5.4.0"), + .package(url: "https://github.com/Alamofire/AlamofireImage.git", from: "4.1.0"), .package(name: "NukeFLAnimatedImagePlugin", url: "https://github.com/kean/Nuke-FLAnimatedImage-Plugin.git", from: "8.0.0"), .package(name: "UITextView+Placeholder", url: "https://github.com/MainasuK/UITextView-Placeholder.git", from: "1.4.1"), .package(name: "Introspect", url: "https://github.com/siteline/SwiftUI-Introspect.git", from: "0.1.3") @@ -30,6 +42,34 @@ let package = Package( targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. // Targets can depend on other targets in this package, and on products in packages this package depends on. + .target( + name: "CoreDataStack", + dependencies: [ + "MastodonCommon", + ], + exclude: [ + "Template/Stencil" + ] + ), + .target( + name: "MastodonAsset", + dependencies: [], + resources: [ + .process("Font"), + ] + ), + .target( + name: "MastodonCommon", + dependencies: [] + ), + .target( + name: "MastodonExtension", + dependencies: [] + ), + .target( + name: "MastodonLocalization", + dependencies: [] + ), .target( name: "MastodonSDK", dependencies: [ @@ -40,18 +80,21 @@ let package = Package( .target( name: "MastodonUI", dependencies: [ + "CoreDataStack", "MastodonSDK", "MastodonExtension", + "MastodonAsset", + "MastodonLocalization", "Nuke", "NukeFLAnimatedImagePlugin", "UITextView+Placeholder", "Introspect", + .product(name: "Alamofire", package: "Alamofire"), + .product(name: "AlamofireImage", package: "AlamofireImage"), + .product(name: "MetaTextKit", package: "MetaTextKit"), + .product(name: "FLAnimatedImage", package: "FLAnimatedImage"), ] ), - .target( - name: "MastodonExtension", - dependencies: [] - ), .testTarget( name: "MastodonSDKTests", dependencies: ["MastodonSDK"] diff --git a/MastodonSDK/Sources/CoreDataStack/.sourcery.yml b/MastodonSDK/Sources/CoreDataStack/.sourcery.yml new file mode 100644 index 000000000..ac3ddce88 --- /dev/null +++ b/MastodonSDK/Sources/CoreDataStack/.sourcery.yml @@ -0,0 +1,6 @@ +sources: + - . +templates: + - ./Template +output: + Generated \ No newline at end of file diff --git a/CoreDataStack/CoreData.xcdatamodeld/.xccurrentversion b/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/.xccurrentversion similarity index 84% rename from CoreDataStack/CoreData.xcdatamodeld/.xccurrentversion rename to MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/.xccurrentversion index 3d5e5761c..cdd244c9c 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/.xccurrentversion +++ b/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/.xccurrentversion @@ -3,6 +3,6 @@ <plist version="1.0"> <dict> <key>_XCCurrentVersionName</key> - <string>CoreData 2.xcdatamodel</string> + <string>CoreData 3.xcdatamodel</string> </dict> </plist> diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData 2.xcdatamodel/contents b/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/CoreData 2.xcdatamodel/contents similarity index 99% rename from CoreDataStack/CoreData.xcdatamodeld/CoreData 2.xcdatamodel/contents rename to MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/CoreData 2.xcdatamodel/contents index 6d576ca15..f4a7b8016 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData 2.xcdatamodel/contents +++ b/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/CoreData 2.xcdatamodel/contents @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="UTF-8" standalone="yes"?> -<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="19206" systemVersion="20G165" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier=""> +<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="19574" systemVersion="21C52" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier=""> <entity name="Application" representedClassName=".Application" syncable="YES"> <attribute name="identifier" optional="YES" attributeType="UUID" usesScalarValueType="NO"/> <attribute name="name" attributeType="String"/> diff --git a/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/CoreData 3.xcdatamodel/contents b/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/CoreData 3.xcdatamodel/contents new file mode 100644 index 000000000..a6f0ee0ce --- /dev/null +++ b/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/CoreData 3.xcdatamodel/contents @@ -0,0 +1,270 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="19574" systemVersion="21D62" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier=""> + <entity name="Application" representedClassName="CoreDataStack.Application" syncable="YES"> + <attribute name="identifier" optional="YES" attributeType="UUID" usesScalarValueType="NO"/> + <attribute name="name" attributeType="String"/> + <attribute name="vapidKey" optional="YES" attributeType="String"/> + <attribute name="website" optional="YES" attributeType="String"/> + <relationship name="status" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="application" inverseEntity="Status"/> + </entity> + <entity name="DomainBlock" representedClassName="CoreDataStack.DomainBlock" syncable="YES"> + <attribute name="blockedDomain" attributeType="String"/> + <attribute name="createAt" attributeType="Date" usesScalarValueType="NO"/> + <attribute name="domain" attributeType="String"/> + <attribute name="userID" attributeType="String"/> + <uniquenessConstraints> + <uniquenessConstraint> + <constraint value="userID"/> + <constraint value="domain"/> + <constraint value="blockedDomain"/> + </uniquenessConstraint> + </uniquenessConstraints> + </entity> + <entity name="Emoji" representedClassName="CoreDataStack.Emoji" syncable="YES"> + <attribute name="category" optional="YES" attributeType="String"/> + <attribute name="createAt" attributeType="Date" defaultDateTimeInterval="631123200" usesScalarValueType="NO"/> + <attribute name="identifier" attributeType="UUID" usesScalarValueType="NO"/> + <attribute name="shortcode" attributeType="String"/> + <attribute name="staticURL" attributeType="String"/> + <attribute name="url" attributeType="String"/> + <attribute name="visibleInPicker" attributeType="Boolean" usesScalarValueType="YES"/> + </entity> + <entity name="Feed" representedClassName="CoreDataStack.Feed" syncable="YES"> + <attribute name="acctRaw" optional="YES" attributeType="String"/> + <attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/> + <attribute name="hasMore" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/> + <attribute name="isLoadingMore" transient="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> + <attribute name="kindRaw" attributeType="String"/> + <attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/> + <relationship name="notification" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Notification" inverseName="feeds" inverseEntity="Notification"/> + <relationship name="status" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="feeds" inverseEntity="Status"/> + </entity> + <entity name="Instance" representedClassName="CoreDataStack.Instance" syncable="YES"> + <attribute name="configurationRaw" optional="YES" attributeType="Binary"/> + <attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/> + <attribute name="domain" attributeType="String"/> + <attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/> + <relationship name="authentications" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonAuthentication" inverseName="instance" inverseEntity="MastodonAuthentication"/> + </entity> + <entity name="MastodonAuthentication" representedClassName="CoreDataStack.MastodonAuthentication" syncable="YES"> + <attribute name="activedAt" attributeType="Date" usesScalarValueType="NO"/> + <attribute name="appAccessToken" attributeType="String"/> + <attribute name="clientID" attributeType="String"/> + <attribute name="clientSecret" attributeType="String"/> + <attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/> + <attribute name="domain" attributeType="String"/> + <attribute name="identifier" attributeType="UUID" usesScalarValueType="NO"/> + <attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/> + <attribute name="userAccessToken" attributeType="String"/> + <attribute name="userID" attributeType="String"/> + <attribute name="username" attributeType="String"/> + <relationship name="instance" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Instance" inverseName="authentications" inverseEntity="Instance"/> + <relationship name="user" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="mastodonAuthentication" inverseEntity="MastodonUser"/> + </entity> + <entity name="MastodonUser" representedClassName="CoreDataStack.MastodonUser" syncable="YES"> + <attribute name="acct" attributeType="String"/> + <attribute name="avatar" attributeType="String"/> + <attribute name="avatarStatic" optional="YES" attributeType="String"/> + <attribute name="bot" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/> + <attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/> + <attribute name="displayName" attributeType="String"/> + <attribute name="domain" attributeType="String"/> + <attribute name="emojis" optional="YES" attributeType="Binary"/> + <attribute name="fields" optional="YES" attributeType="Binary"/> + <attribute name="followersCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/> + <attribute name="followingCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/> + <attribute name="header" attributeType="String"/> + <attribute name="headerStatic" optional="YES" attributeType="String"/> + <attribute name="id" attributeType="String"/> + <attribute name="identifier" attributeType="String"/> + <attribute name="locked" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> + <attribute name="note" optional="YES" attributeType="String"/> + <attribute name="statusesCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/> + <attribute name="suspended" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> + <attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/> + <attribute name="url" optional="YES" attributeType="String"/> + <attribute name="username" attributeType="String"/> + <relationship name="blocking" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="blockingBy" inverseEntity="MastodonUser"/> + <relationship name="blockingBy" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="blocking" inverseEntity="MastodonUser"/> + <relationship name="bookmarked" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="bookmarkedBy" inverseEntity="Status"/> + <relationship name="domainBlocking" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="domainBlockingBy" inverseEntity="MastodonUser"/> + <relationship name="domainBlockingBy" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="domainBlocking" inverseEntity="MastodonUser"/> + <relationship name="endorsed" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="endorsedBy" inverseEntity="MastodonUser"/> + <relationship name="endorsedBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="endorsed" inverseEntity="MastodonUser"/> + <relationship name="favourite" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="favouritedBy" inverseEntity="Status"/> + <relationship name="following" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="followingBy" inverseEntity="MastodonUser"/> + <relationship name="followingBy" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="following" inverseEntity="MastodonUser"/> + <relationship name="followRequested" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="followRequestedBy" inverseEntity="MastodonUser"/> + <relationship name="followRequestedBy" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="followRequested" inverseEntity="MastodonUser"/> + <relationship name="mastodonAuthentication" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonAuthentication" inverseName="user" inverseEntity="MastodonAuthentication"/> + <relationship name="muted" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="mutedBy" inverseEntity="Status"/> + <relationship name="muting" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="mutingBy" inverseEntity="MastodonUser"/> + <relationship name="mutingBy" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="muting" inverseEntity="MastodonUser"/> + <relationship name="notifications" toMany="YES" deletionRule="Nullify" destinationEntity="Notification" inverseName="account" inverseEntity="Notification"/> + <relationship name="pinnedStatus" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="pinnedBy" inverseEntity="Status"/> + <relationship name="privateNotes" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="PrivateNote" inverseName="to" inverseEntity="PrivateNote"/> + <relationship name="privateNotesTo" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="PrivateNote" inverseName="from" inverseEntity="PrivateNote"/> + <relationship name="reblogged" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="rebloggedBy" inverseEntity="Status"/> + <relationship name="searchHistories" toMany="YES" deletionRule="Nullify" destinationEntity="SearchHistory" inverseName="account" inverseEntity="SearchHistory"/> + <relationship name="statuses" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="author" inverseEntity="Status"/> + <relationship name="votePollOptions" toMany="YES" deletionRule="Nullify" destinationEntity="PollOption" inverseName="votedBy" inverseEntity="PollOption"/> + <relationship name="votePolls" toMany="YES" deletionRule="Nullify" destinationEntity="Poll" inverseName="votedBy" inverseEntity="Poll"/> + </entity> + <entity name="Notification" representedClassName="CoreDataStack.Notification" syncable="YES"> + <attribute name="createAt" attributeType="Date" usesScalarValueType="NO"/> + <attribute name="domain" attributeType="String"/> + <attribute name="id" attributeType="String"/> + <attribute name="typeRaw" attributeType="String"/> + <attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/> + <attribute name="userID" attributeType="String"/> + <relationship name="account" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="notifications" inverseEntity="MastodonUser"/> + <relationship name="feeds" toMany="YES" deletionRule="Cascade" destinationEntity="Feed" inverseName="notification" inverseEntity="Feed"/> + <relationship name="status" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="notifications" inverseEntity="Status"/> + </entity> + <entity name="Poll" representedClassName="CoreDataStack.Poll" syncable="YES"> + <attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/> + <attribute name="domain" attributeType="String" defaultValueString=""/> + <attribute name="expired" attributeType="Boolean" usesScalarValueType="YES"/> + <attribute name="expiresAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> + <attribute name="id" attributeType="String"/> + <attribute name="isVoting" transient="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> + <attribute name="multiple" attributeType="Boolean" usesScalarValueType="YES"/> + <attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/> + <attribute name="votersCount" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/> + <attribute name="votesCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/> + <relationship name="options" toMany="YES" deletionRule="Cascade" destinationEntity="PollOption" inverseName="poll" inverseEntity="PollOption"/> + <relationship name="status" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="poll" inverseEntity="Status"/> + <relationship name="votedBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="votePolls" inverseEntity="MastodonUser"/> + </entity> + <entity name="PollOption" representedClassName="CoreDataStack.PollOption" syncable="YES"> + <attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/> + <attribute name="index" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/> + <attribute name="isSelected" transient="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> + <attribute name="title" attributeType="String"/> + <attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/> + <attribute name="votesCount" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/> + <relationship name="poll" maxCount="1" deletionRule="Nullify" destinationEntity="Poll" inverseName="options" inverseEntity="Poll"/> + <relationship name="votedBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="votePollOptions" inverseEntity="MastodonUser"/> + </entity> + <entity name="PrivateNote" representedClassName="CoreDataStack.PrivateNote" syncable="YES"> + <attribute name="note" optional="YES" attributeType="String"/> + <attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/> + <relationship name="from" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="privateNotesTo" inverseEntity="MastodonUser"/> + <relationship name="to" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="privateNotes" inverseEntity="MastodonUser"/> + </entity> + <entity name="SearchHistory" representedClassName="CoreDataStack.SearchHistory" syncable="YES"> + <attribute name="createAt" attributeType="Date" usesScalarValueType="NO"/> + <attribute name="domain" attributeType="String" defaultValueString=""/> + <attribute name="identifier" attributeType="UUID" usesScalarValueType="NO"/> + <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> + <attribute name="userID" attributeType="String" defaultValueString=""/> + <relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="searchHistories" inverseEntity="MastodonUser"/> + <relationship name="hashtag" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Tag" inverseName="searchHistories" inverseEntity="Tag"/> + <relationship name="status" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="searchHistories" inverseEntity="Status"/> + </entity> + <entity name="Setting" representedClassName="CoreDataStack.Setting" syncable="YES"> + <attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/> + <attribute name="domain" attributeType="String"/> + <attribute name="preferredStaticAvatar" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> + <attribute name="preferredStaticEmoji" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> + <attribute name="preferredTrueBlackDarkMode" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> + <attribute name="preferredUsingDefaultBrowser" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> + <attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/> + <attribute name="userID" attributeType="String"/> + <relationship name="subscriptions" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Subscription" inverseName="setting" inverseEntity="Subscription"/> + </entity> + <entity name="Status" representedClassName="CoreDataStack.Status" syncable="YES"> + <attribute name="attachments" optional="YES" attributeType="Binary"/> + <attribute name="content" attributeType="String"/> + <attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/> + <attribute name="deletedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> + <attribute name="domain" attributeType="String"/> + <attribute name="emojis" optional="YES" attributeType="Binary"/> + <attribute name="favouritesCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/> + <attribute name="id" attributeType="String"/> + <attribute name="identifier" attributeType="String"/> + <attribute name="inReplyToAccountID" optional="YES" attributeType="String"/> + <attribute name="inReplyToID" optional="YES" attributeType="String"/> + <attribute name="isContentSensitiveToggled" transient="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> + <attribute name="isMediaSensitiveToggled" transient="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> + <attribute name="language" optional="YES" attributeType="String"/> + <attribute name="mentions" optional="YES" attributeType="Binary"/> + <attribute name="reblogsCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/> + <attribute name="repliesCount" optional="YES" attributeType="Integer 64" usesScalarValueType="NO"/> + <attribute name="revealedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> + <attribute name="sensitive" attributeType="Boolean" usesScalarValueType="YES"/> + <attribute name="spoilerText" optional="YES" attributeType="String"/> + <attribute name="text" optional="YES" attributeType="String"/> + <attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/> + <attribute name="uri" attributeType="String"/> + <attribute name="url" optional="YES" attributeType="String"/> + <attribute name="visibilityRaw" optional="YES" attributeType="String" elementID="visibility"/> + <relationship name="application" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Application" inverseName="status" inverseEntity="Application"/> + <relationship name="author" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="statuses" inverseEntity="MastodonUser"/> + <relationship name="bookmarkedBy" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="bookmarked" inverseEntity="MastodonUser"/> + <relationship name="favouritedBy" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="favourite" inverseEntity="MastodonUser"/> + <relationship name="feeds" toMany="YES" deletionRule="Cascade" destinationEntity="Feed" inverseName="status" inverseEntity="Feed"/> + <relationship name="mutedBy" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="muted" inverseEntity="MastodonUser"/> + <relationship name="notifications" toMany="YES" deletionRule="Cascade" destinationEntity="Notification" inverseName="status" inverseEntity="Notification"/> + <relationship name="pinnedBy" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="pinnedStatus" inverseEntity="MastodonUser"/> + <relationship name="poll" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="Poll" inverseName="status" inverseEntity="Poll"/> + <relationship name="reblog" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="reblogFrom" inverseEntity="Status"/> + <relationship name="reblogFrom" toMany="YES" deletionRule="Cascade" destinationEntity="Status" inverseName="reblog" inverseEntity="Status"/> + <relationship name="rebloggedBy" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="reblogged" inverseEntity="MastodonUser"/> + <relationship name="replyFrom" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="replyTo" inverseEntity="Status"/> + <relationship name="replyTo" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="replyFrom" inverseEntity="Status"/> + <relationship name="searchHistories" toMany="YES" deletionRule="Cascade" destinationEntity="SearchHistory" inverseName="status" inverseEntity="SearchHistory"/> + </entity> + <entity name="Subscription" representedClassName="CoreDataStack.Subscription" syncable="YES"> + <attribute name="activedAt" attributeType="Date" usesScalarValueType="NO"/> + <attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/> + <attribute name="endpoint" optional="YES" attributeType="String"/> + <attribute name="id" optional="YES" attributeType="String"/> + <attribute name="policyRaw" attributeType="String"/> + <attribute name="serverKey" optional="YES" attributeType="String"/> + <attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/> + <attribute name="userToken" optional="YES" attributeType="String"/> + <relationship name="alert" maxCount="1" deletionRule="Cascade" destinationEntity="SubscriptionAlerts" inverseName="subscription" inverseEntity="SubscriptionAlerts"/> + <relationship name="setting" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Setting" inverseName="subscriptions" inverseEntity="Setting"/> + </entity> + <entity name="SubscriptionAlerts" representedClassName="CoreDataStack.SubscriptionAlerts" syncable="YES"> + <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> + <attribute name="favouriteRaw" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/> + <attribute name="followRaw" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/> + <attribute name="followRequestRaw" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/> + <attribute name="mentionRaw" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/> + <attribute name="pollRaw" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/> + <attribute name="reblogRaw" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/> + <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> + <relationship name="subscription" maxCount="1" deletionRule="Nullify" destinationEntity="Subscription" inverseName="alert" inverseEntity="Subscription"/> + </entity> + <entity name="Tag" representedClassName="CoreDataStack.Tag" syncable="YES"> + <attribute name="createAt" attributeType="Date" defaultDateTimeInterval="631123200" usesScalarValueType="NO"/> + <attribute name="domain" attributeType="String" defaultValueString=""/> + <attribute name="histories" optional="YES" attributeType="Binary"/> + <attribute name="identifier" attributeType="UUID" usesScalarValueType="NO"/> + <attribute name="name" attributeType="String"/> + <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> + <attribute name="url" attributeType="String"/> + <relationship name="searchHistories" toMany="YES" deletionRule="Nullify" destinationEntity="SearchHistory" inverseName="hashtag" inverseEntity="SearchHistory"/> + </entity> + <elements> + <element name="Application" positionX="0" positionY="0" width="128" height="104"/> + <element name="DomainBlock" positionX="45" positionY="162" width="128" height="89"/> + <element name="Emoji" positionX="0" positionY="0" width="128" height="134"/> + <element name="Feed" positionX="54" positionY="171" width="128" height="149"/> + <element name="Instance" positionX="45" positionY="162" width="128" height="104"/> + <element name="MastodonAuthentication" positionX="0" positionY="0" width="128" height="224"/> + <element name="MastodonUser" positionX="0" positionY="0" width="128" height="734"/> + <element name="Notification" positionX="9" positionY="162" width="128" height="164"/> + <element name="Poll" positionX="0" positionY="0" width="128" height="224"/> + <element name="PollOption" positionX="0" positionY="0" width="128" height="149"/> + <element name="PrivateNote" positionX="0" positionY="0" width="128" height="89"/> + <element name="SearchHistory" positionX="0" positionY="0" width="128" height="149"/> + <element name="Setting" positionX="72" positionY="162" width="128" height="164"/> + <element name="Status" positionX="0" positionY="0" width="128" height="629"/> + <element name="Subscription" positionX="81" positionY="171" width="128" height="179"/> + <element name="SubscriptionAlerts" positionX="72" positionY="162" width="128" height="164"/> + <element name="Tag" positionX="0" positionY="0" width="128" height="149"/> + </elements> +</model> \ No newline at end of file diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents similarity index 100% rename from CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents rename to MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents diff --git a/CoreDataStack/CoreDataStack.swift b/MastodonSDK/Sources/CoreDataStack/CoreDataStack.swift similarity index 98% rename from CoreDataStack/CoreDataStack.swift rename to MastodonSDK/Sources/CoreDataStack/CoreDataStack.swift index 2dfa0c38c..c5f415758 100644 --- a/CoreDataStack/CoreDataStack.swift +++ b/MastodonSDK/Sources/CoreDataStack/CoreDataStack.swift @@ -9,7 +9,7 @@ import os import Foundation import Combine import CoreData -import AppShared +import MastodonCommon public final class CoreDataStack { @@ -46,7 +46,7 @@ public final class CoreDataStack { }() static func persistentContainer() -> NSPersistentContainer { - let bundles = [Bundle(for: Status.self)] + let bundles = [Bundle.module] // .module required for package in the SwiftPM guard let managedObjectModel = NSManagedObjectModel.mergedModel(from: bundles) else { fatalError("cannot locate bundles") } diff --git a/MastodonSDK/Sources/CoreDataStack/Entity/App/Feed.swift b/MastodonSDK/Sources/CoreDataStack/Entity/App/Feed.swift new file mode 100644 index 000000000..5fca61153 --- /dev/null +++ b/MastodonSDK/Sources/CoreDataStack/Entity/App/Feed.swift @@ -0,0 +1,198 @@ +// +// Feed.swift +// CoreDataStack +// +// Created by MainasuK on 2022-1-11. +// + +import Foundation +import CoreData + +final public class Feed: NSManagedObject { + + @NSManaged public private(set) var acctRaw: String + // sourcery: autoGenerateProperty + public var acct: Acct { + get { + Acct(rawValue: acctRaw) ?? .none + } + set { + acctRaw = newValue.rawValue + } + } + + @NSManaged public private(set) var kindRaw: String + // sourcery: autoGenerateProperty + public var kind: Kind { + get { + Kind(rawValue: kindRaw) ?? .none + } + set { + kindRaw = newValue.rawValue + } + } + + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public private(set) var hasMore: Bool + // sourcery: autoUpdatableObject + @NSManaged public private(set) var isLoadingMore: Bool + + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public private(set) var createdAt: Date + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public private(set) var updatedAt: Date + + // one-to-one relationship + @NSManaged public private(set) var status: Status? + @NSManaged public private(set) var notification: Notification? + +} + +extension Feed { + + @discardableResult + public static func insert( + into context: NSManagedObjectContext, + property: Property + ) -> Feed { + let object: Feed = context.insertObject() + object.configure(property: property) + return object + } + +} + +extension Feed: Managed { + public static var defaultSortDescriptors: [NSSortDescriptor] { + return [NSSortDescriptor(keyPath: \Feed.createdAt, ascending: false)] + } +} + +extension Feed { + + static func predicate(kind: Kind) -> NSPredicate { + return NSPredicate(format: "%K == %@", #keyPath(Feed.kindRaw), kind.rawValue) + } + + public static func predicate(acct: Acct) -> NSPredicate { + return NSPredicate(format: "%K == %@", #keyPath(Feed.acctRaw), acct.rawValue) + } + + public static func predicate(kind: Kind, acct: Acct) -> NSPredicate { + return NSCompoundPredicate(andPredicateWithSubpredicates: [ + Feed.predicate(kind: kind), + Feed.predicate(acct: acct) + ]) + } + + public static func nonePredicate() -> NSPredicate { + return predicate(kind: .none, acct: .none) + } + + public static func hasMorePredicate() -> NSPredicate { + return NSPredicate(format: "%K == YES", #keyPath(Feed.hasMore)) + } + + public static func hasNotificationPredicate() -> NSPredicate { + return NSPredicate(format: "%K != nil", #keyPath(Feed.notification)) + } + + public static func notificationTypePredicate(types: [MastodonNotificationType]) -> NSPredicate { + return NSCompoundPredicate(andPredicateWithSubpredicates: [ + hasNotificationPredicate(), + NSPredicate( + format: "%K.%K IN %@", + #keyPath(Feed.notification), + #keyPath(Notification.typeRaw), + types.map { $0.rawValue } + ) + ]) + } + +} + +// MARK: - AutoGenerateProperty +extension Feed: AutoGenerateProperty { + // sourcery:inline:Feed.AutoGenerateProperty + + // Generated using Sourcery + // DO NOT EDIT + public struct Property { + public let acct: Acct + public let kind: Kind + public let hasMore: Bool + public let createdAt: Date + public let updatedAt: Date + + public init( + acct: Acct, + kind: Kind, + hasMore: Bool, + createdAt: Date, + updatedAt: Date + ) { + self.acct = acct + self.kind = kind + self.hasMore = hasMore + self.createdAt = createdAt + self.updatedAt = updatedAt + } + } + + public func configure(property: Property) { + self.acct = property.acct + self.kind = property.kind + self.hasMore = property.hasMore + self.createdAt = property.createdAt + self.updatedAt = property.updatedAt + } + + public func update(property: Property) { + update(hasMore: property.hasMore) + update(createdAt: property.createdAt) + update(updatedAt: property.updatedAt) + } + // sourcery:end +} + +// MARK: - AutoUpdatableObject +extension Feed: AutoUpdatableObject { + // sourcery:inline:Feed.AutoUpdatableObject + + // Generated using Sourcery + // DO NOT EDIT + public func update(hasMore: Bool) { + if self.hasMore != hasMore { + self.hasMore = hasMore + } + } + public func update(isLoadingMore: Bool) { + if self.isLoadingMore != isLoadingMore { + self.isLoadingMore = isLoadingMore + } + } + public func update(createdAt: Date) { + if self.createdAt != createdAt { + self.createdAt = createdAt + } + } + public func update(updatedAt: Date) { + if self.updatedAt != updatedAt { + self.updatedAt = updatedAt + } + } + // sourcery:end +} + +public protocol FeedIndexable { + var feeds: Set<Feed> { get } + func feed(kind: Feed.Kind, acct: Feed.Acct) -> Feed? +} + +extension FeedIndexable { + public func feed(kind: Feed.Kind, acct: Feed.Acct) -> Feed? { + return feeds.first(where: { feed in + feed.kind == kind && feed.acct == acct + }) + } +} diff --git a/CoreDataStack/Entity/Setting.swift b/MastodonSDK/Sources/CoreDataStack/Entity/App/Setting.swift similarity index 100% rename from CoreDataStack/Entity/Setting.swift rename to MastodonSDK/Sources/CoreDataStack/Entity/App/Setting.swift diff --git a/CoreDataStack/Entity/Application.swift b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Application.swift similarity index 100% rename from CoreDataStack/Entity/Application.swift rename to MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Application.swift diff --git a/CoreDataStack/Entity/DomainBlock.swift b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/DomainBlock.swift similarity index 100% rename from CoreDataStack/Entity/DomainBlock.swift rename to MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/DomainBlock.swift diff --git a/CoreDataStack/Entity/Emoji.swift b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Emoji.swift similarity index 100% rename from CoreDataStack/Entity/Emoji.swift rename to MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Emoji.swift diff --git a/CoreDataStack/Entity/History.swift b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/History.swift similarity index 100% rename from CoreDataStack/Entity/History.swift rename to MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/History.swift diff --git a/CoreDataStack/Entity/Instance.swift b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Instance.swift similarity index 100% rename from CoreDataStack/Entity/Instance.swift rename to MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Instance.swift diff --git a/CoreDataStack/Entity/MastodonAuthentication.swift b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/MastodonAuthentication.swift similarity index 100% rename from CoreDataStack/Entity/MastodonAuthentication.swift rename to MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/MastodonAuthentication.swift diff --git a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/MastodonUser.swift b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/MastodonUser.swift new file mode 100644 index 000000000..4b16b7c7b --- /dev/null +++ b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/MastodonUser.swift @@ -0,0 +1,612 @@ +// +// MastodonUser.swift +// CoreDataStack +// +// Created by MainasuK Cirno on 2021/1/27. +// + +import CoreData +import Foundation + +final public class MastodonUser: NSManagedObject { + + public typealias ID = String + + // sourcery: autoGenerateProperty + @NSManaged public private(set) var identifier: ID + // sourcery: autoGenerateProperty + @NSManaged public private(set) var domain: String + // sourcery: autoGenerateProperty + @NSManaged public private(set) var id: ID + + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public private(set) var acct: String + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public private(set) var username: String + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public private(set) var displayName: String + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public private(set) var avatar: String + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public private(set) var avatarStatic: String? + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public private(set) var header: String + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public private(set) var headerStatic: String? + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public private(set) var note: String? + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public private(set) var url: String? + + @NSManaged public private(set) var emojisData: Data? + @NSManaged public private(set) var fieldsData: Data? + + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public private(set) var statusesCount: Int64 + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public private(set) var followingCount: Int64 + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public private(set) var followersCount: Int64 + + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public private(set) var locked: Bool + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public private(set) var bot: Bool + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public private(set) var suspended: Bool + + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public private(set) var createdAt: Date + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public private(set) var updatedAt: Date + + // one-to-one relationship + @NSManaged public private(set) var pinnedStatus: Status? + @NSManaged public private(set) var mastodonAuthentication: MastodonAuthentication? + + // one-to-many relationship + @NSManaged public private(set) var statuses: Set<Status> + @NSManaged public private(set) var notifications: Set<Notification> + @NSManaged public private(set) var searchHistories: Set<SearchHistory> + + // many-to-many relationship + @NSManaged public private(set) var favourite: Set<Status> + @NSManaged public private(set) var reblogged: Set<Status> + @NSManaged public private(set) var muted: Set<Status> + @NSManaged public private(set) var bookmarked: Set<Status> + @NSManaged public private(set) var votePollOptions: Set<PollOption> + @NSManaged public private(set) var votePolls: Set<Poll> + // relationships + @NSManaged public private(set) var following: Set<MastodonUser> + @NSManaged public private(set) var followingBy: Set<MastodonUser> + @NSManaged public private(set) var followRequested: Set<MastodonUser> + @NSManaged public private(set) var followRequestedBy: Set<MastodonUser> + @NSManaged public private(set) var muting: Set<MastodonUser> + @NSManaged public private(set) var mutingBy: Set<MastodonUser> + @NSManaged public private(set) var blocking: Set<MastodonUser> + @NSManaged public private(set) var blockingBy: Set<MastodonUser> + @NSManaged public private(set) var endorsed: Set<MastodonUser> + @NSManaged public private(set) var endorsedBy: Set<MastodonUser> + @NSManaged public private(set) var domainBlocking: Set<MastodonUser> + @NSManaged public private(set) var domainBlockingBy: Set<MastodonUser> + +} + +extension MastodonUser { + // sourcery: autoUpdatableObject, autoGenerateProperty + @objc public var emojis: [MastodonEmoji] { + get { + let keyPath = #keyPath(MastodonUser.emojis) + willAccessValue(forKey: keyPath) + let _data = primitiveValue(forKey: keyPath) as? Data + didAccessValue(forKey: keyPath) + do { + guard let data = _data else { return [] } + let emojis = try JSONDecoder().decode([MastodonEmoji].self, from: data) + return emojis + } catch { + assertionFailure(error.localizedDescription) + return [] + } + } + set { + let keyPath = #keyPath(MastodonUser.emojis) + let data = try? JSONEncoder().encode(newValue) + willChangeValue(forKey: keyPath) + setPrimitiveValue(data, forKey: keyPath) + didChangeValue(forKey: keyPath) + } + } + + // sourcery: autoUpdatableObject, autoGenerateProperty + @objc public var fields: [MastodonField] { + get { + let keyPath = #keyPath(MastodonUser.fields) + willAccessValue(forKey: keyPath) + let _data = primitiveValue(forKey: keyPath) as? Data + didAccessValue(forKey: keyPath) + do { + guard let data = _data else { return [] } + let fields = try JSONDecoder().decode([MastodonField].self, from: data) + return fields + } catch { + assertionFailure(error.localizedDescription) + return [] + } + } + set { + let keyPath = #keyPath(MastodonUser.fields) + let data = try? JSONEncoder().encode(newValue) + willChangeValue(forKey: keyPath) + setPrimitiveValue(data, forKey: keyPath) + didChangeValue(forKey: keyPath) + } + } +} + +extension MastodonUser { + + @discardableResult + public static func insert( + into context: NSManagedObjectContext, + property: Property + ) -> MastodonUser { + let object: MastodonUser = context.insertObject() + object.configure(property: property) + return object + } + +} + +extension MastodonUser: Managed { + public static var defaultSortDescriptors: [NSSortDescriptor] { + return [NSSortDescriptor(keyPath: \MastodonUser.createdAt, ascending: false)] + } +} + +extension MastodonUser { + + static func predicate(domain: String) -> NSPredicate { + return NSPredicate(format: "%K == %@", #keyPath(MastodonUser.domain), domain) + } + + static func predicate(id: String) -> NSPredicate { + return NSPredicate(format: "%K == %@", #keyPath(MastodonUser.id), id) + } + + public static func predicate(domain: String, id: String) -> NSPredicate { + return NSCompoundPredicate(andPredicateWithSubpredicates: [ + MastodonUser.predicate(domain: domain), + MastodonUser.predicate(id: id) + ]) + } + + static func predicate(ids: [String]) -> NSPredicate { + return NSPredicate(format: "%K IN %@", #keyPath(MastodonUser.id), ids) + } + + public static func predicate(domain: String, ids: [String]) -> NSPredicate { + return NSCompoundPredicate(andPredicateWithSubpredicates: [ + MastodonUser.predicate(domain: domain), + MastodonUser.predicate(ids: ids) + ]) + } + + static func predicate(username: String) -> NSPredicate { + return NSPredicate(format: "%K == %@", #keyPath(MastodonUser.username), username) + } + + public static func predicate(domain: String, username: String) -> NSPredicate { + return NSCompoundPredicate(andPredicateWithSubpredicates: [ + MastodonUser.predicate(domain: domain), + MastodonUser.predicate(username: username) + ]) + } + + public static func predicate(followingBy userID: MastodonUser.ID) -> NSPredicate { + NSPredicate(format: "ANY %K.%K == %@", #keyPath(MastodonUser.followingBy), #keyPath(MastodonUser.id), userID) + } + + public static func predicate(followRequestedBy userID: MastodonUser.ID) -> NSPredicate { + NSPredicate(format: "ANY %K.%K == %@", #keyPath(MastodonUser.followRequestedBy), #keyPath(MastodonUser.id), userID) + } + +} + + +extension MastodonUser { + + public func findSearchHistory( + domain: String, + userID: MastodonUser.ID + ) -> SearchHistory? { + return searchHistories.first { searchHistory in + return searchHistory.domain == domain + && searchHistory.userID == userID + } + } + + public func findSearchHistory(for user: MastodonUser) -> SearchHistory? { + return searchHistories.first { searchHistory in + return searchHistory.domain == user.domain + && searchHistory.userID == user.id + } + } + +} + +// MARK: - AutoGenerateProperty +extension MastodonUser: AutoGenerateProperty { + // sourcery:inline:MastodonUser.AutoGenerateProperty + + // Generated using Sourcery + // DO NOT EDIT + public struct Property { + public let identifier: ID + public let domain: String + public let id: ID + public let acct: String + public let username: String + public let displayName: String + public let avatar: String + public let avatarStatic: String? + public let header: String + public let headerStatic: String? + public let note: String? + public let url: String? + public let statusesCount: Int64 + public let followingCount: Int64 + public let followersCount: Int64 + public let locked: Bool + public let bot: Bool + public let suspended: Bool + public let createdAt: Date + public let updatedAt: Date + public let emojis: [MastodonEmoji] + public let fields: [MastodonField] + + public init( + identifier: ID, + domain: String, + id: ID, + acct: String, + username: String, + displayName: String, + avatar: String, + avatarStatic: String?, + header: String, + headerStatic: String?, + note: String?, + url: String?, + statusesCount: Int64, + followingCount: Int64, + followersCount: Int64, + locked: Bool, + bot: Bool, + suspended: Bool, + createdAt: Date, + updatedAt: Date, + emojis: [MastodonEmoji], + fields: [MastodonField] + ) { + self.identifier = identifier + self.domain = domain + self.id = id + self.acct = acct + self.username = username + self.displayName = displayName + self.avatar = avatar + self.avatarStatic = avatarStatic + self.header = header + self.headerStatic = headerStatic + self.note = note + self.url = url + self.statusesCount = statusesCount + self.followingCount = followingCount + self.followersCount = followersCount + self.locked = locked + self.bot = bot + self.suspended = suspended + self.createdAt = createdAt + self.updatedAt = updatedAt + self.emojis = emojis + self.fields = fields + } + } + + public func configure(property: Property) { + self.identifier = property.identifier + self.domain = property.domain + self.id = property.id + self.acct = property.acct + self.username = property.username + self.displayName = property.displayName + self.avatar = property.avatar + self.avatarStatic = property.avatarStatic + self.header = property.header + self.headerStatic = property.headerStatic + self.note = property.note + self.url = property.url + self.statusesCount = property.statusesCount + self.followingCount = property.followingCount + self.followersCount = property.followersCount + self.locked = property.locked + self.bot = property.bot + self.suspended = property.suspended + self.createdAt = property.createdAt + self.updatedAt = property.updatedAt + self.emojis = property.emojis + self.fields = property.fields + } + + public func update(property: Property) { + update(acct: property.acct) + update(username: property.username) + update(displayName: property.displayName) + update(avatar: property.avatar) + update(avatarStatic: property.avatarStatic) + update(header: property.header) + update(headerStatic: property.headerStatic) + update(note: property.note) + update(url: property.url) + update(statusesCount: property.statusesCount) + update(followingCount: property.followingCount) + update(followersCount: property.followersCount) + update(locked: property.locked) + update(bot: property.bot) + update(suspended: property.suspended) + update(createdAt: property.createdAt) + update(updatedAt: property.updatedAt) + update(emojis: property.emojis) + update(fields: property.fields) + } + // sourcery:end +} + +//extension MastodonUser { +// public struct Property { +// public let identifier: String +// public let domain: String +// +// public let id: String +// public let acct: String +// public let username: String +// public let displayName: String +// public let avatar: String +// public let avatarStatic: String? +// public let header: String +// public let headerStatic: String? +// public let note: String? +// public let url: String? +// public let emojisData: Data? +// public let fieldsData: Data? +// public let statusesCount: Int +// public let followingCount: Int +// public let followersCount: Int +// public let locked: Bool +// public let bot: Bool? +// public let suspended: Bool? +// +// public let createdAt: Date +// public let networkDate: Date +// +// public init( +// id: String, +// domain: String, +// acct: String, +// username: String, +// displayName: String, +// avatar: String, +// avatarStatic: String?, +// header: String, +// headerStatic: String?, +// note: String?, +// url: String?, +// emojisData: Data?, +// fieldsData: Data?, +// statusesCount: Int, +// followingCount: Int, +// followersCount: Int, +// locked: Bool, +// bot: Bool?, +// suspended: Bool?, +// createdAt: Date, +// networkDate: Date +// ) { +// self.identifier = id + "@" + domain +// self.domain = domain +// self.id = id +// self.acct = acct +// self.username = username +// self.displayName = displayName +// self.avatar = avatar +// self.avatarStatic = avatarStatic +// self.header = header +// self.headerStatic = headerStatic +// self.note = note +// self.url = url +// self.emojisData = emojisData +// self.fieldsData = fieldsData +// self.statusesCount = statusesCount +// self.followingCount = followingCount +// self.followersCount = followersCount +// self.locked = locked +// self.bot = bot +// self.suspended = suspended +// self.createdAt = createdAt +// self.networkDate = networkDate +// } +// } +//} + +// MARK: - AutoUpdatableObject +extension MastodonUser: AutoUpdatableObject { + // sourcery:inline:MastodonUser.AutoUpdatableObject + + // Generated using Sourcery + // DO NOT EDIT + public func update(acct: String) { + if self.acct != acct { + self.acct = acct + } + } + public func update(username: String) { + if self.username != username { + self.username = username + } + } + public func update(displayName: String) { + if self.displayName != displayName { + self.displayName = displayName + } + } + public func update(avatar: String) { + if self.avatar != avatar { + self.avatar = avatar + } + } + public func update(avatarStatic: String?) { + if self.avatarStatic != avatarStatic { + self.avatarStatic = avatarStatic + } + } + public func update(header: String) { + if self.header != header { + self.header = header + } + } + public func update(headerStatic: String?) { + if self.headerStatic != headerStatic { + self.headerStatic = headerStatic + } + } + public func update(note: String?) { + if self.note != note { + self.note = note + } + } + public func update(url: String?) { + if self.url != url { + self.url = url + } + } + public func update(statusesCount: Int64) { + if self.statusesCount != statusesCount { + self.statusesCount = statusesCount + } + } + public func update(followingCount: Int64) { + if self.followingCount != followingCount { + self.followingCount = followingCount + } + } + public func update(followersCount: Int64) { + if self.followersCount != followersCount { + self.followersCount = followersCount + } + } + public func update(locked: Bool) { + if self.locked != locked { + self.locked = locked + } + } + public func update(bot: Bool) { + if self.bot != bot { + self.bot = bot + } + } + public func update(suspended: Bool) { + if self.suspended != suspended { + self.suspended = suspended + } + } + public func update(createdAt: Date) { + if self.createdAt != createdAt { + self.createdAt = createdAt + } + } + public func update(updatedAt: Date) { + if self.updatedAt != updatedAt { + self.updatedAt = updatedAt + } + } + public func update(emojis: [MastodonEmoji]) { + if self.emojis != emojis { + self.emojis = emojis + } + } + public func update(fields: [MastodonField]) { + if self.fields != fields { + self.fields = fields + } + } + // sourcery:end + + public func update(isFollowing: Bool, by mastodonUser: MastodonUser) { + if isFollowing { + if !self.followingBy.contains(mastodonUser) { + self.mutableSetValue(forKey: #keyPath(MastodonUser.followingBy)).add(mastodonUser) + } + } else { + if self.followingBy.contains(mastodonUser) { + self.mutableSetValue(forKey: #keyPath(MastodonUser.followingBy)).remove(mastodonUser) + } + } + } + public func update(isFollowRequested: Bool, by mastodonUser: MastodonUser) { + if isFollowRequested { + if !self.followRequestedBy.contains(mastodonUser) { + self.mutableSetValue(forKey: #keyPath(MastodonUser.followRequestedBy)).add(mastodonUser) + } + } else { + if self.followRequestedBy.contains(mastodonUser) { + self.mutableSetValue(forKey: #keyPath(MastodonUser.followRequestedBy)).remove(mastodonUser) + } + } + } + public func update(isMuting: Bool, by mastodonUser: MastodonUser) { + if isMuting { + if !self.mutingBy.contains(mastodonUser) { + self.mutableSetValue(forKey: #keyPath(MastodonUser.mutingBy)).add(mastodonUser) + } + } else { + if self.mutingBy.contains(mastodonUser) { + self.mutableSetValue(forKey: #keyPath(MastodonUser.mutingBy)).remove(mastodonUser) + } + } + } + public func update(isBlocking: Bool, by mastodonUser: MastodonUser) { + if isBlocking { + if !self.blockingBy.contains(mastodonUser) { + self.mutableSetValue(forKey: #keyPath(MastodonUser.blockingBy)).add(mastodonUser) + } + } else { + if self.blockingBy.contains(mastodonUser) { + self.mutableSetValue(forKey: #keyPath(MastodonUser.blockingBy)).remove(mastodonUser) + } + } + } + public func update(isEndorsed: Bool, by mastodonUser: MastodonUser) { + if isEndorsed { + if !self.endorsedBy.contains(mastodonUser) { + self.mutableSetValue(forKey: #keyPath(MastodonUser.endorsedBy)).add(mastodonUser) + } + } else { + if self.endorsedBy.contains(mastodonUser) { + self.mutableSetValue(forKey: #keyPath(MastodonUser.endorsedBy)).remove(mastodonUser) + } + } + } + public func update(isDomainBlocking: Bool, by mastodonUser: MastodonUser) { + if isDomainBlocking { + if !self.domainBlockingBy.contains(mastodonUser) { + self.mutableSetValue(forKey: #keyPath(MastodonUser.domainBlockingBy)).add(mastodonUser) + } + } else { + if self.domainBlockingBy.contains(mastodonUser) { + self.mutableSetValue(forKey: #keyPath(MastodonUser.domainBlockingBy)).remove(mastodonUser) + } + } + } + +} diff --git a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Notification.swift b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Notification.swift new file mode 100644 index 000000000..85019b0dc --- /dev/null +++ b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Notification.swift @@ -0,0 +1,207 @@ +// +// Notification.swift +// CoreDataStack +// +// Created by sxiaojian on 2021/4/13. +// + +import Foundation +import CoreData + +public final class Notification: NSManagedObject { + public typealias ID = String + + // sourcery: autoGenerateProperty + @NSManaged public private(set) var id: ID + // sourcery: autoGenerateProperty + @NSManaged public private(set) var typeRaw: String + // sourcery: autoGenerateProperty + @NSManaged public private(set) var domain: String + // sourcery: autoGenerateProperty + @NSManaged public private(set) var userID: String + + // sourcery: autoGenerateProperty + @NSManaged public private(set) var createAt: Date + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public private(set) var updatedAt: Date + + // one-to-one relationship + // sourcery: autoGenerateRelationship + @NSManaged public private(set) var account: MastodonUser + // sourcery: autoGenerateRelationship + @NSManaged public private(set) var status: Status? + + // many-to-one relationship + @NSManaged public private(set) var feeds: Set<Feed> + +} + +extension Notification: FeedIndexable { } + +extension Notification { + @discardableResult + public static func insert( + into context: NSManagedObjectContext, + property: Property, + relationship: Relationship + ) -> Notification { + let object: Notification = context.insertObject() + + object.configure(property: property) + object.configure(relationship: relationship) + + return object + } +} + +extension Notification: Managed { + public static var defaultSortDescriptors: [NSSortDescriptor] { + return [NSSortDescriptor(keyPath: \Notification.createAt, ascending: false)] + } +} + +extension Notification { + static func predicate(domain: String) -> NSPredicate { + return NSPredicate(format: "%K == %@", #keyPath(Notification.domain), domain) + } + + static func predicate(userID: String) -> NSPredicate { + return NSPredicate(format: "%K == %@", #keyPath(Notification.userID), userID) + } + + static func predicate(id: ID) -> NSPredicate { + return NSPredicate(format: "%K == %@", #keyPath(Notification.id), id) + } + + static func predicate(typeRaw: String) -> NSPredicate { + return NSPredicate(format: "%K == %@", #keyPath(Notification.typeRaw), typeRaw) + } + + public static func predicate( + domain: String, + userID: String, + id: ID + ) -> NSPredicate { + return NSCompoundPredicate(andPredicateWithSubpredicates: [ + Notification.predicate(domain: domain), + Notification.predicate(userID: userID), + Notification.predicate(id: id) + ]) + } + + public static func predicate( + domain: String, + userID: String, + typeRaw: String? = nil + ) -> NSPredicate { + if let typeRaw = typeRaw { + return NSCompoundPredicate(andPredicateWithSubpredicates: [ + Notification.predicate(domain: domain), + Notification.predicate(typeRaw: typeRaw), + Notification.predicate(userID: userID), + ]) + } else { + return NSCompoundPredicate(andPredicateWithSubpredicates: [ + Notification.predicate(domain: domain), + Notification.predicate(userID: userID) + ]) + } + } + + public static func predicate(validTypesRaws types: [String]) -> NSPredicate { + return NSPredicate(format: "%K IN %@", #keyPath(Notification.typeRaw), types) + } + +} + +// MARK: - AutoGenerateProperty +extension Notification: AutoGenerateProperty { + // sourcery:inline:Notification.AutoGenerateProperty + + // Generated using Sourcery + // DO NOT EDIT + public struct Property { + public let id: ID + public let typeRaw: String + public let domain: String + public let userID: String + public let createAt: Date + public let updatedAt: Date + + public init( + id: ID, + typeRaw: String, + domain: String, + userID: String, + createAt: Date, + updatedAt: Date + ) { + self.id = id + self.typeRaw = typeRaw + self.domain = domain + self.userID = userID + self.createAt = createAt + self.updatedAt = updatedAt + } + } + + public func configure(property: Property) { + self.id = property.id + self.typeRaw = property.typeRaw + self.domain = property.domain + self.userID = property.userID + self.createAt = property.createAt + self.updatedAt = property.updatedAt + } + + public func update(property: Property) { + update(updatedAt: property.updatedAt) + } + // sourcery:end +} + +// MARK: - AutoGenerateRelationship +extension Notification: AutoGenerateRelationship { + // sourcery:inline:Notification.AutoGenerateRelationship + + // Generated using Sourcery + // DO NOT EDIT + public struct Relationship { + public let account: MastodonUser + public let status: Status? + + public init( + account: MastodonUser, + status: Status? + ) { + self.account = account + self.status = status + } + } + + public func configure(relationship: Relationship) { + self.account = relationship.account + self.status = relationship.status + } + // sourcery:end +} + +// MARK: - AutoUpdatableObject +extension Notification: AutoUpdatableObject { + // sourcery:inline:Notification.AutoUpdatableObject + + // Generated using Sourcery + // DO NOT EDIT + public func update(updatedAt: Date) { + if self.updatedAt != updatedAt { + self.updatedAt = updatedAt + } + } + // sourcery:end +} + +extension Notification { + public func attach(feed: Feed) { + mutableSetValue(forKey: #keyPath(Notification.feeds)).add(feed) + } +} diff --git a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Poll.swift b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Poll.swift new file mode 100644 index 000000000..a237f5399 --- /dev/null +++ b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Poll.swift @@ -0,0 +1,326 @@ +// +// Poll.swift +// CoreDataStack +// +// Created by MainasuK Cirno on 2021-3-2. +// + +import Foundation +import CoreData + +public final class Poll: NSManagedObject { + public typealias ID = String + + // sourcery: autoGenerateProperty + @NSManaged public private(set) var domain: String + // sourcery: autoGenerateProperty + @NSManaged public private(set) var id: ID + + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public private(set) var expiresAt: Date? + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public private(set) var expired: Bool + + // sourcery: autoGenerateProperty + @NSManaged public private(set) var multiple: Bool + + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public private(set) var votesCount: Int64 + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public private(set) var votersCount: Int64 + + // sourcery: autoGenerateProperty + @NSManaged public private(set) var createdAt: Date + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public private(set) var updatedAt: Date + + // sourcery: autoUpdatableObject + @NSManaged public private(set) var isVoting: Bool + + // one-to-one relationship + @NSManaged public private(set) var status: Status + + // one-to-many relationship + @NSManaged public private(set) var options: Set<PollOption> + + // many-to-many relationship + @NSManaged public private(set) var votedBy: Set<MastodonUser>? +} + +extension Poll { + + @discardableResult + public static func insert( + into context: NSManagedObjectContext, + property: Property + ) -> Poll { + let object: Poll = context.insertObject() + + object.configure(property: property) + + return object + } + +} + +extension Poll: Managed { + public static var defaultSortDescriptors: [NSSortDescriptor] { + return [NSSortDescriptor(keyPath: \Poll.createdAt, ascending: false)] + } +} + +extension Poll { + static func predicate(domain: String) -> NSPredicate { + return NSPredicate(format: "%K == %@", #keyPath(Poll.domain), domain) + } + + static func predicate(id: ID) -> NSPredicate { + return NSPredicate(format: "%K == %@", #keyPath(Poll.id), id) + } + + static func predicate(ids: [ID]) -> NSPredicate { + return NSPredicate(format: "%K IN %@", #keyPath(Poll.id), ids) + } + + public static func predicate(domain: String, id: ID) -> NSPredicate { + return NSCompoundPredicate(andPredicateWithSubpredicates: [ + predicate(domain: domain), + predicate(id: id) + ]) + } + + public static func predicate(domain: String, ids: [ID]) -> NSPredicate { + return NSCompoundPredicate(andPredicateWithSubpredicates: [ + predicate(domain: domain), + predicate(ids: ids) + ]) + } +} + +//extension Poll { +// +// public override func awakeFromInsert() { +// super.awakeFromInsert() +// setPrimitiveValue(Date(), forKey: #keyPath(Poll.createdAt)) +// } +// +// @discardableResult +// public static func insert( +// into context: NSManagedObjectContext, +// property: Property, +// votedBy: MastodonUser?, +// options: [PollOption] +// ) -> Poll { +// let poll: Poll = context.insertObject() +// +// poll.id = property.id +// poll.expiresAt = property.expiresAt +// poll.expired = property.expired +// poll.multiple = property.multiple +// poll.votesCount = property.votesCount +// poll.votersCount = property.votersCount +// +// +// poll.updatedAt = property.networkDate +// +// if let votedBy = votedBy { +// poll.mutableSetValue(forKey: #keyPath(Poll.votedBy)).add(votedBy) +// } +// poll.mutableSetValue(forKey: #keyPath(Poll.options)).addObjects(from: options) +// +// return poll +// } +// +// public func update(expiresAt: Date?) { +// if self.expiresAt != expiresAt { +// self.expiresAt = expiresAt +// } +// } +// +// public func update(expired: Bool) { +// if self.expired != expired { +// self.expired = expired +// } +// } +// +// public func update(votesCount: Int) { +// if self.votesCount.intValue != votesCount { +// self.votesCount = NSNumber(value: votesCount) +// } +// } +// +// public func update(votersCount: Int?) { +// if self.votersCount?.intValue != votersCount { +// self.votersCount = votersCount.flatMap { NSNumber(value: $0) } +// } +// } +// +// public func update(voted: Bool, by: MastodonUser) { +// if voted { +// if !(votedBy ?? Set()).contains(by) { +// mutableSetValue(forKey: #keyPath(Poll.votedBy)).add(by) +// } +// } else { +// if (votedBy ?? Set()).contains(by) { +// mutableSetValue(forKey: #keyPath(Poll.votedBy)).remove(by) +// } +// } +// } +// +// public func didUpdate(at networkDate: Date) { +// self.updatedAt = networkDate +// } +// +//} + +//extension Poll { +// public struct Property { +// public let id: ID +// public let expiresAt: Date? +// public let expired: Bool +// public let multiple: Bool +// public let votesCount: NSNumber +// public let votersCount: NSNumber? +// +// public let networkDate: Date +// +// public init( +// id: Poll.ID, +// expiresAt: Date?, +// expired: Bool, +// multiple: Bool, +// votesCount: Int, +// votersCount: Int?, +// networkDate: Date +// ) { +// self.id = id +// self.expiresAt = expiresAt +// self.expired = expired +// self.multiple = multiple +// self.votesCount = NSNumber(value: votesCount) +// self.votersCount = votersCount.flatMap { NSNumber(value: $0) } +// self.networkDate = networkDate +// } +// } +//} + +// MARK: - AutoGenerateProperty +extension Poll: AutoGenerateProperty { + // sourcery:inline:Poll.AutoGenerateProperty + + // Generated using Sourcery + // DO NOT EDIT + public struct Property { + public let domain: String + public let id: ID + public let expiresAt: Date? + public let expired: Bool + public let multiple: Bool + public let votesCount: Int64 + public let votersCount: Int64 + public let createdAt: Date + public let updatedAt: Date + + public init( + domain: String, + id: ID, + expiresAt: Date?, + expired: Bool, + multiple: Bool, + votesCount: Int64, + votersCount: Int64, + createdAt: Date, + updatedAt: Date + ) { + self.domain = domain + self.id = id + self.expiresAt = expiresAt + self.expired = expired + self.multiple = multiple + self.votesCount = votesCount + self.votersCount = votersCount + self.createdAt = createdAt + self.updatedAt = updatedAt + } + } + + public func configure(property: Property) { + self.domain = property.domain + self.id = property.id + self.expiresAt = property.expiresAt + self.expired = property.expired + self.multiple = property.multiple + self.votesCount = property.votesCount + self.votersCount = property.votersCount + self.createdAt = property.createdAt + self.updatedAt = property.updatedAt + } + + public func update(property: Property) { + update(expiresAt: property.expiresAt) + update(expired: property.expired) + update(votesCount: property.votesCount) + update(votersCount: property.votersCount) + update(updatedAt: property.updatedAt) + } + // sourcery:end + +} + +// MARK: - AutoUpdatableObject +extension Poll: AutoUpdatableObject { + // sourcery:inline:Poll.AutoUpdatableObject + + // Generated using Sourcery + // DO NOT EDIT + public func update(expiresAt: Date?) { + if self.expiresAt != expiresAt { + self.expiresAt = expiresAt + } + } + public func update(expired: Bool) { + if self.expired != expired { + self.expired = expired + } + } + public func update(votesCount: Int64) { + if self.votesCount != votesCount { + self.votesCount = votesCount + } + } + public func update(votersCount: Int64) { + if self.votersCount != votersCount { + self.votersCount = votersCount + } + } + public func update(updatedAt: Date) { + if self.updatedAt != updatedAt { + self.updatedAt = updatedAt + } + } + public func update(isVoting: Bool) { + if self.isVoting != isVoting { + self.isVoting = isVoting + } + } + // sourcery:end + + public func update(voted: Bool, by: MastodonUser) { + if voted { + if !(votedBy ?? Set()).contains(by) { + mutableSetValue(forKey: #keyPath(Poll.votedBy)).add(by) + } + } else { + if (votedBy ?? Set()).contains(by) { + mutableSetValue(forKey: #keyPath(Poll.votedBy)).remove(by) + } + } + } + + public func attach(options: [PollOption]) { + for option in options { + guard !self.options.contains(option) else { continue } + self.mutableSetValue(forKey: #keyPath(Poll.options)).add(option) + } + } +} diff --git a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/PollOption.swift b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/PollOption.swift new file mode 100644 index 000000000..2799dd0a0 --- /dev/null +++ b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/PollOption.swift @@ -0,0 +1,199 @@ +// +// PollOption.swift +// CoreDataStack +// +// Created by MainasuK Cirno on 2021-3-2. +// + +import Foundation +import CoreData + +public final class PollOption: NSManagedObject { + + // sourcery: autoGenerateProperty + @NSManaged public private(set) var index: Int64 + + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public private(set) var title: String + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public private(set) var votesCount: Int64 + + // sourcery: autoGenerateProperty + @NSManaged public private(set) var createdAt: Date + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public private(set) var updatedAt: Date + + // sourcery: autoUpdatableObject + @NSManaged public private(set) var isSelected: Bool + + // many-to-one relationship + @NSManaged public private(set) var poll: Poll + + // many-to-many relationship + @NSManaged public private(set) var votedBy: Set<MastodonUser>? +} + + +extension PollOption { + + @discardableResult + public static func insert( + into context: NSManagedObjectContext, + property: Property + ) -> PollOption { + let object: PollOption = context.insertObject() + + object.configure(property: property) + + return object + } + +} + +extension PollOption: Managed { + public static var defaultSortDescriptors: [NSSortDescriptor] { + return [NSSortDescriptor(keyPath: \PollOption.createdAt, ascending: false)] + } +} + +//extension PollOption { +// +// public override func awakeFromInsert() { +// super.awakeFromInsert() +// setPrimitiveValue(Date(), forKey: #keyPath(PollOption.createdAt)) +// } +// +// @discardableResult +// public static func insert( +// into context: NSManagedObjectContext, +// property: Property, +// votedBy: MastodonUser? +// ) -> PollOption { +// let option: PollOption = context.insertObject() +// +// option.index = property.index +// option.title = property.title +// option.votesCount = property.votesCount +// option.updatedAt = property.networkDate +// +// if let votedBy = votedBy { +// option.mutableSetValue(forKey: #keyPath(PollOption.votedBy)).add(votedBy) +// } +// +// return option +// } +// +// public func update(votesCount: Int?) { +// if self.votesCount?.intValue != votesCount { +// self.votesCount = votesCount.flatMap { NSNumber(value: $0) } +// } +// } +// +// public func didUpdate(at networkDate: Date) { +// self.updatedAt = networkDate +// } +// +//} + +//extension PollOption { +// public struct Property { +// public let index: NSNumber +// public let title: String +// public let votesCount: NSNumber? +// +// public let networkDate: Date +// +// public init(index: Int, title: String, votesCount: Int?, networkDate: Date) { +// self.index = NSNumber(value: index) +// self.title = title +// self.votesCount = votesCount.flatMap { NSNumber(value: $0) } +// self.networkDate = networkDate +// } +// } +//} +// + +// MARK: - AutoGenerateProperty +extension PollOption: AutoGenerateProperty { + // sourcery:inline:PollOption.AutoGenerateProperty + + // Generated using Sourcery + // DO NOT EDIT + public struct Property { + public let index: Int64 + public let title: String + public let votesCount: Int64 + public let createdAt: Date + public let updatedAt: Date + + public init( + index: Int64, + title: String, + votesCount: Int64, + createdAt: Date, + updatedAt: Date + ) { + self.index = index + self.title = title + self.votesCount = votesCount + self.createdAt = createdAt + self.updatedAt = updatedAt + } + } + + public func configure(property: Property) { + self.index = property.index + self.title = property.title + self.votesCount = property.votesCount + self.createdAt = property.createdAt + self.updatedAt = property.updatedAt + } + + public func update(property: Property) { + update(title: property.title) + update(votesCount: property.votesCount) + update(updatedAt: property.updatedAt) + } + // sourcery:end +} + +// MARK: - AutoUpdatableObject +extension PollOption: AutoUpdatableObject { + // sourcery:inline:PollOption.AutoUpdatableObject + + // Generated using Sourcery + // DO NOT EDIT + public func update(title: String) { + if self.title != title { + self.title = title + } + } + public func update(votesCount: Int64) { + if self.votesCount != votesCount { + self.votesCount = votesCount + } + } + public func update(updatedAt: Date) { + if self.updatedAt != updatedAt { + self.updatedAt = updatedAt + } + } + public func update(isSelected: Bool) { + if self.isSelected != isSelected { + self.isSelected = isSelected + } + } + // sourcery:end + + public func update(voted: Bool, by: MastodonUser) { + if voted { + if !(self.votedBy ?? Set()).contains(by) { + self.mutableSetValue(forKey: #keyPath(PollOption.votedBy)).add(by) + } + } else { + if (self.votedBy ?? Set()).contains(by) { + self.mutableSetValue(forKey: #keyPath(PollOption.votedBy)).remove(by) + } + } + } +} diff --git a/CoreDataStack/Entity/PrivateNote.swift b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/PrivateNote.swift similarity index 100% rename from CoreDataStack/Entity/PrivateNote.swift rename to MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/PrivateNote.swift diff --git a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/SearchHistory.swift b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/SearchHistory.swift new file mode 100644 index 000000000..c3c6d28c3 --- /dev/null +++ b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/SearchHistory.swift @@ -0,0 +1,158 @@ +// +// SearchHistory.swift +// CoreDataStack +// +// Created by sxiaojian on 2021/4/7. +// + +import Foundation +import CoreData + +public final class SearchHistory: NSManagedObject { + public typealias ID = UUID + + // sourcery: autoGenerateProperty + @NSManaged public private(set) var identifier: ID + // sourcery: autoGenerateProperty + @NSManaged public private(set) var domain: String + // sourcery: autoGenerateProperty + @NSManaged public private(set) var userID: MastodonUser.ID + // sourcery: autoGenerateProperty + @NSManaged public private(set) var createAt: Date + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public private(set) var updatedAt: Date + + // many-to-one relationship + // sourcery: autoGenerateRelationship + @NSManaged public private(set) var account: MastodonUser? + // sourcery: autoGenerateRelationship + @NSManaged public private(set) var hashtag: Tag? + // sourcery: autoGenerateRelationship + @NSManaged public private(set) var status: Status? + +} + +extension SearchHistory { + @discardableResult + public static func insert( + into context: NSManagedObjectContext, + property: Property, + relationship: Relationship + ) -> SearchHistory { + let object: SearchHistory = context.insertObject() + + object.configure(property: property) + object.configure(relationship: relationship) + + return object + } +} + +extension SearchHistory: Managed { + public static var defaultSortDescriptors: [NSSortDescriptor] { + return [NSSortDescriptor(keyPath: \SearchHistory.updatedAt, ascending: false)] + } +} + +extension SearchHistory { + static func predicate(domain: String) -> NSPredicate { + return NSPredicate(format: "%K == %@", #keyPath(SearchHistory.domain), domain) + } + + static func predicate(userID: String) -> NSPredicate { + return NSPredicate(format: "%K == %@", #keyPath(SearchHistory.userID), userID) + } + + public static func predicate(domain: String, userID: String) -> NSPredicate { + return NSCompoundPredicate(andPredicateWithSubpredicates: [ + predicate(domain: domain), + predicate(userID: userID) + ]) + } +} + +// MARK: - AutoGenerateProperty +extension SearchHistory: AutoGenerateProperty { + // sourcery:inline:SearchHistory.AutoGenerateProperty + + // Generated using Sourcery + // DO NOT EDIT + public struct Property { + public let identifier: ID + public let domain: String + public let userID: MastodonUser.ID + public let createAt: Date + public let updatedAt: Date + + public init( + identifier: ID, + domain: String, + userID: MastodonUser.ID, + createAt: Date, + updatedAt: Date + ) { + self.identifier = identifier + self.domain = domain + self.userID = userID + self.createAt = createAt + self.updatedAt = updatedAt + } + } + + public func configure(property: Property) { + self.identifier = property.identifier + self.domain = property.domain + self.userID = property.userID + self.createAt = property.createAt + self.updatedAt = property.updatedAt + } + + public func update(property: Property) { + update(updatedAt: property.updatedAt) + } + // sourcery:end +} + +// MARK: - AutoGenerateRelationship +extension SearchHistory: AutoGenerateRelationship { + // sourcery:inline:SearchHistory.AutoGenerateRelationship + + // Generated using Sourcery + // DO NOT EDIT + public struct Relationship { + public let account: MastodonUser? + public let hashtag: Tag? + public let status: Status? + + public init( + account: MastodonUser?, + hashtag: Tag?, + status: Status? + ) { + self.account = account + self.hashtag = hashtag + self.status = status + } + } + + public func configure(relationship: Relationship) { + self.account = relationship.account + self.hashtag = relationship.hashtag + self.status = relationship.status + } + // sourcery:end +} + +// MARK: - AutoUpdatableObject +extension SearchHistory: AutoUpdatableObject { + // sourcery:inline:SearchHistory.AutoUpdatableObject + + // Generated using Sourcery + // DO NOT EDIT + public func update(updatedAt: Date) { + if self.updatedAt != updatedAt { + self.updatedAt = updatedAt + } + } + // sourcery:end +} diff --git a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Status.swift b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Status.swift new file mode 100644 index 000000000..d17d1c616 --- /dev/null +++ b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Status.swift @@ -0,0 +1,579 @@ +// +// Status.swift +// CoreDataStack +// +// Created by MainasuK Cirno on 2021/1/27. +// + +import CoreData +import Foundation + +public final class Status: NSManagedObject { + public typealias ID = String + + // sourcery: autoGenerateProperty + @NSManaged public private(set) var identifier: ID + // sourcery: autoGenerateProperty + @NSManaged public private(set) var domain: String + // sourcery: autoGenerateProperty + @NSManaged public private(set) var id: String + // sourcery: autoGenerateProperty + @NSManaged public private(set) var uri: String + + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public private(set) var createdAt: Date + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public private(set) var content: String + + @NSManaged public private(set) var visibilityRaw: String + // sourcery: autoUpdatableObject, autoGenerateProperty + public var visibility: MastodonVisibility { + get { + let rawValue = visibilityRaw + return MastodonVisibility(rawValue: rawValue) ?? ._other(rawValue) + } + set { + visibilityRaw = newValue.rawValue + } + } + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public private(set) var sensitive: Bool + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public private(set) var spoilerText: String? + + // sourcery: autoUpdatableObject + @NSManaged public private(set) var isContentSensitiveToggled: Bool + // sourcery: autoUpdatableObject + @NSManaged public private(set) var isMediaSensitiveToggled: Bool + + @NSManaged public private(set) var application: Application? + + // Informational + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public private(set) var reblogsCount: Int64 + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public private(set) var favouritesCount: Int64 + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public private(set) var repliesCount: Int64 + + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public private(set) var url: String? + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public private(set) var inReplyToID: Status.ID? + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public private(set) var inReplyToAccountID: MastodonUser.ID? + + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public private(set) var language: String? // (ISO 639 Part 1 two-letter language code) + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public private(set) var text: String? + + // many-to-one relationship + // sourcery: autoGenerateRelationship + @NSManaged public private(set) var author: MastodonUser + // sourcery: autoGenerateRelationship + @NSManaged public private(set) var reblog: Status? + // sourcery: autoUpdatableObject + @NSManaged public private(set) var replyTo: Status? + + // many-to-many relationship + @NSManaged public private(set) var favouritedBy: Set<MastodonUser> + @NSManaged public private(set) var rebloggedBy: Set<MastodonUser> + @NSManaged public private(set) var mutedBy: Set<MastodonUser> + @NSManaged public private(set) var bookmarkedBy: Set<MastodonUser> + + // one-to-one relationship + @NSManaged public private(set) var pinnedBy: MastodonUser? + // sourcery: autoGenerateRelationship + @NSManaged public private(set) var poll: Poll? + + // one-to-many relationship + @NSManaged public private(set) var feeds: Set<Feed> + + @NSManaged public private(set) var reblogFrom: Set<Status> + @NSManaged public private(set) var replyFrom: Set<Status> + @NSManaged public private(set) var notifications: Set<Notification> + @NSManaged public private(set) var searchHistories: Set<SearchHistory> + + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public private(set) var updatedAt: Date + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public private(set) var deletedAt: Date? + // sourcery: autoUpdatableObject + @NSManaged public private(set) var revealedAt: Date? +} + +extension Status { + // sourcery: autoUpdatableObject, autoGenerateProperty + @objc public var attachments: [MastodonAttachment] { + get { + let keyPath = #keyPath(Status.attachments) + willAccessValue(forKey: keyPath) + let _data = primitiveValue(forKey: keyPath) as? Data + didAccessValue(forKey: keyPath) + do { + guard let data = _data else { return [] } + let attachments = try JSONDecoder().decode([MastodonAttachment].self, from: data) + return attachments + } catch { + assertionFailure(error.localizedDescription) + return [] + } + } + set { + let keyPath = #keyPath(Status.attachments) + let data = try? JSONEncoder().encode(newValue) + willChangeValue(forKey: keyPath) + setPrimitiveValue(data, forKey: keyPath) + didChangeValue(forKey: keyPath) + } + } + + // sourcery: autoUpdatableObject, autoGenerateProperty + @objc public var emojis: [MastodonEmoji] { + get { + let keyPath = #keyPath(Status.emojis) + willAccessValue(forKey: keyPath) + let _data = primitiveValue(forKey: keyPath) as? Data + didAccessValue(forKey: keyPath) + do { + guard let data = _data else { return [] } + let emojis = try JSONDecoder().decode([MastodonEmoji].self, from: data) + return emojis + } catch { + assertionFailure(error.localizedDescription) + return [] + } + } + set { + let keyPath = #keyPath(Status.emojis) + let data = try? JSONEncoder().encode(newValue) + willChangeValue(forKey: keyPath) + setPrimitiveValue(data, forKey: keyPath) + didChangeValue(forKey: keyPath) + } + } + + // sourcery: autoUpdatableObject, autoGenerateProperty + @objc public var mentions: [MastodonMention] { + get { + let keyPath = #keyPath(Status.mentions) + willAccessValue(forKey: keyPath) + let _data = primitiveValue(forKey: keyPath) as? Data + didAccessValue(forKey: keyPath) + do { + guard let data = _data else { return [] } + let emojis = try JSONDecoder().decode([MastodonMention].self, from: data) + return emojis + } catch { + assertionFailure(error.localizedDescription) + return [] + } + } + set { + let keyPath = #keyPath(Status.mentions) + let data = try? JSONEncoder().encode(newValue) + willChangeValue(forKey: keyPath) + setPrimitiveValue(data, forKey: keyPath) + didChangeValue(forKey: keyPath) + } + } +} + +extension Status: FeedIndexable { } + +extension Status { + + @discardableResult + public static func insert( + into context: NSManagedObjectContext, + property: Property, + relationship: Relationship + ) -> Status { + let object: Status = context.insertObject() + + object.configure(property: property) + object.configure(relationship: relationship) + + return object + } + +} + +extension Status: Managed { + public static var defaultSortDescriptors: [NSSortDescriptor] { + return [NSSortDescriptor(keyPath: \Status.createdAt, ascending: false)] + } +} + +extension Status { + + static func predicate(domain: String) -> NSPredicate { + return NSPredicate(format: "%K == %@", #keyPath(Status.domain), domain) + } + + static func predicate(id: String) -> NSPredicate { + return NSPredicate(format: "%K == %@", #keyPath(Status.id), id) + } + + public static func predicate(domain: String, id: String) -> NSPredicate { + return NSCompoundPredicate(andPredicateWithSubpredicates: [ + predicate(domain: domain), + predicate(id: id) + ]) + } + + static func predicate(ids: [String]) -> NSPredicate { + return NSPredicate(format: "%K IN %@", #keyPath(Status.id), ids) + } + + public static func predicate(domain: String, ids: [String]) -> NSPredicate { + return NSCompoundPredicate(andPredicateWithSubpredicates: [ + predicate(domain: domain), + predicate(ids: ids) + ]) + } + + public static func notDeleted() -> NSPredicate { + return NSPredicate(format: "%K == nil", #keyPath(Status.deletedAt)) + } + + public static func deleted() -> NSPredicate { + return NSPredicate(format: "%K != nil", #keyPath(Status.deletedAt)) + } + +} + +// MARK: - AutoGenerateProperty +extension Status: AutoGenerateProperty { + // sourcery:inline:Status.AutoGenerateProperty + + // Generated using Sourcery + // DO NOT EDIT + public struct Property { + public let identifier: ID + public let domain: String + public let id: String + public let uri: String + public let createdAt: Date + public let content: String + public let visibility: MastodonVisibility + public let sensitive: Bool + public let spoilerText: String? + public let reblogsCount: Int64 + public let favouritesCount: Int64 + public let repliesCount: Int64 + public let url: String? + public let inReplyToID: Status.ID? + public let inReplyToAccountID: MastodonUser.ID? + public let language: String? + public let text: String? + public let updatedAt: Date + public let deletedAt: Date? + public let attachments: [MastodonAttachment] + public let emojis: [MastodonEmoji] + public let mentions: [MastodonMention] + + public init( + identifier: ID, + domain: String, + id: String, + uri: String, + createdAt: Date, + content: String, + visibility: MastodonVisibility, + sensitive: Bool, + spoilerText: String?, + reblogsCount: Int64, + favouritesCount: Int64, + repliesCount: Int64, + url: String?, + inReplyToID: Status.ID?, + inReplyToAccountID: MastodonUser.ID?, + language: String?, + text: String?, + updatedAt: Date, + deletedAt: Date?, + attachments: [MastodonAttachment], + emojis: [MastodonEmoji], + mentions: [MastodonMention] + ) { + self.identifier = identifier + self.domain = domain + self.id = id + self.uri = uri + self.createdAt = createdAt + self.content = content + self.visibility = visibility + self.sensitive = sensitive + self.spoilerText = spoilerText + self.reblogsCount = reblogsCount + self.favouritesCount = favouritesCount + self.repliesCount = repliesCount + self.url = url + self.inReplyToID = inReplyToID + self.inReplyToAccountID = inReplyToAccountID + self.language = language + self.text = text + self.updatedAt = updatedAt + self.deletedAt = deletedAt + self.attachments = attachments + self.emojis = emojis + self.mentions = mentions + } + } + + public func configure(property: Property) { + self.identifier = property.identifier + self.domain = property.domain + self.id = property.id + self.uri = property.uri + self.createdAt = property.createdAt + self.content = property.content + self.visibility = property.visibility + self.sensitive = property.sensitive + self.spoilerText = property.spoilerText + self.reblogsCount = property.reblogsCount + self.favouritesCount = property.favouritesCount + self.repliesCount = property.repliesCount + self.url = property.url + self.inReplyToID = property.inReplyToID + self.inReplyToAccountID = property.inReplyToAccountID + self.language = property.language + self.text = property.text + self.updatedAt = property.updatedAt + self.deletedAt = property.deletedAt + self.attachments = property.attachments + self.emojis = property.emojis + self.mentions = property.mentions + } + + public func update(property: Property) { + update(createdAt: property.createdAt) + update(content: property.content) + update(visibility: property.visibility) + update(sensitive: property.sensitive) + update(spoilerText: property.spoilerText) + update(reblogsCount: property.reblogsCount) + update(favouritesCount: property.favouritesCount) + update(repliesCount: property.repliesCount) + update(url: property.url) + update(inReplyToID: property.inReplyToID) + update(inReplyToAccountID: property.inReplyToAccountID) + update(language: property.language) + update(text: property.text) + update(updatedAt: property.updatedAt) + update(deletedAt: property.deletedAt) + update(attachments: property.attachments) + update(emojis: property.emojis) + update(mentions: property.mentions) + } + // sourcery:end +} + +// MARK: - AutoGenerateRelationship +extension Status: AutoGenerateRelationship { + // sourcery:inline:Status.AutoGenerateRelationship + + // Generated using Sourcery + // DO NOT EDIT + public struct Relationship { + public let author: MastodonUser + public let reblog: Status? + public let poll: Poll? + + public init( + author: MastodonUser, + reblog: Status?, + poll: Poll? + ) { + self.author = author + self.reblog = reblog + self.poll = poll + } + } + + public func configure(relationship: Relationship) { + self.author = relationship.author + self.reblog = relationship.reblog + self.poll = relationship.poll + } + // sourcery:end +} + +// MARK: - AutoUpdatableObject +extension Status: AutoUpdatableObject { + // sourcery:inline:Status.AutoUpdatableObject + + // Generated using Sourcery + // DO NOT EDIT + public func update(createdAt: Date) { + if self.createdAt != createdAt { + self.createdAt = createdAt + } + } + public func update(content: String) { + if self.content != content { + self.content = content + } + } + public func update(visibility: MastodonVisibility) { + if self.visibility != visibility { + self.visibility = visibility + } + } + public func update(sensitive: Bool) { + if self.sensitive != sensitive { + self.sensitive = sensitive + } + } + public func update(spoilerText: String?) { + if self.spoilerText != spoilerText { + self.spoilerText = spoilerText + } + } + public func update(isContentSensitiveToggled: Bool) { + if self.isContentSensitiveToggled != isContentSensitiveToggled { + self.isContentSensitiveToggled = isContentSensitiveToggled + } + } + public func update(isMediaSensitiveToggled: Bool) { + if self.isMediaSensitiveToggled != isMediaSensitiveToggled { + self.isMediaSensitiveToggled = isMediaSensitiveToggled + } + } + public func update(reblogsCount: Int64) { + if self.reblogsCount != reblogsCount { + self.reblogsCount = reblogsCount + } + } + public func update(favouritesCount: Int64) { + if self.favouritesCount != favouritesCount { + self.favouritesCount = favouritesCount + } + } + public func update(repliesCount: Int64) { + if self.repliesCount != repliesCount { + self.repliesCount = repliesCount + } + } + public func update(url: String?) { + if self.url != url { + self.url = url + } + } + public func update(inReplyToID: Status.ID?) { + if self.inReplyToID != inReplyToID { + self.inReplyToID = inReplyToID + } + } + public func update(inReplyToAccountID: MastodonUser.ID?) { + if self.inReplyToAccountID != inReplyToAccountID { + self.inReplyToAccountID = inReplyToAccountID + } + } + public func update(language: String?) { + if self.language != language { + self.language = language + } + } + public func update(text: String?) { + if self.text != text { + self.text = text + } + } + public func update(replyTo: Status?) { + if self.replyTo != replyTo { + self.replyTo = replyTo + } + } + public func update(updatedAt: Date) { + if self.updatedAt != updatedAt { + self.updatedAt = updatedAt + } + } + public func update(deletedAt: Date?) { + if self.deletedAt != deletedAt { + self.deletedAt = deletedAt + } + } + public func update(revealedAt: Date?) { + if self.revealedAt != revealedAt { + self.revealedAt = revealedAt + } + } + public func update(attachments: [MastodonAttachment]) { + if self.attachments != attachments { + self.attachments = attachments + } + } + public func update(emojis: [MastodonEmoji]) { + if self.emojis != emojis { + self.emojis = emojis + } + } + public func update(mentions: [MastodonMention]) { + if self.mentions != mentions { + self.mentions = mentions + } + } + // sourcery:end + + public func update(liked: Bool, by mastodonUser: MastodonUser) { + if liked { + if !self.favouritedBy.contains(mastodonUser) { + self.mutableSetValue(forKey: #keyPath(Status.favouritedBy)).add(mastodonUser) + } + } else { + if self.favouritedBy.contains(mastodonUser) { + self.mutableSetValue(forKey: #keyPath(Status.favouritedBy)).remove(mastodonUser) + } + } + } + + public func update(reblogged: Bool, by mastodonUser: MastodonUser) { + if reblogged { + if !self.rebloggedBy.contains(mastodonUser) { + self.mutableSetValue(forKey: #keyPath(Status.rebloggedBy)).add(mastodonUser) + } + } else { + if self.rebloggedBy.contains(mastodonUser) { + self.mutableSetValue(forKey: #keyPath(Status.rebloggedBy)).remove(mastodonUser) + } + } + } + + public func update(muted: Bool, by mastodonUser: MastodonUser) { + if muted { + if !self.mutedBy.contains(mastodonUser) { + self.mutableSetValue(forKey: #keyPath(Status.mutedBy)).add(mastodonUser) + } + } else { + if self.mutedBy.contains(mastodonUser) { + self.mutableSetValue(forKey: #keyPath(Status.mutedBy)).remove(mastodonUser) + } + } + } + + public func update(bookmarked: Bool, by mastodonUser: MastodonUser) { + if bookmarked { + if !self.bookmarkedBy.contains(mastodonUser) { + self.mutableSetValue(forKey: #keyPath(Status.bookmarkedBy)).add(mastodonUser) + } + } else { + if self.bookmarkedBy.contains(mastodonUser) { + self.mutableSetValue(forKey: #keyPath(Status.bookmarkedBy)).remove(mastodonUser) + } + } + } + + public func update(isReveal: Bool) { + revealedAt = isReveal ? Date() : nil + } +} + +extension Status { + public func attach(feed: Feed) { + mutableSetValue(forKey: #keyPath(Status.feeds)).add(feed) + } +} diff --git a/CoreDataStack/Entity/Subscription.swift b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Subscription.swift similarity index 100% rename from CoreDataStack/Entity/Subscription.swift rename to MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Subscription.swift diff --git a/CoreDataStack/Entity/SubscriptionAlerts.swift b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/SubscriptionAlerts.swift similarity index 100% rename from CoreDataStack/Entity/SubscriptionAlerts.swift rename to MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/SubscriptionAlerts.swift diff --git a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Tag.swift b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Tag.swift new file mode 100644 index 000000000..b5c335db3 --- /dev/null +++ b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Tag.swift @@ -0,0 +1,218 @@ +// +// Tag.swift +// CoreDataStack +// +// Created by sxiaojian on 2021/2/1. +// + +import CoreData +import Foundation + +public final class Tag: NSManagedObject { + public typealias ID = UUID + + // sourcery: autoGenerateProperty + @NSManaged public private(set) var identifier: ID + // sourcery: autoGenerateProperty + @NSManaged public private(set) var domain: String + // sourcery: autoGenerateProperty + @NSManaged public private(set) var createAt: Date + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public private(set) var updatedAt: Date + + // sourcery: autoGenerateProperty + @NSManaged public private(set) var name: String + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public private(set) var url: String + + // one-to-one relationship + + // many-to-many relationship + + // one-to-many relationship + @NSManaged public private(set) var searchHistories: Set<SearchHistory> +} + +extension Tag { + // sourcery: autoUpdatableObject, autoGenerateProperty + @objc public var histories: [MastodonTagHistory] { + get { + let keyPath = #keyPath(Tag.histories) + willAccessValue(forKey: keyPath) + let _data = primitiveValue(forKey: keyPath) as? Data + didAccessValue(forKey: keyPath) + do { + guard let data = _data else { return [] } + let attachments = try JSONDecoder().decode([MastodonTagHistory].self, from: data) + return attachments + } catch { + assertionFailure(error.localizedDescription) + return [] + } + } + set { + let keyPath = #keyPath(Tag.histories) + let data = try? JSONEncoder().encode(newValue) + willChangeValue(forKey: keyPath) + setPrimitiveValue(data, forKey: keyPath) + didChangeValue(forKey: keyPath) + } + } +} + +extension Tag { + @discardableResult + public static func insert( + into context: NSManagedObjectContext, + property: Property + ) -> Tag { + let object: Tag = context.insertObject() + + object.configure(property: property) + + return object + } +} + + +extension Tag: Managed { + public static var defaultSortDescriptors: [NSSortDescriptor] { + [NSSortDescriptor(keyPath: \Tag.createAt, ascending: false)] + } +} + +public extension Tag { + + static func predicate(domain: String) -> NSPredicate { + NSPredicate(format: "%K == %@", #keyPath(Tag.domain), domain) + } + + static func predicate(name: String) -> NSPredicate { + NSPredicate(format: "%K == %@", #keyPath(Tag.name), name) + } + + static func predicate(domain: String, name: String) -> NSPredicate { + NSCompoundPredicate(andPredicateWithSubpredicates: [ + predicate(domain: domain), + predicate(name: name), + ]) + } +} + +// MARK: - AutoGenerateProperty +extension Tag: AutoGenerateProperty { + // sourcery:inline:Tag.AutoGenerateProperty + + // Generated using Sourcery + // DO NOT EDIT + public struct Property { + public let identifier: ID + public let domain: String + public let createAt: Date + public let updatedAt: Date + public let name: String + public let url: String + public let histories: [MastodonTagHistory] + + public init( + identifier: ID, + domain: String, + createAt: Date, + updatedAt: Date, + name: String, + url: String, + histories: [MastodonTagHistory] + ) { + self.identifier = identifier + self.domain = domain + self.createAt = createAt + self.updatedAt = updatedAt + self.name = name + self.url = url + self.histories = histories + } + } + + public func configure(property: Property) { + self.identifier = property.identifier + self.domain = property.domain + self.createAt = property.createAt + self.updatedAt = property.updatedAt + self.name = property.name + self.url = property.url + self.histories = property.histories + } + + public func update(property: Property) { + update(updatedAt: property.updatedAt) + update(url: property.url) + update(histories: property.histories) + } + // sourcery:end +} + +// MARK: - AutoUpdatableObject +extension Tag: AutoUpdatableObject { + // sourcery:inline:Tag.AutoUpdatableObject + + // Generated using Sourcery + // DO NOT EDIT + public func update(updatedAt: Date) { + if self.updatedAt != updatedAt { + self.updatedAt = updatedAt + } + } + public func update(url: String) { + if self.url != url { + self.url = url + } + } + public func update(histories: [MastodonTagHistory]) { + if self.histories != histories { + self.histories = histories + } + } + // sourcery:end +} + + +extension Tag { + + public func findSearchHistory(domain: String, userID: MastodonUser.ID) -> SearchHistory? { + return searchHistories.first { searchHistory in + return searchHistory.domain == domain + && searchHistory.userID == userID + } + } + + public func findSearchHistory(for user: MastodonUser) -> SearchHistory? { + return searchHistories.first { searchHistory in + return searchHistory.domain == user.domain + && searchHistory.userID == user.id + } + } + +} + +public extension Tag { +// func updateHistory(index: Int, day: Date, uses: String, account: String) { +// let histories = self.histories.sorted { +// $0.createAt.compare($1.createAt) == .orderedAscending +// } +// guard index < histories.count else { return } +// let history = histories[index] +// history.update(day: day) +// history.update(uses: uses) +// history.update(accounts: account) +// } +// +// func appendHistory(history: History) { +// self.mutableSetValue(forKeyPath: #keyPath(Tag.histories)).add(history) +// } +// +// func update(url: String) { +// if self.url != url { +// self.url = url +// } +// } +} diff --git a/MastodonSDK/Sources/CoreDataStack/Entity/Transient/Acct.swift b/MastodonSDK/Sources/CoreDataStack/Entity/Transient/Acct.swift new file mode 100644 index 000000000..fe59bb9d4 --- /dev/null +++ b/MastodonSDK/Sources/CoreDataStack/Entity/Transient/Acct.swift @@ -0,0 +1,46 @@ +// +// Feed+Acct.swift +// Feed+Acct +// +// Created by Cirno MainasuK on 2021-8-26. +// Copyright © 2021 Twidere. All rights reserved. +// + +import Foundation + +extension Feed { + public enum Acct: RawRepresentable { + case none + case mastodon(domain: String, userID: MastodonUser.ID) + + public init?(rawValue: String) { + let components = rawValue.split(separator: "@", maxSplits: 2) + guard components.count == 3 else { return nil } + let userID = String(components[1]).escape + let domain = String(components[2]).escape + + switch components[0] { + case "M": + self = .mastodon(domain: domain, userID: userID) + default: + self = .none + } + + } + + public var rawValue: String { + switch self { + case .none: + return "none@userID@domain" + case .mastodon(let domain, let userID): + return "M@\(userID.escape)@\(domain.escape)" + } + } + } +} + +extension String { + fileprivate var escape: String { + replacingOccurrences(of: "@", with: "_at_") + } +} diff --git a/MastodonSDK/Sources/CoreDataStack/Entity/Transient/Feed+Kind.swift b/MastodonSDK/Sources/CoreDataStack/Entity/Transient/Feed+Kind.swift new file mode 100644 index 000000000..de32d9490 --- /dev/null +++ b/MastodonSDK/Sources/CoreDataStack/Entity/Transient/Feed+Kind.swift @@ -0,0 +1,17 @@ +// +// Feed+Kind.swift +// CoreDataStack +// +// Created by MainasuK on 2022-1-11. +// + +import Foundation + +extension Feed { + public enum Kind: String, CaseIterable, Hashable { + case none + case home + case notificationAll + case notificationMentions + } +} diff --git a/MastodonSDK/Sources/CoreDataStack/Entity/Transient/MastodonAttachment.swift b/MastodonSDK/Sources/CoreDataStack/Entity/Transient/MastodonAttachment.swift new file mode 100644 index 000000000..aa25ada19 --- /dev/null +++ b/MastodonSDK/Sources/CoreDataStack/Entity/Transient/MastodonAttachment.swift @@ -0,0 +1,58 @@ +// +// MastodonAttachment.swift +// MastodonAttachment +// +// Created by Cirno MainasuK on 2021-8-30. +// Copyright © 2021 Twidere. All rights reserved. +// + +import Foundation +import CoreGraphics + +public final class MastodonAttachment: NSObject, Codable { + public typealias ID = String + + public let id: ID + public let kind: Kind + public let size: CGSize + public let focus: CGPoint? + public let blurhash: String? + public let assetURL: String? + public let previewURL: String? + public let textURL: String? + public let durationMS: Int? + public let altDescription: String? + + public init( + id: MastodonAttachment.ID, + kind: MastodonAttachment.Kind, + size: CGSize, + focus: CGPoint?, + blurhash: String?, + assetURL: String?, + previewURL: String?, + textURL: String?, + durationMS: Int?, + altDescription: String? + ) { + self.id = id + self.kind = kind + self.size = size + self.focus = focus + self.blurhash = blurhash + self.assetURL = assetURL + self.previewURL = previewURL + self.textURL = textURL + self.durationMS = durationMS + self.altDescription = altDescription + } +} + +extension MastodonAttachment { + public enum Kind: String, Codable { + case image + case video + case gifv + case audio + } +} diff --git a/MastodonSDK/Sources/CoreDataStack/Entity/Transient/MastodonEmoji.swift b/MastodonSDK/Sources/CoreDataStack/Entity/Transient/MastodonEmoji.swift new file mode 100644 index 000000000..b067849c6 --- /dev/null +++ b/MastodonSDK/Sources/CoreDataStack/Entity/Transient/MastodonEmoji.swift @@ -0,0 +1,30 @@ +// +// MastodonEmoji.swift +// MastodonEmoji +// +// Created by Cirno MainasuK on 2021-9-2. +// Copyright © 2021 Twidere. All rights reserved. +// + +import Foundation + +public final class MastodonEmoji: NSObject, Codable { + public let code: String + public let url: String + public let staticURL: String + public let visibleInPicker: Bool + public let category: String? + + public init(code: + String, url: + String, staticURL: + String, visibleInPicker: + Bool, category: String? + ) { + self.code = code + self.url = url + self.staticURL = staticURL + self.visibleInPicker = visibleInPicker + self.category = category + } +} diff --git a/MastodonSDK/Sources/CoreDataStack/Entity/Transient/MastodonField.swift b/MastodonSDK/Sources/CoreDataStack/Entity/Transient/MastodonField.swift new file mode 100644 index 000000000..507f6f9a3 --- /dev/null +++ b/MastodonSDK/Sources/CoreDataStack/Entity/Transient/MastodonField.swift @@ -0,0 +1,25 @@ +// +// MastodonField.swift +// CoreDataStack +// +// Created by Cirno MainasuK on 2021-9-18. +// Copyright © 2021 Twidere. All rights reserved. +// + +import Foundation + +public final class MastodonField: NSObject, Codable { + public let name: String + public let value: String + public let verifiedAt: Date? + + public init( + name: String, + value: String, + verifiedAt: Date? + ) { + self.name = name + self.value = value + self.verifiedAt = verifiedAt + } +} diff --git a/MastodonSDK/Sources/CoreDataStack/Entity/Transient/MastodonMention.swift b/MastodonSDK/Sources/CoreDataStack/Entity/Transient/MastodonMention.swift new file mode 100644 index 000000000..ee53222c4 --- /dev/null +++ b/MastodonSDK/Sources/CoreDataStack/Entity/Transient/MastodonMention.swift @@ -0,0 +1,31 @@ +// +// MastodonMention.swift +// CoreDataStack +// +// Created by MainasuK on 2022-1-17. +// + +import Foundation + +public final class MastodonMention: NSObject, Codable { + + public typealias ID = String + + public let id: ID + public let username: String + public let acct: String + public let url: String + + public init( + id: MastodonMention.ID, + username: String, + acct: String, + url: String + ) { + self.id = id + self.username = username + self.acct = acct + self.url = url + } + +} diff --git a/MastodonSDK/Sources/CoreDataStack/Entity/Transient/MastodonNotificationType.swift b/MastodonSDK/Sources/CoreDataStack/Entity/Transient/MastodonNotificationType.swift new file mode 100644 index 000000000..a982fda93 --- /dev/null +++ b/MastodonSDK/Sources/CoreDataStack/Entity/Transient/MastodonNotificationType.swift @@ -0,0 +1,46 @@ +// +// MastodonNotificationType.swift +// CoreDataStack +// +// Created by MainasuK on 2022-1-21. +// + +import Foundation + +public enum MastodonNotificationType: RawRepresentable { + case follow + case followRequest + case mention + case reblog + case favourite // same to API + case poll + case status + + case _other(String) + + public init?(rawValue: String) { + switch rawValue { + case "follow": self = .follow + case "followRequest": self = .followRequest + case "mention": self = .mention + case "reblog": self = .reblog + case "favourite": self = .favourite + case "poll": self = .poll + case "status": self = .status + default: self = ._other(rawValue) + } + } + + public var rawValue: String { + switch self { + case .follow: return "follow" + case .followRequest: return "followRequest" + case .mention: return "mention" + case .reblog: return "reblog" + case .favourite: return "favourite" + case .poll: return "poll" + case .status: return "status" + case ._other(let value): return value + } + } +} diff --git a/MastodonSDK/Sources/CoreDataStack/Entity/Transient/MastodonTagHistory.swift b/MastodonSDK/Sources/CoreDataStack/Entity/Transient/MastodonTagHistory.swift new file mode 100644 index 000000000..f2d1cf712 --- /dev/null +++ b/MastodonSDK/Sources/CoreDataStack/Entity/Transient/MastodonTagHistory.swift @@ -0,0 +1,24 @@ +// +// MastodonTagHistory.swift +// CoreDataStack +// +// Created by MainasuK on 2022-1-20. +// + +import Foundation + +public final class MastodonTagHistory: NSObject, Codable { + + /// UNIX timestamp on midnight of the given day + public let day: Date + public let uses: String + public let accounts: String + + public init(day: Date, uses: String, accounts: String) { + self.day = day + self.uses = uses + self.accounts = accounts + } + +} + diff --git a/MastodonSDK/Sources/CoreDataStack/Entity/Transient/MastodonVisibility.swift b/MastodonSDK/Sources/CoreDataStack/Entity/Transient/MastodonVisibility.swift new file mode 100644 index 000000000..798db208a --- /dev/null +++ b/MastodonSDK/Sources/CoreDataStack/Entity/Transient/MastodonVisibility.swift @@ -0,0 +1,38 @@ +// +// MastodonVisibility.swift +// MastodonVisibility +// +// Created by Cirno MainasuK on 2021-8-27. +// Copyright © 2021 Twidere. All rights reserved. +// + +import Foundation + +public enum MastodonVisibility: RawRepresentable { + case `public` + case unlisted + case `private` + case direct + + case _other(String) + + public init?(rawValue: String) { + switch rawValue { + case "public": self = .public + case "unlisted": self = .unlisted + case "private": self = .private + case "direct": self = .direct + default: self = ._other(rawValue) + } + } + + public var rawValue: String { + switch self { + case .public: return "public" + case .unlisted: return "unlisted" + case .private: return "private" + case .direct: return "direct" + case ._other(let value): return value + } + } +} diff --git a/CoreDataStack/Extension/Collection.swift b/MastodonSDK/Sources/CoreDataStack/Extension/Collection.swift similarity index 100% rename from CoreDataStack/Extension/Collection.swift rename to MastodonSDK/Sources/CoreDataStack/Extension/Collection.swift diff --git a/MastodonSDK/Sources/CoreDataStack/Extension/NSManagedObjectContext.swift b/MastodonSDK/Sources/CoreDataStack/Extension/NSManagedObjectContext.swift new file mode 100644 index 000000000..b921de819 --- /dev/null +++ b/MastodonSDK/Sources/CoreDataStack/Extension/NSManagedObjectContext.swift @@ -0,0 +1,112 @@ +// +// NSManagedObjectContext.swift +// CoreDataStack +// +// Created by Cirno MainasuK on 2020-8-10. +// + +import os +import Foundation +import Combine +import CoreData + +extension NSManagedObjectContext { + public func insert<T: NSManagedObject>() -> T where T: Managed { + guard let object = NSEntityDescription.insertNewObject(forEntityName: T.entityName, into: self) as? T else { + fatalError("cannot insert object: \(T.self)") + } + + return object + } + + public func saveOrRollback() throws { + do { + guard hasChanges else { + return + } + try save() + } catch { + rollback() + + os_log("%{public}s[%{public}ld], %{public}s: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + throw error + } + } + + public func performChanges(block: @escaping () -> Void) -> Future<Result<Void, Error>, Never> { + Future { promise in + self.perform { + block() + do { + try self.saveOrRollback() + promise(.success(Result.success(()))) + } catch { + promise(.success(Result.failure(error))) + } + } + } + } +} + +extension NSManagedObjectContext { + public func perform<T>(block: @escaping () throws -> T) async throws -> T { + if #available(iOS 15.0, *) { + return try await perform(schedule: .enqueued) { + try block() + } + } else { + return try await withCheckedThrowingContinuation { continuation in + self.perform { + do { + let value = try block() + continuation.resume(returning: value) + } catch { + continuation.resume(throwing: error) + } + } + } // end return + } + } + + public func performChanges<T>(block: @escaping () throws -> T) async throws -> T { + if #available(iOS 15.0, *) { + return try await perform(schedule: .enqueued) { + let value = try block() + try self.saveOrRollback() + return value + } + } else { + return try await withCheckedThrowingContinuation { continuation in + self.perform { + do { + let value = try block() + try self.saveOrRollback() + continuation.resume(returning: value) + } catch { + continuation.resume(throwing: error) + } + } + } // end return + } + } // end func +} + +extension NSManagedObjectContext { + static let objectCacheKey = "ObjectCacheKey" + private typealias ObjectCache = [String: NSManagedObject] + + public func cache( + _ object: NSManagedObject?, + key: String + ) { + var cache = userInfo[NSManagedObjectContext.objectCacheKey] as? ObjectCache ?? [:] + cache[key] = object + userInfo[NSManagedObjectContext.objectCacheKey] = cache + } + + public func cache(froKey key: String) -> NSManagedObject? { + guard let cache = userInfo[NSManagedObjectContext.objectCacheKey] as? ObjectCache + else { return nil } + return cache[key] + } +} diff --git a/CoreDataStack/Extension/UIFont.swift b/MastodonSDK/Sources/CoreDataStack/Extension/UIFont.swift similarity index 100% rename from CoreDataStack/Extension/UIFont.swift rename to MastodonSDK/Sources/CoreDataStack/Extension/UIFont.swift diff --git a/CoreDataStack/Extension/URL.swift b/MastodonSDK/Sources/CoreDataStack/Extension/URL.swift similarity index 100% rename from CoreDataStack/Extension/URL.swift rename to MastodonSDK/Sources/CoreDataStack/Extension/URL.swift diff --git a/CoreDataStack/Protocol/Managed.swift b/MastodonSDK/Sources/CoreDataStack/Protocol/Managed.swift similarity index 100% rename from CoreDataStack/Protocol/Managed.swift rename to MastodonSDK/Sources/CoreDataStack/Protocol/Managed.swift diff --git a/CoreDataStack/Protocol/NetworkUpdatable.swift b/MastodonSDK/Sources/CoreDataStack/Protocol/NetworkUpdatable.swift similarity index 100% rename from CoreDataStack/Protocol/NetworkUpdatable.swift rename to MastodonSDK/Sources/CoreDataStack/Protocol/NetworkUpdatable.swift diff --git a/CoreDataStack/Stack/ManagedObjectContextObjectsDidChange.swift b/MastodonSDK/Sources/CoreDataStack/Stack/ManagedObjectContextObjectsDidChange.swift similarity index 93% rename from CoreDataStack/Stack/ManagedObjectContextObjectsDidChange.swift rename to MastodonSDK/Sources/CoreDataStack/Stack/ManagedObjectContextObjectsDidChange.swift index 980a2a5e1..33cbf08d6 100644 --- a/CoreDataStack/Stack/ManagedObjectContextObjectsDidChange.swift +++ b/MastodonSDK/Sources/CoreDataStack/Stack/ManagedObjectContextObjectsDidChange.swift @@ -10,10 +10,10 @@ import CoreData public struct ManagedObjectContextObjectsDidChangeNotification { - public let notification: Notification + public let notification: Foundation.Notification public let managedObjectContext: NSManagedObjectContext - public init?(notification: Notification) { + public init?(notification: Foundation.Notification) { guard notification.name == .NSManagedObjectContextObjectsDidChange, let managedObjectContext = notification.object as? NSManagedObjectContext else { return nil diff --git a/CoreDataStack/Stack/ManagedObjectObserver.swift b/MastodonSDK/Sources/CoreDataStack/Stack/ManagedObjectObserver.swift similarity index 55% rename from CoreDataStack/Stack/ManagedObjectObserver.swift rename to MastodonSDK/Sources/CoreDataStack/Stack/ManagedObjectObserver.swift index 3681fee95..c1fbb5b82 100644 --- a/CoreDataStack/Stack/ManagedObjectObserver.swift +++ b/MastodonSDK/Sources/CoreDataStack/Stack/ManagedObjectObserver.swift @@ -2,7 +2,8 @@ // ManagedObjectObserver.swift // CoreDataStack // -// Created by sxiaojian on 2021/2/8. +// Created by Cirno MainasuK on 2020-6-12. +// Copyright © 2020 Dimension. All rights reserved. // import Foundation @@ -15,6 +16,26 @@ final public class ManagedObjectObserver { extension ManagedObjectObserver { + public static func observe(context: NSManagedObjectContext) -> AnyPublisher<Changes, Error> { + + return NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange, object: context) + .tryMap { notification in + guard let notification = ManagedObjectContextObjectsDidChangeNotification(notification: notification) else { + throw Error.notManagedObjectChangeNotification + } + + let changeTypes = ManagedObjectObserver.changeTypes(in: notification) + return Changes( + changeTypes: changeTypes, + changeNotification: notification + ) + } + .mapError { error -> Error in + return (error as? Error) ?? .unknown(error) + } + .eraseToAnyPublisher() + } + public static func observe(object: NSManagedObject) -> AnyPublisher<Change, Error> { guard let context = object.managedObjectContext else { return Fail(error: .noManagedObjectContext).eraseToAnyPublisher() @@ -41,10 +62,26 @@ extension ManagedObjectObserver { } extension ManagedObjectObserver { + private static func changeTypes(in notification: ManagedObjectContextObjectsDidChangeNotification) -> [ChangeType] { + var changeTypes: [ChangeType] = [] + + let deleted = notification.deletedObjects.union(notification.invalidedObjects) + for object in deleted { + changeTypes.append(.delete(object)) + } + + let updated = notification.updatedObjects.union(notification.refreshedObjects) + for object in updated { + changeTypes.append(.update(object)) + } + + return changeTypes + } + private static func changeType(of object: NSManagedObject, in notification: ManagedObjectContextObjectsDidChangeNotification) -> ChangeType? { let deleted = notification.deletedObjects.union(notification.invalidedObjects) if notification.invalidatedAllObjects || deleted.contains(where: { $0 === object }) { - return .delete + return .delete(object) } let updated = notification.updatedObjects.union(notification.refreshedObjects) @@ -57,6 +94,16 @@ extension ManagedObjectObserver { } extension ManagedObjectObserver { + public struct Changes { + public let changeTypes: [ChangeType] + public let changeNotification: ManagedObjectContextObjectsDidChangeNotification + + init(changeTypes: [ManagedObjectObserver.ChangeType], changeNotification: ManagedObjectContextObjectsDidChangeNotification) { + self.changeTypes = changeTypes + self.changeNotification = changeNotification + } + } + public struct Change { public let changeType: ChangeType? public let changeNotification: ManagedObjectContextObjectsDidChangeNotification @@ -65,10 +112,10 @@ extension ManagedObjectObserver { self.changeType = changeType self.changeNotification = changeNotification } - } + public enum ChangeType { - case delete + case delete(NSManagedObject) case update(NSManagedObject) } diff --git a/MastodonSDK/Sources/CoreDataStack/Template/AutoGenerateProperty.swift b/MastodonSDK/Sources/CoreDataStack/Template/AutoGenerateProperty.swift new file mode 100644 index 000000000..e36b93690 --- /dev/null +++ b/MastodonSDK/Sources/CoreDataStack/Template/AutoGenerateProperty.swift @@ -0,0 +1,14 @@ +// +// AutoGenerateProperty.swift +// AutoGenerateProperty +// +// Created by Cirno MainasuK on 2021-8-18. +// Copyright © 2021 Twidere. All rights reserved. +// + +import Foundation + +// Stencil protocol +protocol AutoGenerateProperty { } + +// - autoGenerateProperty diff --git a/MastodonSDK/Sources/CoreDataStack/Template/AutoGenerateRelationship.swift b/MastodonSDK/Sources/CoreDataStack/Template/AutoGenerateRelationship.swift new file mode 100644 index 000000000..caeed0deb --- /dev/null +++ b/MastodonSDK/Sources/CoreDataStack/Template/AutoGenerateRelationship.swift @@ -0,0 +1,14 @@ +// +// AutoGenerateRelationship.swift +// AutoGenerateRelationship +// +// Created by Cirno MainasuK on 2021-8-19. +// Copyright © 2021 Twidere. All rights reserved. +// + +import Foundation + +// Stencil protocol +protocol AutoGenerateRelationship { } + +// - autoGenerateRelationship diff --git a/MastodonSDK/Sources/CoreDataStack/Template/AutoUpdatableObject.swift b/MastodonSDK/Sources/CoreDataStack/Template/AutoUpdatableObject.swift new file mode 100644 index 000000000..ad031db2a --- /dev/null +++ b/MastodonSDK/Sources/CoreDataStack/Template/AutoUpdatableObject.swift @@ -0,0 +1,14 @@ +// +// AutoUpdatableObject.swift +// AutoUpdatableObject +// +// Created by Cirno MainasuK on 2021-8-18. +// Copyright © 2021 Twidere. All rights reserved. +// + +import Foundation + +// Stencil protocol +protocol AutoUpdatableObject { } + +// - autoUpdatableObject diff --git a/MastodonSDK/Sources/CoreDataStack/Template/Stencil/AutoGenerateProperty.stencil b/MastodonSDK/Sources/CoreDataStack/Template/Stencil/AutoGenerateProperty.stencil new file mode 100644 index 000000000..2c14bab23 --- /dev/null +++ b/MastodonSDK/Sources/CoreDataStack/Template/Stencil/AutoGenerateProperty.stencil @@ -0,0 +1,45 @@ +{% for type in types.implementing.AutoGenerateProperty %} +// sourcery:inline:{{type.name}}.AutoGenerateProperty + +// Generated using Sourcery +// DO NOT EDIT +public struct Property { + {% for variable in type.variables|instance where + variable|annotated:"autoGenerateProperty" + %} + public let {{variable.name}}: {{variable.typeName}} + {% endfor %} + + public init( + {% for variable in type.variables|instance where + variable|annotated:"autoGenerateProperty" + %} + {{variable.name}}: {{variable.typeName}}{% if not forloop.last %},{% endif %} + {% endfor %} + ) { + {% for variable in type.variables|instance where + variable|annotated:"autoGenerateProperty" + %} + self.{{variable.name}} = {{variable.name}} + {% endfor %} + } +} + +public func configure(property: Property) { + {% for variable in type.variables|instance where + variable|annotated:"autoGenerateProperty" + %} + self.{{variable.name}} = property.{{variable.name}} + {% endfor %} +} + +public func update(property: Property) { + {% for variable in type.variables|instance where + variable|annotated:"autoUpdatableObject" and + variable|annotated:"autoGenerateProperty" + %} + update({{variable.name}}: property.{{variable.name}}) + {% endfor %} +} +// sourcery:end +{% endfor %} diff --git a/MastodonSDK/Sources/CoreDataStack/Template/Stencil/AutoGenerateRelationship.stencil b/MastodonSDK/Sources/CoreDataStack/Template/Stencil/AutoGenerateRelationship.stencil new file mode 100644 index 000000000..8b5490238 --- /dev/null +++ b/MastodonSDK/Sources/CoreDataStack/Template/Stencil/AutoGenerateRelationship.stencil @@ -0,0 +1,29 @@ +{% for type in types.implementing.AutoGenerateRelationship %} +// sourcery:inline:{{type.name}}.AutoGenerateRelationship + +// Generated using Sourcery +// DO NOT EDIT +public struct Relationship { + {% for variable in type.storedVariables|annotated:"autoGenerateRelationship" %} + public let {{variable.name}}: {{variable.typeName}} + {% endfor %} + + public init( + {% for variable in type.storedVariables|annotated:"autoGenerateRelationship" %} + {{variable.name}}: {{variable.typeName}}{% if not forloop.last %},{% endif %} + {% endfor %} + ) { + {% for variable in type.storedVariables|annotated:"autoGenerateRelationship" %} + self.{{variable.name}} = {{variable.name}} + {% endfor %} + } +} + +public func configure(relationship: Relationship) { + {% for variable in type.storedVariables|annotated:"autoGenerateRelationship" %} + self.{{variable.name}} = relationship.{{variable.name}} + {% endfor %} +} + +// sourcery:end +{% endfor %} diff --git a/MastodonSDK/Sources/CoreDataStack/Template/Stencil/AutoUpdatableObject.stencil b/MastodonSDK/Sources/CoreDataStack/Template/Stencil/AutoUpdatableObject.stencil new file mode 100644 index 000000000..4e81c8b44 --- /dev/null +++ b/MastodonSDK/Sources/CoreDataStack/Template/Stencil/AutoUpdatableObject.stencil @@ -0,0 +1,16 @@ +{% for type in types.implementing.AutoUpdatableObject %} +// sourcery:inline:{{type.name}}.AutoUpdatableObject + +// Generated using Sourcery +// DO NOT EDIT +{% for variable in type.variables|instance where +variable|annotated:"autoUpdatableObject" +%} +public func update({{variable.name}}: {{variable.typeName}}) { + if self.{{variable.name}} != {{variable.name}} { + self.{{variable.name}} = {{variable.name}} + } +} +{% endfor %} +// sourcery:end +{% endfor %} diff --git a/MastodonSDK/Sources/CoreDataStack/Utility/ManagedObjectRecord.swift b/MastodonSDK/Sources/CoreDataStack/Utility/ManagedObjectRecord.swift new file mode 100644 index 000000000..ea087d894 --- /dev/null +++ b/MastodonSDK/Sources/CoreDataStack/Utility/ManagedObjectRecord.swift @@ -0,0 +1,32 @@ +// +// ManagedObjectRecord.swift +// ManagedObjectRecord +// +// Created by Cirno MainasuK on 2021-8-25. +// Copyright © 2021 Twidere. All rights reserved. +// + +import Foundation +import CoreData + +public class ManagedObjectRecord<T: NSFetchRequestResult>: Hashable { + + public let objectID: NSManagedObjectID + + public init(objectID: NSManagedObjectID) { + self.objectID = objectID + } + + public func object(in managedObjectContext: NSManagedObjectContext) -> T? { + return managedObjectContext.object(with: objectID) as? T + } + + public static func == (lhs: ManagedObjectRecord<T>, rhs: ManagedObjectRecord<T>) -> Bool { + return lhs.objectID == rhs.objectID + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(objectID) + } + +} diff --git a/Mastodon/Resources/Assets.xcassets/Asset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Arrow/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Asset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Arrow/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Settings/dark.auto.imageset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Arrow/repeat.imageset/Contents.json similarity index 54% rename from Mastodon/Resources/Assets.xcassets/Settings/dark.auto.imageset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Arrow/repeat.imageset/Contents.json index 3b7f153c7..01af77029 100644 --- a/Mastodon/Resources/Assets.xcassets/Settings/dark.auto.imageset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Arrow/repeat.imageset/Contents.json @@ -1,12 +1,15 @@ { "images" : [ { - "filename" : "Mixed_Dark_Light.png", + "filename" : "repeat.pdf", "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true } } diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Arrow/repeat.imageset/repeat.pdf b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Arrow/repeat.imageset/repeat.pdf new file mode 100644 index 000000000..55832f674 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Arrow/repeat.imageset/repeat.pdf @@ -0,0 +1,122 @@ +%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 2.000000 2.107513 cm +0.000000 0.000000 0.000000 scn +12.610181 19.422018 m +12.533377 19.489384 l +12.239406 19.713934 11.818006 19.691479 11.549520 19.422018 c +11.482400 19.344936 l +11.258662 19.049896 11.281035 18.626966 11.549520 18.357506 c +13.521000 16.380682 l +6.500000 16.380682 l +6.266866 16.376564 l +2.784996 16.253372 0.000000 13.381639 0.000000 9.857073 c +0.000000 8.166546 0.640704 6.626207 1.691700 5.467305 c +1.764071 5.397793 l +1.894961 5.285951 2.064627 5.218454 2.250000 5.218454 c +2.664214 5.218454 3.000000 5.555460 3.000000 5.971178 c +3.000000 6.144394 2.941704 6.303945 2.843728 6.431146 c +2.644393 6.661853 l +1.929533 7.529752 1.500000 8.643068 1.500000 9.857073 c +1.500000 12.628527 3.738576 14.875234 6.500000 14.875234 c +13.381000 14.875234 l +11.549520 13.034943 l +11.482400 12.957859 l +11.258662 12.662821 11.281035 12.239891 11.549520 11.970430 c +11.842414 11.676473 12.317287 11.676473 12.610181 11.970430 c +15.792162 15.163968 l +15.859283 15.241051 l +16.083021 15.536089 16.060648 15.959020 15.792162 16.228481 c +12.610181 19.422018 l +h +18.229979 14.321409 m +18.099916 14.430242 17.932577 14.495717 17.750000 14.495717 c +17.335787 14.495717 17.000000 14.158710 17.000000 13.742992 c +17.000000 13.556305 17.067719 13.385490 17.179129 13.255264 c +17.999193 12.361402 18.500000 11.167934 18.500000 9.857073 c +18.500000 7.085619 16.261425 4.838912 13.500000 4.838912 c +6.558000 4.838912 l +8.463367 6.750031 l +8.536530 6.835192 l +8.733867 7.102798 8.731853 7.471925 8.530489 7.737460 c +8.463367 7.814543 l +8.378515 7.887972 l +8.111876 8.086025 7.744085 8.084003 7.479511 7.881908 c +7.402708 7.814543 l +4.220727 4.621005 l +4.147565 4.535844 l +3.950228 4.268237 3.952242 3.899111 4.153605 3.633575 c +4.220727 3.556492 l +7.402708 0.362953 l +7.486826 0.290073 l +7.780437 0.071426 8.197102 0.095720 8.463367 0.362953 c +8.731853 0.632414 8.754227 1.055346 8.530489 1.350384 c +8.463367 1.427467 l +6.564000 3.333464 l +13.500000 3.333464 l +13.733133 3.337582 l +17.215004 3.460773 20.000000 6.332506 20.000000 9.857073 c +20.000000 11.550518 19.357080 13.093257 18.302853 14.252840 c +18.229979 14.321409 l +h +f +n +Q + +endstream +endobj + +3 0 obj + 2191 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 24.000000 24.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 + << /Pages 5 0 R + /Type /Catalog + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000002281 00000 n +0000002304 00000 n +0000002477 00000 n +0000002551 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +2610 +%%EOF \ No newline at end of file diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Arrow/repeat.small.imageset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Arrow/repeat.small.imageset/Contents.json new file mode 100644 index 000000000..61b0d4b44 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Arrow/repeat.small.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "repeat.small.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Arrow/repeat.small.imageset/repeat.small.pdf b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Arrow/repeat.small.imageset/repeat.small.pdf new file mode 100644 index 000000000..be8467d2c --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Arrow/repeat.small.imageset/repeat.small.pdf @@ -0,0 +1,121 @@ +%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 1.997589 2.358398 cm +0.000000 0.000000 0.000000 scn +13.752419 11.631601 m +13.931720 11.631601 14.096325 11.568684 14.225361 11.463720 c +15.313271 10.549767 16.004837 9.176446 16.004837 7.641602 c +16.004837 4.952847 13.882531 2.759784 11.221727 2.646221 c +11.004837 2.641602 l +6.563000 2.641602 l +7.782749 1.421932 l +8.049015 1.155665 8.073221 0.739002 7.855367 0.445391 c +7.782749 0.361271 l +7.516482 0.095004 7.099819 0.070798 6.806207 0.288652 c +6.722089 0.361271 l +4.222089 2.861272 l +3.955822 3.127539 3.931616 3.544202 4.149471 3.837813 c +4.222089 3.921931 l +6.722089 6.421931 l +7.014983 6.714825 7.489855 6.714825 7.782749 6.421931 c +8.049015 6.155664 8.073221 5.739001 7.855367 5.445390 c +7.782749 5.361272 l +6.563000 4.141602 l +11.004837 4.141602 l +12.873401 4.141602 14.399964 5.605879 14.499659 7.449566 c +14.504837 7.641602 l +14.504837 8.722754 14.014629 9.689410 13.244354 10.331430 c +13.095952 10.466222 13.002419 10.662989 13.002419 10.881601 c +13.002419 11.295815 13.338205 11.631601 13.752419 11.631601 c +h +8.222090 14.921932 m +8.488357 15.188198 8.905020 15.212404 9.198631 14.994550 c +9.282749 14.921932 l +11.782749 12.421932 l +11.855368 12.337813 l +12.049016 12.076825 12.051406 11.718611 11.862539 11.455222 c +11.782749 11.361271 l +9.282749 8.861272 l +9.198631 8.788653 l +8.937643 8.595005 8.579429 8.592613 8.316040 8.781481 c +8.222090 8.861272 l +8.149471 8.945390 l +7.955823 9.206378 7.953431 9.564592 8.142298 9.827981 c +8.222090 9.921932 l +9.441000 11.141602 l +5.000000 11.141602 l +3.131437 11.141602 1.604874 9.677324 1.505179 7.833637 c +1.500000 7.641602 l +1.500000 6.558465 1.992010 5.590244 2.764729 4.948239 c +2.910926 4.812664 3.002419 4.617817 3.002419 4.401602 c +3.002419 3.987389 2.666633 3.651602 2.252419 3.651602 c +2.061133 3.651602 1.886572 3.723213 1.754084 3.841089 c +0.681080 4.754352 0.000000 6.118439 0.000000 7.641602 c +0.000000 10.330357 2.122307 12.523419 4.783111 12.636982 c +5.000000 12.641602 l +9.441000 12.641602 l +8.222090 13.861271 l +8.149471 13.945390 l +7.931617 14.239001 7.955823 14.655665 8.222090 14.921932 c +h +f +n +Q + +endstream +endobj + +3 0 obj + 2140 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 20.000000 20.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 + << /Pages 5 0 R + /Type /Catalog + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000002230 00000 n +0000002253 00000 n +0000002426 00000 n +0000002500 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +2559 +%%EOF \ No newline at end of file diff --git a/Mastodon/Resources/Assets.xcassets/Circles/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Asset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Circles/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Asset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Asset/email.imageset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Asset/email.imageset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Asset/email.imageset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Asset/email.imageset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Asset/email.imageset/c1 1~universal.pdf b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Asset/email.imageset/c1 1~universal.pdf similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Asset/email.imageset/c1 1~universal.pdf rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Asset/email.imageset/c1 1~universal.pdf diff --git a/Mastodon/Resources/Assets.xcassets/Asset/friends.imageset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Asset/friends.imageset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Asset/friends.imageset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Asset/friends.imageset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Asset/friends.imageset/friends 1.png b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Asset/friends.imageset/friends 1.png similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Asset/friends.imageset/friends 1.png rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Asset/friends.imageset/friends 1.png diff --git a/Mastodon/Resources/Assets.xcassets/Asset/friends.imageset/friends 2.png b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Asset/friends.imageset/friends 2.png similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Asset/friends.imageset/friends 2.png rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Asset/friends.imageset/friends 2.png diff --git a/Mastodon/Resources/Assets.xcassets/Asset/friends.imageset/friends 3.png b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Asset/friends.imageset/friends 3.png similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Asset/friends.imageset/friends 3.png rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Asset/friends.imageset/friends 3.png diff --git a/Mastodon/Resources/Assets.xcassets/Asset/mastodon.text.logo.imageset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Asset/mastodon.text.logo.imageset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Asset/mastodon.text.logo.imageset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Asset/mastodon.text.logo.imageset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Asset/mastodon.text.logo.imageset/mastodon.title.logo.pdf b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Asset/mastodon.text.logo.imageset/mastodon.title.logo.pdf similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Asset/mastodon.text.logo.imageset/mastodon.title.logo.pdf rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Asset/mastodon.text.logo.imageset/mastodon.title.logo.pdf diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Border/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Circles/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Colors/Border/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Circles/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Circles/plus.circle.fill.imageset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Circles/plus.circle.fill.imageset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Circles/plus.circle.fill.imageset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Circles/plus.circle.fill.imageset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Circles/plus.circle.fill.imageset/plus.circle.fill.pdf b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Circles/plus.circle.fill.imageset/plus.circle.fill.pdf similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Circles/plus.circle.fill.imageset/plus.circle.fill.pdf rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Circles/plus.circle.fill.imageset/plus.circle.fill.pdf diff --git a/Mastodon/Resources/Assets.xcassets/Circles/plus.circle.imageset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Circles/plus.circle.imageset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Circles/plus.circle.imageset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Circles/plus.circle.imageset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Circles/plus.circle.imageset/plus.circle.pdf b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Circles/plus.circle.imageset/plus.circle.pdf similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Circles/plus.circle.imageset/plus.circle.pdf rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Circles/plus.circle.imageset/plus.circle.pdf diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Button/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Border/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Colors/Button/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Border/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Border/compose.poll.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Border/compose.poll.colorset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Colors/Border/compose.poll.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Border/compose.poll.colorset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Border/searchCard.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Border/searchCard.colorset/Contents.json similarity index 88% rename from Mastodon/Resources/Assets.xcassets/Colors/Border/searchCard.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Border/searchCard.colorset/Contents.json index a0ce2efb8..f28745f07 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Border/searchCard.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Border/searchCard.colorset/Contents.json @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "0.300", - "blue" : "213", - "green" : "213", - "red" : "213" + "blue" : "0.835", + "green" : "0.835", + "red" : "0.835" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Border/status.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Border/status.colorset/Contents.json similarity index 88% rename from Mastodon/Resources/Assets.xcassets/Colors/Border/status.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Border/status.colorset/Contents.json index 486f86490..14df8ad4a 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Border/status.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Border/status.colorset/Contents.json @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "0.003", - "blue" : "213", - "green" : "213", - "red" : "213" + "blue" : "0.835", + "green" : "0.835", + "red" : "0.835" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Button/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Colors/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Button/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Label/secondary.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Button/action.toolbar.colorset/Contents.json similarity index 76% rename from Mastodon/Resources/Assets.xcassets/Colors/Label/secondary.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Button/action.toolbar.colorset/Contents.json index 70b1446d0..579de1da7 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Label/secondary.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Button/action.toolbar.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "0.600", - "blue" : "0x43", - "green" : "0x3C", - "red" : "0x3C" + "blue" : "0.263", + "green" : "0.235", + "red" : "0.235" } }, "idiom" : "universal" @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "0.600", - "blue" : "0xF5", - "green" : "0xEB", - "red" : "0xEB" + "blue" : "0.961", + "green" : "0.922", + "red" : "0.922" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Button/disabled.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Button/disabled.colorset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Colors/Button/disabled.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Button/disabled.colorset/Contents.json diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Button/inactive.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Button/inactive.colorset/Contents.json new file mode 100644 index 000000000..9fbab2202 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Button/inactive.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.549", + "green" : "0.510", + "red" : "0.431" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.392", + "green" : "0.365", + "red" : "0.310" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Icon/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Colors/Icon/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Label/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Icon/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Colors/Label/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Icon/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Notification/favourite.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Icon/plus.colorset/Contents.json similarity index 74% rename from Mastodon/Resources/Assets.xcassets/Colors/Notification/favourite.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Icon/plus.colorset/Contents.json index 36de20274..13aaacf16 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Notification/favourite.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Icon/plus.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0", - "green" : "204", - "red" : "255" + "blue" : "0.349", + "green" : "0.780", + "red" : "0.098" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Notification/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Label/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Colors/Notification/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Label/Contents.json diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Label/primary.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Label/primary.colorset/Contents.json new file mode 100644 index 000000000..a36ab82ce --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Label/primary.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.216", + "green" : "0.173", + "red" : "0.157" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.933", + "green" : "0.933", + "red" : "0.933" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Label/primary.reverse.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Label/primary.reverse.colorset/Contents.json new file mode 100644 index 000000000..8f42a585a --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Label/primary.reverse.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.933", + "green" : "0.933", + "red" : "0.933" + } + }, + "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/Colors/Button/action.toolbar.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Label/secondary.colorset/Contents.json similarity index 84% rename from Mastodon/Resources/Assets.xcassets/Colors/Button/action.toolbar.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Label/secondary.colorset/Contents.json index 8b7864ebe..cd123376b 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Button/action.toolbar.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Label/secondary.colorset/Contents.json @@ -22,10 +22,10 @@ "color" : { "color-space" : "srgb", "components" : { - "alpha" : "0.600", - "blue" : "245", - "green" : "235", - "red" : "235" + "alpha" : "1.000", + "blue" : "173", + "green" : "157", + "red" : "151" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Label/tertiary.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Label/tertiary.colorset/Contents.json similarity index 74% rename from Mastodon/Resources/Assets.xcassets/Colors/Label/tertiary.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Label/tertiary.colorset/Contents.json index d4f558bfd..fe0e4dbc2 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Label/tertiary.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Label/tertiary.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "0.300", - "blue" : "67", - "green" : "60", - "red" : "60" + "blue" : "0.263", + "green" : "0.235", + "red" : "0.235" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Poll/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Notification/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Colors/Poll/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Notification/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Colors/TextField/valid.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Notification/favourite.colorset/Contents.json similarity index 74% rename from Mastodon/Resources/Assets.xcassets/Colors/TextField/valid.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Notification/favourite.colorset/Contents.json index 7ccf54a1c..f287ce105 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/TextField/valid.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Notification/favourite.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "89", - "green" : "199", - "red" : "52" + "blue" : "0.000", + "green" : "0.800", + "red" : "1.000" } }, "idiom" : "universal" diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Notification/mention.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Notification/mention.colorset/Contents.json new file mode 100644 index 000000000..c2416c589 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Notification/mention.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.871", + "green" : "0.322", + "red" : "0.686" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.949", + "green" : "0.353", + "red" : "0.749" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Notification/reblog.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Notification/reblog.colorset/Contents.json new file mode 100644 index 000000000..ac763a858 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Notification/reblog.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.349", + "green" : "0.780", + "red" : "0.204" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.294", + "green" : "0.843", + "red" : "0.078" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Shadow/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Poll/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Colors/Shadow/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Poll/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Poll/disabled.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Poll/disabled.colorset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Colors/Poll/disabled.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Poll/disabled.colorset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Slider/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Shadow/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Colors/Slider/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Shadow/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Shadow/SearchCard.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Shadow/SearchCard.colorset/Contents.json similarity index 88% rename from Mastodon/Resources/Assets.xcassets/Colors/Shadow/SearchCard.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Shadow/SearchCard.colorset/Contents.json index a28cf0793..c0dd4f8d2 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Shadow/SearchCard.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Shadow/SearchCard.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0", - "green" : "0", - "red" : "0" + "blue" : "0.000", + "green" : "0.000", + "red" : "0.000" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/TextField/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Slider/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Colors/TextField/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Slider/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Slider/track.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Slider/track.colorset/Contents.json similarity index 76% rename from Mastodon/Resources/Assets.xcassets/Colors/Slider/track.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Slider/track.colorset/Contents.json index ccbeb8648..ac8203aef 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Slider/track.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Slider/track.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "0.600", - "blue" : "213", - "green" : "213", - "red" : "212" + "blue" : "0.835", + "green" : "0.835", + "red" : "0.831" } }, "idiom" : "universal" @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "0.300", - "blue" : "60", - "green" : "60", - "red" : "60" + "blue" : "0.235", + "green" : "0.235", + "red" : "0.235" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Connectivity/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/TextField/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Connectivity/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/TextField/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Colors/TextField/background.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/TextField/background.colorset/Contents.json similarity index 76% rename from Mastodon/Resources/Assets.xcassets/Colors/TextField/background.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/TextField/background.colorset/Contents.json index cde0cdf00..c34bae049 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/TextField/background.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/TextField/background.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "0.600", - "blue" : "213", - "green" : "212", - "red" : "212" + "blue" : "0.835", + "green" : "0.831", + "red" : "0.831" } }, "idiom" : "universal" @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "0.240", - "blue" : "128", - "green" : "118", - "red" : "118" + "blue" : "0.502", + "green" : "0.463", + "red" : "0.463" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/TextField/invalid.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/TextField/invalid.colorset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Colors/TextField/invalid.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/TextField/invalid.colorset/Contents.json diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/TextField/valid.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/TextField/valid.colorset/Contents.json new file mode 100644 index 000000000..861cb3a04 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/TextField/valid.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.349", + "green" : "0.780", + "red" : "0.204" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/alert.yellow.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/alert.yellow.colorset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Colors/alert.yellow.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/alert.yellow.colorset/Contents.json diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/badge.background.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/badge.background.colorset/Contents.json new file mode 100644 index 000000000..69346039d --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/badge.background.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.188", + "green" : "0.231", + "red" : "1.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/battleshipGrey.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/battleshipGrey.colorset/Contents.json similarity index 74% rename from Mastodon/Resources/Assets.xcassets/Colors/battleshipGrey.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/battleshipGrey.colorset/Contents.json index 37df8107f..fdd0acdb9 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/battleshipGrey.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/battleshipGrey.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "0.200", - "blue" : "0x80", - "green" : "0x78", - "red" : "0x78" + "blue" : "0.502", + "green" : "0.471", + "red" : "0.471" } }, "idiom" : "universal" diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/brand.blue.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/brand.blue.colorset/Contents.json new file mode 100644 index 000000000..e973fbf3b --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/brand.blue.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.851", + "green" : "0.565", + "red" : "0.169" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.894", + "green" : "0.616", + "red" : "0.227" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/brand.blue.darken.20.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/brand.blue.darken.20.colorset/Contents.json new file mode 100644 index 000000000..97aaed2bc --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/brand.blue.darken.20.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.690", + "green" : "0.451", + "red" : "0.122" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.788", + "green" : "0.502", + "red" : "0.106" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/danger.border.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/danger.border.colorset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Colors/danger.border.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/danger.border.colorset/Contents.json diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/danger.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/danger.colorset/Contents.json new file mode 100644 index 000000000..dabccc33e --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/danger.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.353", + "green" : "0.251", + "red" : "0.875" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/disabled.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/disabled.colorset/Contents.json new file mode 100644 index 000000000..f2e6f489e --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/disabled.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.784", + "green" : "0.682", + "red" : "0.608" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.392", + "green" : "0.365", + "red" : "0.310" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/inactive.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/inactive.colorset/Contents.json new file mode 100644 index 000000000..9fbab2202 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/inactive.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.549", + "green" : "0.510", + "red" : "0.431" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.392", + "green" : "0.365", + "red" : "0.310" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/media.type.indicotor.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/media.type.indicotor.colorset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Colors/media.type.indicotor.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/media.type.indicotor.colorset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Colors/success.green.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/success.green.colorset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Colors/success.green.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/success.green.colorset/Contents.json diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/system.orange.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/system.orange.colorset/Contents.json new file mode 100644 index 000000000..70b342097 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/system.orange.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.039", + "green" : "0.624", + "red" : "1.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Human/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Communication/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Human/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Communication/Contents.json diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Communication/bubble.left.and.bubble.right.imageset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Communication/bubble.left.and.bubble.right.imageset/Contents.json new file mode 100644 index 000000000..7668a03c1 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Communication/bubble.left.and.bubble.right.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "bubble.left.and.bubble.right.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Communication/bubble.left.and.bubble.right.imageset/bubble.left.and.bubble.right.pdf b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Communication/bubble.left.and.bubble.right.imageset/bubble.left.and.bubble.right.pdf new file mode 100644 index 000000000..35b6bce14 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Communication/bubble.left.and.bubble.right.imageset/bubble.left.and.bubble.right.pdf @@ -0,0 +1,110 @@ +%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 2.000000 2.836700 cm +0.000000 0.000000 0.000000 scn +7.561576 18.163300 m +3.419440 18.163300 0.061576 14.805435 0.061576 10.663300 c +0.061576 9.530416 0.313246 8.454476 0.764230 7.489944 c +0.510198 6.491944 0.226379 5.379101 0.039990 4.648594 c +-0.193033 3.735312 0.629398 2.904506 1.543737 3.124281 c +2.293964 3.304609 3.446986 3.581353 4.474467 3.826294 c +5.416776 3.400215 6.462413 3.163300 7.561576 3.163300 c +11.703712 3.163300 15.061575 6.521164 15.061575 10.663300 c +15.061575 14.805435 11.703712 18.163300 7.561576 18.163300 c +h +1.561576 10.663300 m +1.561576 13.977008 4.247868 16.663300 7.561576 16.663300 c +10.875283 16.663300 13.561575 13.977008 13.561575 10.663300 c +13.561575 7.349591 10.875283 4.663300 7.561576 4.663300 c +6.600843 4.663300 5.694872 4.888549 4.891613 5.288434 c +4.648198 5.409614 l +4.383680 5.346642 l +3.460951 5.126980 2.394888 4.871399 1.595922 4.679508 c +1.794670 5.458641 2.057843 6.490885 2.286006 7.387637 c +2.356194 7.663498 l +2.225676 7.916461 l +1.801452 8.738670 1.561576 9.671877 1.561576 10.663300 c +h +12.561601 0.163244 m +10.592215 0.163244 8.800118 0.922304 7.461914 2.163819 c +7.495111 2.163436 7.528352 2.163244 7.561634 2.163244 c +8.279597 2.163244 8.976770 2.252259 9.642719 2.419854 c +10.506666 1.937881 11.502057 1.663244 12.561601 1.663244 c +13.522333 1.663244 14.428305 1.888493 15.231564 2.288379 c +15.474978 2.409558 l +15.739497 2.346587 l +16.661055 2.127203 17.704596 1.900763 18.478659 1.737051 c +18.303703 2.487577 18.064995 3.492156 17.837170 4.387582 c +17.766983 4.663443 l +17.897501 4.916406 l +18.321724 5.738615 18.561600 6.671822 18.561600 7.663244 c +18.561600 9.777950 17.467583 11.637134 15.814650 12.705694 c +15.636257 13.428946 15.365275 14.115738 15.014999 14.752777 c +17.952297 13.736459 20.061600 10.946178 20.061600 7.663244 c +20.061600 6.530099 19.809814 5.453921 19.358633 4.489217 c +19.611656 3.481242 19.867884 2.389293 20.030380 1.685955 c +20.234837 0.801001 19.455448 -0.000011 18.562967 0.186527 c +17.835819 0.338509 16.693346 0.582319 15.649543 0.826614 c +14.707017 0.400299 13.661087 0.163244 12.561601 0.163244 c +h +f +n +Q + +endstream +endobj + +3 0 obj + 2167 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 24.000000 24.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 + << /Pages 5 0 R + /Type /Catalog + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000002257 00000 n +0000002280 00000 n +0000002453 00000 n +0000002527 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +2586 +%%EOF \ No newline at end of file diff --git a/Mastodon/Resources/Assets.xcassets/Settings/black.imageset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Communication/share.imageset/Contents.json similarity index 54% rename from Mastodon/Resources/Assets.xcassets/Settings/black.imageset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Communication/share.imageset/Contents.json index 23975c38d..5d0e2aab2 100644 --- a/Mastodon/Resources/Assets.xcassets/Settings/black.imageset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Communication/share.imageset/Contents.json @@ -1,12 +1,15 @@ { "images" : [ { - "filename" : "Home Black.png", + "filename" : "share.pdf", "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true } } diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Communication/share.imageset/share.pdf b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Communication/share.imageset/share.pdf new file mode 100644 index 000000000..01ef13903 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Communication/share.imageset/share.pdf @@ -0,0 +1,127 @@ +%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 2.996704 2.791443 cm +0.066667 0.000000 0.000000 scn +3.750000 17.208559 m +7.214205 17.208559 l +7.628418 17.208559 7.964205 16.872772 7.964205 16.458559 c +7.964205 16.078863 7.682051 15.765067 7.315975 15.715405 c +7.214205 15.708558 l +3.750000 15.708558 l +2.559136 15.708558 1.584355 14.783398 1.505191 13.612608 c +1.500000 13.458559 l +1.500000 3.958558 l +1.500000 2.767694 2.425161 1.792913 3.595951 1.713749 c +3.750000 1.708559 l +13.250732 1.708559 l +14.441596 1.708559 15.416378 2.633719 15.495543 3.804509 c +15.500732 3.958558 l +15.500732 4.456340 l +15.500732 4.870553 15.836519 5.206340 16.250732 5.206340 c +16.630428 5.206340 16.944225 4.924186 16.993887 4.558110 c +17.000732 4.456340 l +17.000732 3.958558 l +17.000732 1.954300 15.428372 0.317286 13.449892 0.213757 c +13.250732 0.208559 l +3.750000 0.208559 l +1.745741 0.208559 0.108726 1.780920 0.005198 3.759400 c +0.000000 3.958558 l +0.000000 13.458559 l +0.000000 15.462818 1.572361 17.099833 3.550841 17.203360 c +3.750000 17.208559 l +7.214205 17.208559 l +3.750000 17.208559 l +h +11.503993 14.688704 m +11.503993 17.458559 l +11.503993 18.082529 12.210776 18.418612 12.690969 18.068756 c +12.773166 17.999817 l +18.767767 12.249817 l +19.047527 11.981472 19.072992 11.550446 18.844139 11.252485 c +18.767855 11.167385 l +12.773253 5.415532 l +12.322979 4.983491 11.591075 5.260884 11.511142 5.849654 c +11.503993 5.956706 l +11.503993 8.682026 l +11.160501 8.651914 l +8.760812 8.401592 6.460772 7.320704 4.245949 5.391203 c +3.726840 4.938968 2.923710 5.366555 3.009085 6.049711 c +3.673874 11.369261 6.455748 14.301262 11.204629 14.669057 c +11.503993 14.688704 l +11.503993 17.458559 l +11.503993 14.688704 l +h +13.003994 15.699915 m +13.003994 13.958559 l +13.003994 13.544345 12.668206 13.208559 12.253993 13.208559 c +8.380589 13.208559 5.979970 11.532429 4.942725 8.051376 c +4.863667 7.772803 l +5.215857 8.009624 l +7.452339 9.471366 9.801754 10.208558 12.253993 10.208558 c +12.633689 10.208558 12.947484 9.926404 12.997147 9.560328 c +13.003994 9.458558 l +13.003994 7.715741 l +17.165230 11.708471 l +13.003994 15.699915 l +h +f +n +Q + +endstream +endobj + +3 0 obj + 2134 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 24.000000 24.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 + << /Pages 5 0 R + /Type /Catalog + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000002224 00000 n +0000002247 00000 n +0000002420 00000 n +0000002494 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +2553 +%%EOF \ No newline at end of file diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Connectivity/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Connectivity/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Connectivity/photo.fill.split.imageset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Connectivity/photo.fill.split.imageset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Connectivity/photo.fill.split.imageset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Connectivity/photo.fill.split.imageset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Connectivity/photo.fill.split.imageset/Frame 2.pdf b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Connectivity/photo.fill.split.imageset/Frame 2.pdf similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Connectivity/photo.fill.split.imageset/Frame 2.pdf rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Connectivity/photo.fill.split.imageset/Frame 2.pdf diff --git a/Mastodon/Resources/Assets.xcassets/Theme/Mastodon/Background/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Theme/Mastodon/Background/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Profile/Banner/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Human/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Profile/Banner/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Human/Contents.json diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Human/eye.circle.fill.imageset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Human/eye.circle.fill.imageset/Contents.json new file mode 100644 index 000000000..35dacadd5 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Human/eye.circle.fill.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "eye.circle.fill.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Human/eye.circle.fill.imageset/eye.circle.fill.pdf b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Human/eye.circle.fill.imageset/eye.circle.fill.pdf new file mode 100644 index 000000000..bbc4a3ac4 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Human/eye.circle.fill.imageset/eye.circle.fill.pdf @@ -0,0 +1,125 @@ +%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.000000 cm +0.000000 0.000000 0.000000 scn +16.989307 0.000000 m +19.322350 0.000000 21.512966 0.441750 23.561153 1.325245 c +25.609318 2.208746 27.416132 3.436619 28.981590 5.008865 c +30.547117 6.581110 31.774899 8.391962 32.664940 10.441422 c +33.554981 12.490898 34.000000 14.677086 34.000000 16.999983 c +34.000000 19.322903 33.554981 21.509090 32.664940 23.558544 c +31.774899 25.608021 30.544210 27.418886 28.972881 28.991137 c +27.401642 30.563387 25.591934 31.791258 23.543745 32.674755 c +21.495579 33.558250 19.304962 34.000000 16.971897 34.000000 c +14.653032 34.000000 12.468869 33.558250 10.419407 32.674755 c +8.369945 31.791258 6.566022 30.563387 5.007641 28.991137 c +3.449283 27.418886 2.225084 25.608021 1.335042 23.558544 c +0.445014 21.509090 0.000000 19.322903 0.000000 16.999983 c +0.000000 14.677086 0.445014 12.490898 1.335042 10.441422 c +2.225084 8.391962 3.452185 6.581110 5.016346 5.008865 c +6.580508 3.436619 8.387330 2.208746 10.436815 1.325245 c +12.486278 0.441750 14.670443 0.000000 16.989307 0.000000 c +h +16.993164 9.949825 m +15.357431 9.949825 13.855102 10.229713 12.486176 10.789488 c +11.117251 11.349285 9.930407 12.031605 8.925645 12.836449 c +7.920860 13.641315 7.142945 14.430695 6.591897 15.204592 c +6.040850 15.978466 5.765326 16.572420 5.765326 16.986456 c +5.765326 17.400492 6.038916 17.994457 6.586094 18.768354 c +7.133273 19.542252 7.906352 20.331621 8.905334 21.136465 c +9.904292 21.941330 11.091135 22.623650 12.465864 23.183426 c +13.840593 23.743221 15.349693 24.023121 16.993164 24.023121 c +18.640503 24.023121 20.147987 23.743221 21.515615 23.183426 c +22.883266 22.623650 24.067867 21.941330 25.069420 21.136465 c +26.070972 20.331621 26.843737 19.542252 27.387707 18.768354 c +27.931677 17.994457 28.203663 17.400492 28.203663 16.986456 c +28.203663 16.572420 27.931677 15.978466 27.387707 15.204592 c +26.843737 14.430695 26.071623 13.641315 25.071367 12.836449 c +24.071089 12.031605 22.887135 11.349285 21.519506 10.789488 c +20.151878 10.229713 18.643097 9.949825 16.993164 9.949825 c +h +16.993164 12.377918 m +17.840057 12.377918 18.611862 12.589771 19.308580 13.013485 c +20.005299 13.437176 20.562477 13.996964 20.980122 14.692844 c +21.397766 15.388702 21.606586 16.153238 21.606586 16.986456 c +21.606586 17.848070 21.397766 18.626804 20.980122 19.322662 c +20.562477 20.018520 20.005299 20.571213 19.308580 20.980740 c +18.611862 21.390266 17.840057 21.595030 16.993164 21.595030 c +16.134687 21.595030 15.357088 21.390266 14.660371 20.980740 c +13.963676 20.571213 13.407145 20.018520 12.990775 19.322662 c +12.574429 18.626804 12.366257 17.848070 12.366257 16.986456 c +12.368829 16.153238 12.578299 15.388702 12.994668 14.692844 c +13.411015 13.996964 13.966898 13.437176 14.662318 13.013485 c +15.357738 12.589771 16.134687 12.377918 16.993164 12.377918 c +h +17.014465 14.958965 m +16.460209 14.958965 15.980392 15.161781 15.575014 15.567413 c +15.169660 15.973045 14.966982 16.446060 14.966982 16.986456 c +14.966982 17.526875 15.169660 17.999889 15.575014 18.405499 c +15.980392 18.811131 16.460209 19.013947 17.014465 19.013947 c +17.554522 19.013947 18.024336 18.811131 18.423910 18.405499 c +18.823484 17.999889 19.023272 17.526875 19.023272 16.986456 c +19.023272 16.446060 18.823484 15.973045 18.423910 15.567413 c +18.024336 15.161781 17.554522 14.958965 17.014465 14.958965 c +h +f +n +Q + +endstream +endobj + +3 0 obj + 3391 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 34.000000 34.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 + << /Pages 5 0 R + /Type /Catalog + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000003481 00000 n +0000003504 00000 n +0000003677 00000 n +0000003751 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +3810 +%%EOF \ No newline at end of file diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Human/eye.slash.circle.fill.imageset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Human/eye.slash.circle.fill.imageset/Contents.json new file mode 100644 index 000000000..cc18167f7 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Human/eye.slash.circle.fill.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "eye.slash.circle.fill.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Human/eye.slash.circle.fill.imageset/eye.slash.circle.fill.pdf b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Human/eye.slash.circle.fill.imageset/eye.slash.circle.fill.pdf new file mode 100644 index 000000000..3912ea6d3 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Human/eye.slash.circle.fill.imageset/eye.slash.circle.fill.pdf @@ -0,0 +1,139 @@ +%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.000000 cm +0.000000 0.000000 0.000000 scn +16.989307 0.000000 m +19.322350 0.000000 21.512964 0.441746 23.561153 1.325245 c +25.609318 2.208746 27.416132 3.436619 28.981590 5.008865 c +30.547117 6.581110 31.774899 8.391962 32.664940 10.441422 c +33.554981 12.490898 34.000000 14.677086 34.000000 16.999983 c +34.000000 19.322903 33.554981 21.509090 32.664940 23.558544 c +31.774899 25.608021 30.544210 27.418886 28.972881 28.991137 c +27.401642 30.563387 25.591932 31.791258 23.543745 32.674755 c +21.495579 33.558250 19.304962 34.000000 16.971897 34.000000 c +14.653033 34.000000 12.468869 33.558250 10.419407 32.674755 c +8.369944 31.791258 6.566022 30.563387 5.007641 28.991137 c +3.449283 27.418886 2.225084 25.608021 1.335042 23.558544 c +0.445014 21.509090 0.000000 19.322903 0.000000 16.999983 c +0.000000 14.677086 0.445014 12.490898 1.335042 10.441422 c +2.225084 8.391962 3.452185 6.581110 5.016346 5.008865 c +6.580508 3.436619 8.387330 2.208746 10.436815 1.325245 c +12.486278 0.441746 14.670442 0.000000 16.989307 0.000000 c +h +20.951313 10.079727 m +20.344168 9.895254 19.708973 9.742058 19.045732 9.620138 c +18.382490 9.498241 17.697016 9.437292 16.989307 9.437292 c +15.235007 9.437292 13.624114 9.738777 12.156626 10.341749 c +10.689138 10.944744 9.420459 11.680578 8.350589 12.549252 c +7.280741 13.417925 6.449994 14.262413 5.858347 15.082718 c +5.266723 15.903046 4.970911 16.537626 4.970911 16.986456 c +4.970911 17.488209 5.335063 18.222446 6.063368 19.189175 c +6.791673 20.155926 7.797766 21.079124 9.081647 21.958775 c +12.354888 18.669945 l +12.148547 18.150091 12.045376 17.588926 12.045376 16.986456 c +12.047971 16.095217 12.269036 15.274912 12.708573 14.525541 c +13.148132 13.776169 13.745263 13.175121 14.499967 12.722397 c +15.254647 12.269672 16.084427 12.043310 16.989307 12.043310 c +17.572033 12.043310 18.128338 12.153637 18.658220 12.374296 c +20.951313 10.079727 l +h +16.610533 14.419378 m +15.995673 14.406466 15.470092 14.628626 15.033787 15.085859 c +14.597482 15.543070 14.387078 16.056072 14.402575 16.624865 c +16.610533 14.419378 l +h +21.635332 15.318474 m +21.833935 15.861555 21.933235 16.417551 21.933235 16.986456 c +21.933235 17.903496 21.712809 18.733810 21.271954 19.477398 c +20.831120 20.220963 20.236252 20.815556 19.487350 21.261173 c +18.738451 21.706795 17.905769 21.929604 16.989307 21.929604 c +16.416889 21.929604 15.868335 21.831537 15.343640 21.635405 c +13.067924 23.908693 l +13.675068 24.090595 14.308327 24.240559 14.967699 24.358583 c +15.627072 24.476631 16.300941 24.535656 16.989307 24.535656 c +18.766796 24.535656 20.389282 24.234158 21.856770 23.631165 c +23.324280 23.028191 24.593609 22.292368 25.664755 21.423695 c +26.735899 20.555000 27.561493 19.710501 28.141533 18.890194 c +28.721573 18.069889 29.011593 17.435308 29.011593 16.986456 c +29.011593 16.487301 28.650341 15.753389 27.927839 14.784727 c +27.205315 13.816063 26.204697 12.890612 24.925982 12.008366 c +21.635332 15.318474 l +h +17.387436 19.586462 m +17.990688 19.576170 18.504028 19.351105 18.927452 18.911270 c +19.350876 18.471457 19.560640 17.965868 19.556749 17.394503 c +17.387436 19.586462 l +h +25.073380 7.792742 m +7.762697 25.118214 l +7.622216 25.261356 7.551338 25.442539 7.550064 25.661762 c +7.548766 25.881008 7.619644 26.069296 7.762697 26.226625 c +7.917356 26.381382 8.105525 26.458761 8.327205 26.458761 c +8.548884 26.458761 8.737055 26.381382 8.891714 26.226625 c +26.181129 8.901154 l +26.335789 8.748993 26.413122 8.565552 26.413122 8.350830 c +26.413122 8.136106 26.335789 7.950079 26.181129 7.792742 c +26.040648 7.647011 25.859568 7.574789 25.637888 7.576080 c +25.416208 7.577374 25.228039 7.649595 25.073380 7.792742 c +h +f +n +Q + +endstream +endobj + +3 0 obj + 3709 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 34.000000 34.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 + << /Pages 5 0 R + /Type /Catalog + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000003799 00000 n +0000003822 00000 n +0000003995 00000 n +0000004069 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +4128 +%%EOF \ No newline at end of file diff --git a/Mastodon/Resources/Assets.xcassets/Human/face.smiling.adaptive.imageset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Human/face.smiling.adaptive.imageset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Human/face.smiling.adaptive.imageset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Human/face.smiling.adaptive.imageset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Human/face.smiling.adaptive.imageset/emojiIconDark.pdf b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Human/face.smiling.adaptive.imageset/emojiIconDark.pdf similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Human/face.smiling.adaptive.imageset/emojiIconDark.pdf rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Human/face.smiling.adaptive.imageset/emojiIconDark.pdf diff --git a/Mastodon/Resources/Assets.xcassets/Human/face.smiling.adaptive.imageset/emojiIconLight.pdf b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Human/face.smiling.adaptive.imageset/emojiIconLight.pdf similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Human/face.smiling.adaptive.imageset/emojiIconLight.pdf rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Human/face.smiling.adaptive.imageset/emojiIconLight.pdf diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Profile/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/ObjectsAndTools/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Profile/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/ObjectsAndTools/Contents.json diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/ObjectsAndTools/star.fill.imageset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/ObjectsAndTools/star.fill.imageset/Contents.json new file mode 100644 index 000000000..3df4557f3 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/ObjectsAndTools/star.fill.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "star.fill.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/ObjectsAndTools/star.fill.imageset/star.fill.pdf b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/ObjectsAndTools/star.fill.imageset/star.fill.pdf new file mode 100644 index 000000000..5b188c0b6 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/ObjectsAndTools/star.fill.imageset/star.fill.pdf @@ -0,0 +1,82 @@ +%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 2.000000 1.886078 cm +0.000000 0.000000 0.000000 scn +8.787823 19.011379 m +9.283020 20.014759 10.713811 20.014765 11.209011 19.011381 c +13.566957 14.233658 l +18.839485 13.467514 l +19.946779 13.306616 20.388926 11.945854 19.587675 11.164829 c +15.772436 7.445889 l +16.673092 2.194668 l +16.862242 1.091846 15.704712 0.250845 14.714312 0.771528 c +9.998417 3.250818 l +5.282524 0.771528 l +4.292129 0.250847 3.134592 1.091841 3.323741 2.194666 c +4.224397 7.445890 l +0.409159 11.164828 l +-0.392086 11.945848 0.050045 13.306614 1.157346 13.467514 c +6.429876 14.233658 l +8.787823 19.011379 l +h +f +n +Q + +endstream +endobj + +3 0 obj + 655 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 24.000000 24.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 + << /Pages 5 0 R + /Type /Catalog + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000000745 00000 n +0000000767 00000 n +0000000940 00000 n +0000001014 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +1073 +%%EOF \ No newline at end of file diff --git a/Mastodon/Resources/Assets.xcassets/Settings/black.auto.imageset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/ObjectsAndTools/star.imageset/Contents.json similarity index 55% rename from Mastodon/Resources/Assets.xcassets/Settings/black.auto.imageset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/ObjectsAndTools/star.imageset/Contents.json index 909d1ad2b..a15637406 100644 --- a/Mastodon/Resources/Assets.xcassets/Settings/black.auto.imageset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/ObjectsAndTools/star.imageset/Contents.json @@ -1,12 +1,15 @@ { "images" : [ { - "filename" : "Mixed_Black_Light.png", + "filename" : "star.pdf", "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true } } diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/ObjectsAndTools/star.imageset/star.pdf b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/ObjectsAndTools/star.imageset/star.pdf new file mode 100644 index 000000000..1eb8ec956 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/ObjectsAndTools/star.imageset/star.pdf @@ -0,0 +1,99 @@ +%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 2.000000 1.886078 cm +0.000000 0.000000 0.000000 scn +8.787823 19.011379 m +9.283020 20.014759 10.713811 20.014765 11.209011 19.011381 c +13.566957 14.233658 l +18.839485 13.467514 l +19.946779 13.306616 20.388926 11.945854 19.587675 11.164829 c +15.772436 7.445889 l +16.673092 2.194668 l +16.862242 1.091846 15.704712 0.250845 14.714312 0.771528 c +9.998417 3.250818 l +5.282524 0.771528 l +4.292129 0.250847 3.134592 1.091841 3.323741 2.194666 c +4.224397 7.445890 l +0.409159 11.164828 l +-0.392086 11.945848 0.050045 13.306614 1.157346 13.467514 c +6.429876 14.233658 l +8.787823 19.011379 l +h +9.998417 18.074984 m +7.740080 13.499093 l +7.543436 13.100649 7.163321 12.824480 6.723613 12.760588 c +1.673818 12.026810 l +5.327885 8.464975 l +5.646061 8.154830 5.791251 7.707979 5.716140 7.270047 c +4.853532 2.240658 l +9.370207 4.615213 l +9.763494 4.821977 10.233340 4.821977 10.626628 4.615213 c +15.143302 2.240660 l +14.280694 7.270047 l +14.205583 7.707980 14.350773 8.154830 14.668949 8.464975 c +18.323015 12.026810 l +13.273220 12.760588 l +12.833511 12.824480 12.453398 13.100650 12.256754 13.499093 c +9.998417 18.074984 l +h +f +n +Q + +endstream +endobj + +3 0 obj + 1181 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 24.000000 24.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 + << /Pages 5 0 R + /Type /Catalog + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000001271 00000 n +0000001294 00000 n +0000001467 00000 n +0000001541 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +1600 +%%EOF \ No newline at end of file diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Sidebar/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Sidebar/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Onboarding/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Welcome/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Onboarding/Contents.json diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Onboarding/avatar.placeholder.imageset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Onboarding/avatar.placeholder.imageset/Contents.json new file mode 100644 index 000000000..2b84d06bc --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Onboarding/avatar.placeholder.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Frame 82.jpg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Frame 82@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Frame 82@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Onboarding/avatar.placeholder.imageset/Frame 82.jpg b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Onboarding/avatar.placeholder.imageset/Frame 82.jpg new file mode 100644 index 000000000..7819c97b9 Binary files /dev/null and b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Onboarding/avatar.placeholder.imageset/Frame 82.jpg differ diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Onboarding/avatar.placeholder.imageset/Frame 82@2x.png b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Onboarding/avatar.placeholder.imageset/Frame 82@2x.png new file mode 100644 index 000000000..31f1bdf68 Binary files /dev/null and b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Onboarding/avatar.placeholder.imageset/Frame 82@2x.png differ diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Onboarding/avatar.placeholder.imageset/Frame 82@3x.png b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Onboarding/avatar.placeholder.imageset/Frame 82@3x.png new file mode 100644 index 000000000..68603b227 Binary files /dev/null and b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Onboarding/avatar.placeholder.imageset/Frame 82@3x.png differ diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Onboarding/background.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Onboarding/background.colorset/Contents.json new file mode 100644 index 000000000..4d55227b9 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Onboarding/background.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.969", + "green" : "0.949", + "red" : "0.949" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.129", + "green" : "0.106", + "red" : "0.098" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Onboarding/navigation.back.button.background.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Onboarding/navigation.back.button.background.colorset/Contents.json new file mode 100644 index 000000000..fb6807b05 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Onboarding/navigation.back.button.background.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "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" : "0.200", + "blue" : "0.502", + "green" : "0.471", + "red" : "0.471" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Onboarding/navigation.back.button.background.highlighted.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Onboarding/navigation.back.button.background.highlighted.colorset/Contents.json new file mode 100644 index 000000000..b7b5a14de --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Onboarding/navigation.back.button.background.highlighted.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.898", + "green" : "0.898", + "red" : "0.898" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.400", + "blue" : "0.502", + "green" : "0.471", + "red" : "0.471" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Theme/system/Background/secondary.grouped.system.background.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Onboarding/navigation.next.button.background.colorset/Contents.json similarity index 80% rename from Mastodon/Resources/Assets.xcassets/Theme/system/Background/secondary.grouped.system.background.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Onboarding/navigation.next.button.background.colorset/Contents.json index 036066700..0c0c8af04 100644 --- a/Mastodon/Resources/Assets.xcassets/Theme/system/Background/secondary.grouped.system.background.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Onboarding/navigation.next.button.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" : "55", + "green" : "44", + "red" : "40" } }, "idiom" : "universal" @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "46", - "green" : "44", - "red" : "44" + "blue" : "0.933", + "green" : "0.933", + "red" : "0.933" } }, "idiom" : "universal" diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Onboarding/navigation.next.button.background.highlighted.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Onboarding/navigation.next.button.background.highlighted.colorset/Contents.json new file mode 100644 index 000000000..2dfe8b1c4 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Onboarding/navigation.next.button.background.highlighted.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.106", + "green" : "0.082", + "red" : "0.075" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.729", + "green" : "0.729", + "red" : "0.729" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Onboarding/search.bar.background.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Onboarding/search.bar.background.colorset/Contents.json new file mode 100644 index 000000000..6cfd2655d --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Onboarding/search.bar.background.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.200", + "blue" : "0.502", + "green" : "0.471", + "red" : "0.471" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.240", + "blue" : "0.502", + "green" : "0.463", + "red" : "0.463" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Theme/Mastodon/Background/system.elevated.background.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Onboarding/textField.background.colorset/Contents.json similarity index 88% rename from Mastodon/Resources/Assets.xcassets/Theme/Mastodon/Background/system.elevated.background.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Onboarding/textField.background.colorset/Contents.json index dd6cbfd91..33b71ef90 100644 --- a/Mastodon/Resources/Assets.xcassets/Theme/Mastodon/Background/system.elevated.background.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Onboarding/textField.background.colorset/Contents.json @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "55", - "green" : "44", - "red" : "40" + "blue" : "0.216", + "green" : "0.173", + "red" : "0.157" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/Banner/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/Banner/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Profile/Banner/bio.edit.background.gray.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/Banner/bio.edit.background.gray.colorset/Contents.json similarity index 76% rename from Mastodon/Resources/Assets.xcassets/Scene/Profile/Banner/bio.edit.background.gray.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/Banner/bio.edit.background.gray.colorset/Contents.json index aa5323a21..64f158348 100644 --- a/Mastodon/Resources/Assets.xcassets/Scene/Profile/Banner/bio.edit.background.gray.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/Banner/bio.edit.background.gray.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "0.200", - "blue" : "128", - "green" : "120", - "red" : "120" + "blue" : "0.502", + "green" : "0.471", + "red" : "0.471" } }, "idiom" : "universal" @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "0.360", - "blue" : "128", - "green" : "120", - "red" : "120" + "blue" : "0.502", + "green" : "0.471", + "red" : "0.471" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Profile/Banner/name.edit.background.gray.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/Banner/name.edit.background.gray.colorset/Contents.json similarity index 74% rename from Mastodon/Resources/Assets.xcassets/Scene/Profile/Banner/name.edit.background.gray.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/Banner/name.edit.background.gray.colorset/Contents.json index b4ce9fd5b..d1c47604a 100644 --- a/Mastodon/Resources/Assets.xcassets/Scene/Profile/Banner/name.edit.background.gray.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/Banner/name.edit.background.gray.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "0.360", - "blue" : "128", - "green" : "120", - "red" : "120" + "blue" : "0.502", + "green" : "0.471", + "red" : "0.471" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Profile/Banner/username.gray.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/Banner/username.gray.colorset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Profile/Banner/username.gray.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/Banner/username.gray.colorset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Settings/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Settings/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Theme/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/RelationshipButton/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Theme/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/RelationshipButton/Contents.json diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/RelationshipButton/background.dark.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/RelationshipButton/background.dark.colorset/Contents.json new file mode 100644 index 000000000..63600675a --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/RelationshipButton/background.dark.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.933", + "green" : "0.933", + "red" : "0.933" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/badge.background.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/RelationshipButton/background.highlighted.dark.colorset/Contents.json similarity index 82% rename from Mastodon/Resources/Assets.xcassets/Colors/badge.background.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/RelationshipButton/background.highlighted.dark.colorset/Contents.json index f58a604a1..4e900a602 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/badge.background.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/RelationshipButton/background.highlighted.dark.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "48", - "green" : "59", - "red" : "255" + "blue" : "0.729", + "green" : "0.729", + "red" : "0.729" } }, "idiom" : "universal" diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/RelationshipButton/background.highlighted.light.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/RelationshipButton/background.highlighted.light.colorset/Contents.json new file mode 100644 index 000000000..6ba0d80b0 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/RelationshipButton/background.highlighted.light.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.106", + "green" : "0.082", + "red" : "0.075" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/RelationshipButton/background.light.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/RelationshipButton/background.light.colorset/Contents.json new file mode 100644 index 000000000..70d85d5da --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/RelationshipButton/background.light.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "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/Theme/Mastodon/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Report/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Theme/Mastodon/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Report/Contents.json diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Report/background.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Report/background.colorset/Contents.json new file mode 100644 index 000000000..4d55227b9 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Report/background.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.969", + "green" : "0.949", + "red" : "0.949" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.129", + "green" : "0.106", + "red" : "0.098" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Icon/plus.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Report/report.banner.colorset/Contents.json similarity index 75% rename from Mastodon/Resources/Assets.xcassets/Colors/Icon/plus.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Report/report.banner.colorset/Contents.json index f783ce00f..2d639eeb5 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Icon/plus.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Report/report.banner.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "89", - "green" : "199", - "red" : "25" + "blue" : "0x55", + "green" : "0x98", + "red" : "0x03" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Theme/system/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Setting/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Theme/system/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Setting/Contents.json diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Setting/background.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Setting/background.colorset/Contents.json new file mode 100644 index 000000000..4d55227b9 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Setting/background.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.969", + "green" : "0.949", + "red" : "0.949" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.129", + "green" : "0.106", + "red" : "0.098" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Sidebar/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Sidebar/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Sidebar/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Sidebar/logo.imageset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Sidebar/logo.imageset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Sidebar/logo.imageset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Sidebar/logo.imageset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Sidebar/logo.imageset/logo.pdf b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Sidebar/logo.imageset/logo.pdf similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Sidebar/logo.imageset/logo.pdf rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Sidebar/logo.imageset/logo.pdf diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/background.cyan.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/background.cyan.colorset/Contents.json new file mode 100644 index 000000000..de0f60b66 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/background.cyan.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.910", + "green" : "0.812", + "red" : "0.235" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/cloud.base.extend.imageset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/cloud.base.extend.imageset/Contents.json new file mode 100644 index 000000000..421e01a34 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/cloud.base.extend.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "cloud.base.extend.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "cloud.base.extend@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "cloud.base.extend@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/cloud.base.extend.imageset/cloud.base.extend.png b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/cloud.base.extend.imageset/cloud.base.extend.png new file mode 100644 index 000000000..3c8443c9f Binary files /dev/null and b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/cloud.base.extend.imageset/cloud.base.extend.png differ diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/cloud.base.extend.imageset/cloud.base.extend@2x.png b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/cloud.base.extend.imageset/cloud.base.extend@2x.png new file mode 100644 index 000000000..b03b6720a Binary files /dev/null and b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/cloud.base.extend.imageset/cloud.base.extend@2x.png differ diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/cloud.base.extend.imageset/cloud.base.extend@3x.png b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/cloud.base.extend.imageset/cloud.base.extend@3x.png new file mode 100644 index 000000000..f77476855 Binary files /dev/null and b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/cloud.base.extend.imageset/cloud.base.extend@3x.png differ diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/cloud.base.imageset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/cloud.base.imageset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/cloud.base.imageset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/cloud.base.imageset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/cloud.base.imageset/untitled10007Group61.png b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/cloud.base.imageset/untitled10007Group61.png similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/cloud.base.imageset/untitled10007Group61.png rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/cloud.base.imageset/untitled10007Group61.png diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/cloud.base.imageset/untitled10007Group61@2x.png b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/cloud.base.imageset/untitled10007Group61@2x.png similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/cloud.base.imageset/untitled10007Group61@2x.png rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/cloud.base.imageset/untitled10007Group61@2x.png diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/cloud.base.imageset/untitled10007Group61@3x.png b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/cloud.base.imageset/untitled10007Group61@3x.png similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/cloud.base.imageset/untitled10007Group61@3x.png rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/cloud.base.imageset/untitled10007Group61@3x.png diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/untitled10006Group21.png b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/untitled10006Group21.png similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/untitled10006Group21.png rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/untitled10006Group21.png diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/untitled10006Group21@2x.png b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/untitled10006Group21@2x.png similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/untitled10006Group21@2x.png rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/untitled10006Group21@2x.png diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/untitled10006Group21@3x.png b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/untitled10006Group21@3x.png similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/untitled10006Group21@3x.png rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/untitled10006Group21@3x.png diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.extend.imageset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.extend.imageset/Contents.json new file mode 100644 index 000000000..9c3ea2de7 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.extend.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "elephant.three.on.grass.extend.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "elephant.three.on.grass.extend@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "elephant.three.on.grass.extend@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.extend.imageset/elephant.three.on.grass.extend.png b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.extend.imageset/elephant.three.on.grass.extend.png new file mode 100644 index 000000000..97ef8df63 Binary files /dev/null and b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.extend.imageset/elephant.three.on.grass.extend.png differ diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.extend.imageset/elephant.three.on.grass.extend@2x.png b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.extend.imageset/elephant.three.on.grass.extend@2x.png new file mode 100644 index 000000000..63580f3ce Binary files /dev/null and b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.extend.imageset/elephant.three.on.grass.extend@2x.png differ diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.extend.imageset/elephant.three.on.grass.extend@3x.png b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.extend.imageset/elephant.three.on.grass.extend@3x.png new file mode 100644 index 000000000..8799a7313 Binary files /dev/null and b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.extend.imageset/elephant.three.on.grass.extend@3x.png differ diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.imageset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.imageset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.imageset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.imageset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.imageset/untitled10003Group11.png b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.imageset/untitled10003Group11.png similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.imageset/untitled10003Group11.png rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.imageset/untitled10003Group11.png diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.imageset/untitled10003Group11@2x.png b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.imageset/untitled10003Group11@2x.png similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.imageset/untitled10003Group11@2x.png rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.imageset/untitled10003Group11@2x.png diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.imageset/untitled10003Group11@3x.png b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.imageset/untitled10003Group11@3x.png similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.imageset/untitled10003Group11@3x.png rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.imageset/untitled10003Group11@3x.png diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/untitled10005Group101.png b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/untitled10005Group101.png similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/untitled10005Group101.png rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/untitled10005Group101.png diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/untitled10005Group101@2x.png b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/untitled10005Group101@2x.png similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/untitled10005Group101@2x.png rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/untitled10005Group101@2x.png diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/untitled10005Group101@3x.png b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/untitled10005Group101@3x.png similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/untitled10005Group101@3x.png rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/untitled10005Group101@3x.png diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/untitled10004Group111.png b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/untitled10004Group111.png similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/untitled10004Group111.png rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/untitled10004Group111.png diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/untitled10004Group111@2x.png b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/untitled10004Group111@2x.png similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/untitled10004Group111@2x.png rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/untitled10004Group111@2x.png diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/untitled10004Group111@3x.png b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/untitled10004Group111@3x.png similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/untitled10004Group111@3x.png rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/untitled10004Group111@3x.png diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.black.imageset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/mastodon.logo.black.imageset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.black.imageset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/mastodon.logo.black.imageset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.black.imageset/mastodon.logo.black.pdf b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/mastodon.logo.black.imageset/mastodon.logo.black.pdf similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.black.imageset/mastodon.logo.black.pdf rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/mastodon.logo.black.imageset/mastodon.logo.black.pdf diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.black.large.imageset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/mastodon.logo.black.large.imageset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.black.large.imageset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/mastodon.logo.black.large.imageset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.black.large.imageset/mastodon.logo.black.large.pdf b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/mastodon.logo.black.large.imageset/mastodon.logo.black.large.pdf similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.black.large.imageset/mastodon.logo.black.large.pdf rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/mastodon.logo.black.large.imageset/mastodon.logo.black.large.pdf diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.imageset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/mastodon.logo.imageset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.imageset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/mastodon.logo.imageset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.imageset/logotypeFull1.pdf b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/mastodon.logo.imageset/logotypeFull1.pdf similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.imageset/logotypeFull1.pdf rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/mastodon.logo.imageset/logotypeFull1.pdf diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.large.imageset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/mastodon.logo.large.imageset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.large.imageset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/mastodon.logo.large.imageset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.large.imageset/logotypeFull1.large.pdf b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/mastodon.logo.large.imageset/logotypeFull1.large.pdf similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.large.imageset/logotypeFull1.large.pdf rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/mastodon.logo.large.imageset/logotypeFull1.large.pdf diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/sign.in.button.background.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/sign.in.button.background.colorset/Contents.json new file mode 100644 index 000000000..4872f3188 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/sign.in.button.background.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.506", + "green" : "0.675", + "red" : "0.345" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/automatic.imageset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/automatic.imageset/Contents.json new file mode 100644 index 000000000..634b1b249 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/automatic.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "automatic.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "automatic@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "automatic@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/automatic.imageset/automatic.png b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/automatic.imageset/automatic.png new file mode 100644 index 000000000..8a4997026 Binary files /dev/null and b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/automatic.imageset/automatic.png differ diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/automatic.imageset/automatic@2x.png b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/automatic.imageset/automatic@2x.png new file mode 100644 index 000000000..353f123a5 Binary files /dev/null and b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/automatic.imageset/automatic@2x.png differ diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/automatic.imageset/automatic@3x.png b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/automatic.imageset/automatic@3x.png new file mode 100644 index 000000000..6a865417a Binary files /dev/null and b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/automatic.imageset/automatic@3x.png differ diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/dark.imageset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/dark.imageset/Contents.json new file mode 100644 index 000000000..45265b0c2 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/dark.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "dark.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "dark@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "dark@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/dark.imageset/dark.png b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/dark.imageset/dark.png new file mode 100644 index 000000000..5bfe79abf Binary files /dev/null and b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/dark.imageset/dark.png differ diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/dark.imageset/dark@2x.png b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/dark.imageset/dark@2x.png new file mode 100644 index 000000000..4bbee3622 Binary files /dev/null and b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/dark.imageset/dark@2x.png differ diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/dark.imageset/dark@3x.png b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/dark.imageset/dark@3x.png new file mode 100644 index 000000000..4ea316fb2 Binary files /dev/null and b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/dark.imageset/dark@3x.png differ diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/light.imageset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/light.imageset/Contents.json new file mode 100644 index 000000000..c89e6245e --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/light.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "light.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "light@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "light@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/light.imageset/light.png b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/light.imageset/light.png new file mode 100644 index 000000000..23efb384c Binary files /dev/null and b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/light.imageset/light.png differ diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/light.imageset/light@2x.png b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/light.imageset/light@2x.png new file mode 100644 index 000000000..6bff099c8 Binary files /dev/null and b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/light.imageset/light@2x.png differ diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/light.imageset/light@3x.png b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/light.imageset/light@3x.png new file mode 100644 index 000000000..94c68eb50 Binary files /dev/null and b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/light.imageset/light@3x.png differ diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Theme/system/Background/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/Background/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Theme/system/Background/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/Background/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Theme/Mastodon/Background/compose.toolbar.background.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/Background/compose.toolbar.background.colorset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Theme/Mastodon/Background/compose.toolbar.background.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/Background/compose.toolbar.background.colorset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Theme/Mastodon/Background/content.warning.overlay.background.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/Background/content.warning.overlay.background.colorset/Contents.json similarity index 88% rename from Mastodon/Resources/Assets.xcassets/Theme/Mastodon/Background/content.warning.overlay.background.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/Background/content.warning.overlay.background.colorset/Contents.json index 54427c610..7d751f897 100644 --- a/Mastodon/Resources/Assets.xcassets/Theme/Mastodon/Background/content.warning.overlay.background.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/Background/content.warning.overlay.background.colorset/Contents.json @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0x6E", - "green" : "0x57", - "red" : "0x4F" + "blue" : "0.431", + "green" : "0.341", + "red" : "0.310" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Theme/Mastodon/Background/tab.bar.background.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/Background/navigation.bar.background.colorset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Theme/Mastodon/Background/tab.bar.background.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/Background/navigation.bar.background.colorset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Theme/Mastodon/Background/profile.field.collection.view.background.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/Background/profile.field.collection.view.background.colorset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Theme/Mastodon/Background/profile.field.collection.view.background.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/Background/profile.field.collection.view.background.colorset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Theme/Mastodon/Background/secondary.grouped.system.background.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/Background/secondary.grouped.system.background.colorset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Theme/Mastodon/Background/secondary.grouped.system.background.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/Background/secondary.grouped.system.background.colorset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Theme/Mastodon/Background/secondary.system.background.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/Background/secondary.system.background.colorset/Contents.json similarity index 88% rename from Mastodon/Resources/Assets.xcassets/Theme/Mastodon/Background/secondary.system.background.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/Background/secondary.system.background.colorset/Contents.json index c915c8911..14b5119b8 100644 --- a/Mastodon/Resources/Assets.xcassets/Theme/Mastodon/Background/secondary.system.background.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/Background/secondary.system.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" : "0xF7", + "green" : "0xF2", + "red" : "0xF2" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Theme/Mastodon/Background/sidebar.background.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/Background/sidebar.background.colorset/Contents.json similarity index 88% rename from Mastodon/Resources/Assets.xcassets/Theme/Mastodon/Background/sidebar.background.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/Background/sidebar.background.colorset/Contents.json index c24074078..bc3fb38b9 100644 --- a/Mastodon/Resources/Assets.xcassets/Theme/Mastodon/Background/sidebar.background.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/Background/sidebar.background.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0xF1", - "green" : "0xF1", - "red" : "0xF1" + "blue" : "0xF7", + "green" : "0xF2", + "red" : "0xF2" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Theme/Mastodon/Background/system.background.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/Background/system.background.colorset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Theme/Mastodon/Background/system.background.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/Background/system.background.colorset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Notification/mention.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/Background/system.elevated.background.colorset/Contents.json similarity index 76% rename from Mastodon/Resources/Assets.xcassets/Colors/Notification/mention.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/Background/system.elevated.background.colorset/Contents.json index 9dff2f59b..d47dc714f 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Notification/mention.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/Background/system.elevated.background.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "222", - "green" : "82", - "red" : "175" + "blue" : "0xF2", + "green" : "0xED", + "red" : "0xE9" } }, "idiom" : "universal" @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "242", - "green" : "90", - "red" : "191" + "blue" : "0.216", + "green" : "0.173", + "red" : "0.157" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Theme/Mastodon/Background/system.grouped.background.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/Background/system.grouped.background.colorset/Contents.json similarity index 88% rename from Mastodon/Resources/Assets.xcassets/Theme/Mastodon/Background/system.grouped.background.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/Background/system.grouped.background.colorset/Contents.json index c915c8911..14b5119b8 100644 --- a/Mastodon/Resources/Assets.xcassets/Theme/Mastodon/Background/system.grouped.background.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/Background/system.grouped.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" : "0xF7", + "green" : "0xF2", + "red" : "0xF2" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Theme/Mastodon/Background/navigation.bar.background.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/Background/tab.bar.background.colorset/Contents.json similarity index 88% rename from Mastodon/Resources/Assets.xcassets/Theme/Mastodon/Background/navigation.bar.background.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/Background/tab.bar.background.colorset/Contents.json index ec7c19fac..e3ffa5a61 100644 --- a/Mastodon/Resources/Assets.xcassets/Theme/Mastodon/Background/navigation.bar.background.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/Background/tab.bar.background.colorset/Contents.json @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "67", - "green" : "53", - "red" : "49" + "blue" : "0.263", + "green" : "0.208", + "red" : "0.192" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Theme/Mastodon/Background/table.view.cell.background.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/Background/table.view.cell.background.colorset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Theme/Mastodon/Background/table.view.cell.background.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/Background/table.view.cell.background.colorset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Theme/system/Background/content.warning.overlay.background.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/Background/table.view.cell.selection.background.colorset/Contents.json similarity index 88% rename from Mastodon/Resources/Assets.xcassets/Theme/system/Background/content.warning.overlay.background.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/Background/table.view.cell.selection.background.colorset/Contents.json index d211d7df9..7d751f897 100644 --- a/Mastodon/Resources/Assets.xcassets/Theme/system/Background/content.warning.overlay.background.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/Background/table.view.cell.selection.background.colorset/Contents.json @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "60", - "green" : "58", - "red" : "58" + "blue" : "0.431", + "green" : "0.341", + "red" : "0.310" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Theme/Mastodon/Background/tertiary.system.background.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/Background/tertiary.system.background.colorset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Theme/Mastodon/Background/tertiary.system.background.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/Background/tertiary.system.background.colorset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Theme/Mastodon/Background/tertiary.system.grouped.background.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/Background/tertiary.system.grouped.background.colorset/Contents.json similarity index 88% rename from Mastodon/Resources/Assets.xcassets/Theme/Mastodon/Background/tertiary.system.grouped.background.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/Background/tertiary.system.grouped.background.colorset/Contents.json index 98dd7bbde..bc3fb38b9 100644 --- a/Mastodon/Resources/Assets.xcassets/Theme/Mastodon/Background/tertiary.system.grouped.background.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/Background/tertiary.system.grouped.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" : "0xF7", + "green" : "0xF2", + "red" : "0xF2" } }, "idiom" : "universal" diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Theme/Mastodon/notification.status.border.color.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/notification.status.border.color.colorset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Theme/Mastodon/notification.status.border.color.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/notification.status.border.color.colorset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Theme/Mastodon/separator.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/separator.colorset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Theme/Mastodon/separator.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/separator.colorset/Contents.json diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/tab.bar.item.inactive.icon.color.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/tab.bar.item.inactive.icon.color.colorset/Contents.json new file mode 100644 index 000000000..baf4b4b42 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/tab.bar.item.inactive.icon.color.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.600", + "green" : "0.600", + "red" : "0.600" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.600", + "green" : "0.600", + "red" : "0.600" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Theme/system/Background/compose.toolbar.background.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/compose.toolbar.background.colorset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Theme/system/Background/compose.toolbar.background.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/compose.toolbar.background.colorset/Contents.json diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/content.warning.overlay.background.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/content.warning.overlay.background.colorset/Contents.json new file mode 100644 index 000000000..0ca6215a8 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/content.warning.overlay.background.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.922", + "green" : "0.894", + "red" : "0.867" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.235", + "green" : "0.227", + "red" : "0.227" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Theme/system/Background/tab.bar.background.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/navigation.bar.background.colorset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Theme/system/Background/tab.bar.background.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/navigation.bar.background.colorset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Theme/system/Background/system.elevated.background.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/profile.field.collection.view.background.colorset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Theme/system/Background/system.elevated.background.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/profile.field.collection.view.background.colorset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Theme/system/Background/secondary.system.background.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/secondary.grouped.system.background.colorset/Contents.json similarity index 84% rename from Mastodon/Resources/Assets.xcassets/Theme/system/Background/secondary.system.background.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/secondary.grouped.system.background.colorset/Contents.json index 77d24b11d..b0a1b74fb 100644 --- a/Mastodon/Resources/Assets.xcassets/Theme/system/Background/secondary.system.background.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/secondary.grouped.system.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" : "254", + "green" : "255", + "red" : "254" } }, "idiom" : "universal" @@ -23,7 +23,7 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0x2E", + "blue" : "0x2D", "green" : "0x2C", "red" : "0x2C" } diff --git a/Mastodon/Resources/Assets.xcassets/Theme/system/Background/sidebar.background.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/secondary.system.background.colorset/Contents.json similarity index 84% rename from Mastodon/Resources/Assets.xcassets/Theme/system/Background/sidebar.background.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/secondary.system.background.colorset/Contents.json index ee5b1c373..8fd668a51 100644 --- a/Mastodon/Resources/Assets.xcassets/Theme/system/Background/sidebar.background.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/secondary.system.background.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.945", - "green" : "0.945", - "red" : "0.945" + "blue" : "0xF7", + "green" : "0xF2", + "red" : "0xF2" } }, "idiom" : "universal" @@ -23,7 +23,7 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0x2E", + "blue" : "0x2D", "green" : "0x2C", "red" : "0x2C" } diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Notification/reblog.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/sidebar.background.colorset/Contents.json similarity index 76% rename from Mastodon/Resources/Assets.xcassets/Colors/Notification/reblog.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/sidebar.background.colorset/Contents.json index ec427ccaa..f30d42222 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Notification/reblog.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/sidebar.background.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "89", - "green" : "199", - "red" : "52" + "blue" : "0xF7", + "green" : "0xF2", + "red" : "0xF2" } }, "idiom" : "universal" @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "75", - "green" : "215", - "red" : "20" + "blue" : "0.180", + "green" : "0.173", + "red" : "0.173" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Label/primary.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/system.background.colorset/Contents.json similarity index 92% rename from Mastodon/Resources/Assets.xcassets/Colors/Label/primary.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/system.background.colorset/Contents.json index 202a1c04e..ab7d95395 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Label/primary.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/system.background.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0x00", - "green" : "0x00", - "red" : "0x00" + "blue" : "0.996", + "green" : "1.000", + "red" : "0.996" } }, "idiom" : "universal" @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "1.000", - "green" : "1.000", - "red" : "1.000" + "blue" : "0x00", + "green" : "0x00", + "red" : "0x00" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Theme/system/Background/profile.field.collection.view.background.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/system.elevated.background.colorset/Contents.json similarity index 87% rename from Mastodon/Resources/Assets.xcassets/Theme/system/Background/profile.field.collection.view.background.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/system.elevated.background.colorset/Contents.json index 82edd034b..253652481 100644 --- a/Mastodon/Resources/Assets.xcassets/Theme/system/Background/profile.field.collection.view.background.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/system.elevated.background.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "1.000", - "green" : "1.000", - "red" : "1.000" + "blue" : "0xF2", + "green" : "0xED", + "red" : "0xE9" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Theme/system/Background/system.grouped.background.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/system.grouped.background.colorset/Contents.json similarity index 88% rename from Mastodon/Resources/Assets.xcassets/Theme/system/Background/system.grouped.background.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/system.grouped.background.colorset/Contents.json index 370a745eb..05051dc50 100644 --- a/Mastodon/Resources/Assets.xcassets/Theme/system/Background/system.grouped.background.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/system.grouped.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" : "0xF7", + "green" : "0xF2", + "red" : "0xF2" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Theme/system/Background/navigation.bar.background.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/tab.bar.background.colorset/Contents.json similarity index 76% rename from Mastodon/Resources/Assets.xcassets/Theme/system/Background/navigation.bar.background.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/tab.bar.background.colorset/Contents.json index 7f9578a7a..8ef5fd6db 100644 --- a/Mastodon/Resources/Assets.xcassets/Theme/system/Background/navigation.bar.background.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/tab.bar.background.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "0.940", - "blue" : "249", - "green" : "249", - "red" : "249" + "blue" : "0.976", + "green" : "0.976", + "red" : "0.976" } }, "idiom" : "universal" @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "0.940", - "blue" : "29", - "green" : "29", - "red" : "29" + "blue" : "0.114", + "green" : "0.114", + "red" : "0.114" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Theme/system/Background/system.background.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/table.view.cell.background.colorset/Contents.json similarity index 92% rename from Mastodon/Resources/Assets.xcassets/Theme/system/Background/system.background.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/table.view.cell.background.colorset/Contents.json index 2b3ad55ea..04256378a 100644 --- a/Mastodon/Resources/Assets.xcassets/Theme/system/Background/system.background.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/table.view.cell.background.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.996", + "blue" : "1.000", "green" : "1.000", - "red" : "0.996" + "red" : "1.000" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Theme/system/Background/table.view.cell.selection.background.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/table.view.cell.selection.background.colorset/Contents.json similarity index 88% rename from Mastodon/Resources/Assets.xcassets/Theme/system/Background/table.view.cell.selection.background.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/table.view.cell.selection.background.colorset/Contents.json index d211d7df9..640af3a21 100644 --- a/Mastodon/Resources/Assets.xcassets/Theme/system/Background/table.view.cell.selection.background.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/table.view.cell.selection.background.colorset/Contents.json @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "60", - "green" : "58", - "red" : "58" + "blue" : "0.235", + "green" : "0.227", + "red" : "0.227" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Theme/system/Background/tertiary.system.background.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/tertiary.system.background.colorset/Contents.json similarity index 88% rename from Mastodon/Resources/Assets.xcassets/Theme/system/Background/tertiary.system.background.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/tertiary.system.background.colorset/Contents.json index e7d7e3cd0..c752c3a5c 100644 --- a/Mastodon/Resources/Assets.xcassets/Theme/system/Background/tertiary.system.background.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/tertiary.system.background.colorset/Contents.json @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "60", - "green" : "58", - "red" : "58" + "blue" : "0.235", + "green" : "0.227", + "red" : "0.227" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Button/inactive.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/tertiary.system.grouped.background.colorset/Contents.json similarity index 76% rename from Mastodon/Resources/Assets.xcassets/Colors/Button/inactive.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/tertiary.system.grouped.background.colorset/Contents.json index 717d78925..30aadfbcb 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Button/inactive.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/tertiary.system.grouped.background.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "140", - "green" : "130", - "red" : "110" + "blue" : "0xF7", + "green" : "0xF2", + "red" : "0xF2" } }, "idiom" : "universal" @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "100", - "green" : "93", - "red" : "79" + "blue" : "0.235", + "green" : "0.227", + "red" : "0.227" } }, "idiom" : "universal" diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Theme/system/notification.status.border.color.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/notification.status.border.color.colorset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Theme/system/notification.status.border.color.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/notification.status.border.color.colorset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Theme/system/separator.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/separator.colorset/Contents.json similarity index 76% rename from Mastodon/Resources/Assets.xcassets/Theme/system/separator.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/separator.colorset/Contents.json index 04fbae35d..ec5491c94 100644 --- a/Mastodon/Resources/Assets.xcassets/Theme/system/separator.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/separator.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "0.290", - "blue" : "0x43", - "green" : "0x3C", - "red" : "0x3C" + "blue" : "0.263", + "green" : "0.235", + "red" : "0.235" } }, "idiom" : "universal" @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "0.650", - "blue" : "0x58", - "green" : "0x54", - "red" : "0x54" + "blue" : "0.345", + "green" : "0.329", + "red" : "0.329" } }, "idiom" : "universal" diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/tab.bar.item.inactive.icon.color.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/tab.bar.item.inactive.icon.color.colorset/Contents.json new file mode 100644 index 000000000..baf4b4b42 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/tab.bar.item.inactive.icon.color.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.600", + "green" : "0.600", + "red" : "0.600" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.600", + "green" : "0.600", + "red" : "0.600" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Font/Staatliches-Regular.ttf b/MastodonSDK/Sources/MastodonAsset/Font/Staatliches-Regular.ttf new file mode 100644 index 000000000..5036a9356 Binary files /dev/null and b/MastodonSDK/Sources/MastodonAsset/Font/Staatliches-Regular.ttf differ diff --git a/MastodonSDK/Sources/MastodonAsset/Generated/Assets.swift b/MastodonSDK/Sources/MastodonAsset/Generated/Assets.swift new file mode 100644 index 000000000..26e54900f --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Generated/Assets.swift @@ -0,0 +1,284 @@ +// swiftlint:disable all +// Generated using SwiftGen — https://github.com/SwiftGen/SwiftGen + +#if os(macOS) + import AppKit +#elseif os(iOS) + import UIKit +#elseif os(tvOS) || os(watchOS) + import UIKit +#endif + +// Deprecated typealiases +@available(*, deprecated, renamed: "ColorAsset.Color", message: "This typealias will be removed in SwiftGen 7.0") +public typealias AssetColorTypeAlias = ColorAsset.Color +@available(*, deprecated, renamed: "ImageAsset.Image", message: "This typealias will be removed in SwiftGen 7.0") +public typealias AssetImageTypeAlias = ImageAsset.Image + +// swiftlint:disable superfluous_disable_command file_length implicit_return + +// MARK: - Asset Catalogs + +// swiftlint:disable identifier_name line_length nesting type_body_length type_name +public enum Asset { + public enum Arrow { + public static let `repeat` = ImageAsset(name: "Arrow/repeat") + public static let repeatSmall = ImageAsset(name: "Arrow/repeat.small") + } + public enum Asset { + public static let email = ImageAsset(name: "Asset/email") + public static let friends = ImageAsset(name: "Asset/friends") + public static let mastodonTextLogo = ImageAsset(name: "Asset/mastodon.text.logo") + } + public enum Circles { + public static let plusCircleFill = ImageAsset(name: "Circles/plus.circle.fill") + public static let plusCircle = ImageAsset(name: "Circles/plus.circle") + } + public enum Colors { + public enum Border { + public static let composePoll = ColorAsset(name: "Colors/Border/compose.poll") + public static let searchCard = ColorAsset(name: "Colors/Border/searchCard") + public static let status = ColorAsset(name: "Colors/Border/status") + } + public enum Button { + public static let actionToolbar = ColorAsset(name: "Colors/Button/action.toolbar") + public static let disabled = ColorAsset(name: "Colors/Button/disabled") + public static let inactive = ColorAsset(name: "Colors/Button/inactive") + } + public enum Icon { + public static let plus = ColorAsset(name: "Colors/Icon/plus") + } + public enum Label { + public static let primary = ColorAsset(name: "Colors/Label/primary") + public static let primaryReverse = ColorAsset(name: "Colors/Label/primary.reverse") + public static let secondary = ColorAsset(name: "Colors/Label/secondary") + public static let tertiary = ColorAsset(name: "Colors/Label/tertiary") + } + public enum Notification { + public static let favourite = ColorAsset(name: "Colors/Notification/favourite") + public static let mention = ColorAsset(name: "Colors/Notification/mention") + public static let reblog = ColorAsset(name: "Colors/Notification/reblog") + } + public enum Poll { + public static let disabled = ColorAsset(name: "Colors/Poll/disabled") + } + public enum Shadow { + public static let searchCard = ColorAsset(name: "Colors/Shadow/SearchCard") + } + public enum Slider { + public static let track = ColorAsset(name: "Colors/Slider/track") + } + public enum TextField { + public static let background = ColorAsset(name: "Colors/TextField/background") + public static let invalid = ColorAsset(name: "Colors/TextField/invalid") + public static let valid = ColorAsset(name: "Colors/TextField/valid") + } + public static let alertYellow = ColorAsset(name: "Colors/alert.yellow") + public static let badgeBackground = ColorAsset(name: "Colors/badge.background") + public static let battleshipGrey = ColorAsset(name: "Colors/battleshipGrey") + public static let brandBlue = ColorAsset(name: "Colors/brand.blue") + public static let brandBlueDarken20 = ColorAsset(name: "Colors/brand.blue.darken.20") + public static let dangerBorder = ColorAsset(name: "Colors/danger.border") + public static let danger = ColorAsset(name: "Colors/danger") + public static let disabled = ColorAsset(name: "Colors/disabled") + public static let inactive = ColorAsset(name: "Colors/inactive") + public static let mediaTypeIndicotor = ColorAsset(name: "Colors/media.type.indicotor") + public static let successGreen = ColorAsset(name: "Colors/success.green") + public static let systemOrange = ColorAsset(name: "Colors/system.orange") + } + public enum Communication { + public static let bubbleLeftAndBubbleRight = ImageAsset(name: "Communication/bubble.left.and.bubble.right") + public static let share = ImageAsset(name: "Communication/share") + } + public enum Connectivity { + public static let photoFillSplit = ImageAsset(name: "Connectivity/photo.fill.split") + } + public enum Human { + public static let eyeCircleFill = ImageAsset(name: "Human/eye.circle.fill") + public static let eyeSlashCircleFill = ImageAsset(name: "Human/eye.slash.circle.fill") + public static let faceSmilingAdaptive = ImageAsset(name: "Human/face.smiling.adaptive") + } + public enum ObjectsAndTools { + public static let starFill = ImageAsset(name: "ObjectsAndTools/star.fill") + public static let star = ImageAsset(name: "ObjectsAndTools/star") + } + public enum Scene { + public enum Onboarding { + public static let avatarPlaceholder = ImageAsset(name: "Scene/Onboarding/avatar.placeholder") + public static let background = ColorAsset(name: "Scene/Onboarding/background") + public static let navigationBackButtonBackground = ColorAsset(name: "Scene/Onboarding/navigation.back.button.background") + public static let navigationBackButtonBackgroundHighlighted = ColorAsset(name: "Scene/Onboarding/navigation.back.button.background.highlighted") + public static let navigationNextButtonBackground = ColorAsset(name: "Scene/Onboarding/navigation.next.button.background") + public static let navigationNextButtonBackgroundHighlighted = ColorAsset(name: "Scene/Onboarding/navigation.next.button.background.highlighted") + public static let searchBarBackground = ColorAsset(name: "Scene/Onboarding/search.bar.background") + public static let textFieldBackground = ColorAsset(name: "Scene/Onboarding/textField.background") + } + public enum Profile { + public enum Banner { + public static let bioEditBackgroundGray = ColorAsset(name: "Scene/Profile/Banner/bio.edit.background.gray") + public static let nameEditBackgroundGray = ColorAsset(name: "Scene/Profile/Banner/name.edit.background.gray") + public static let usernameGray = ColorAsset(name: "Scene/Profile/Banner/username.gray") + } + public enum RelationshipButton { + public static let backgroundDark = ColorAsset(name: "Scene/Profile/RelationshipButton/background.dark") + public static let backgroundHighlightedDark = ColorAsset(name: "Scene/Profile/RelationshipButton/background.highlighted.dark") + public static let backgroundHighlightedLight = ColorAsset(name: "Scene/Profile/RelationshipButton/background.highlighted.light") + public static let backgroundLight = ColorAsset(name: "Scene/Profile/RelationshipButton/background.light") + } + } + public enum Report { + public static let background = ColorAsset(name: "Scene/Report/background") + public static let reportBanner = ColorAsset(name: "Scene/Report/report.banner") + } + public enum Setting { + public static let background = ColorAsset(name: "Scene/Setting/background") + } + public enum Sidebar { + public static let logo = ImageAsset(name: "Scene/Sidebar/logo") + } + public enum Welcome { + public enum Illustration { + public static let backgroundCyan = ColorAsset(name: "Scene/Welcome/illustration/background.cyan") + public static let cloudBaseExtend = ImageAsset(name: "Scene/Welcome/illustration/cloud.base.extend") + public static let cloudBase = ImageAsset(name: "Scene/Welcome/illustration/cloud.base") + public static let elephantOnAirplaneWithContrail = ImageAsset(name: "Scene/Welcome/illustration/elephant.on.airplane.with.contrail") + public static let elephantThreeOnGrassExtend = ImageAsset(name: "Scene/Welcome/illustration/elephant.three.on.grass.extend") + public static let elephantThreeOnGrass = ImageAsset(name: "Scene/Welcome/illustration/elephant.three.on.grass") + public static let elephantThreeOnGrassWithTreeThree = ImageAsset(name: "Scene/Welcome/illustration/elephant.three.on.grass.with.tree.three") + public static let elephantThreeOnGrassWithTreeTwo = ImageAsset(name: "Scene/Welcome/illustration/elephant.three.on.grass.with.tree.two") + } + public static let mastodonLogoBlack = ImageAsset(name: "Scene/Welcome/mastodon.logo.black") + public static let mastodonLogoBlackLarge = ImageAsset(name: "Scene/Welcome/mastodon.logo.black.large") + public static let mastodonLogo = ImageAsset(name: "Scene/Welcome/mastodon.logo") + public static let mastodonLogoLarge = ImageAsset(name: "Scene/Welcome/mastodon.logo.large") + public static let signInButtonBackground = ColorAsset(name: "Scene/Welcome/sign.in.button.background") + } + } + public enum Settings { + public static let automatic = ImageAsset(name: "Settings/automatic") + public static let dark = ImageAsset(name: "Settings/dark") + public static let light = ImageAsset(name: "Settings/light") + } + public enum Theme { + public enum Mastodon { + public static let composeToolbarBackground = ColorAsset(name: "Theme/Mastodon/compose.toolbar.background") + public static let contentWarningOverlayBackground = ColorAsset(name: "Theme/Mastodon/content.warning.overlay.background") + public static let navigationBarBackground = ColorAsset(name: "Theme/Mastodon/navigation.bar.background") + public static let profileFieldCollectionViewBackground = ColorAsset(name: "Theme/Mastodon/profile.field.collection.view.background") + public static let secondaryGroupedSystemBackground = ColorAsset(name: "Theme/Mastodon/secondary.grouped.system.background") + public static let secondarySystemBackground = ColorAsset(name: "Theme/Mastodon/secondary.system.background") + public static let sidebarBackground = ColorAsset(name: "Theme/Mastodon/sidebar.background") + public static let systemBackground = ColorAsset(name: "Theme/Mastodon/system.background") + public static let systemElevatedBackground = ColorAsset(name: "Theme/Mastodon/system.elevated.background") + public static let systemGroupedBackground = ColorAsset(name: "Theme/Mastodon/system.grouped.background") + public static let tabBarBackground = ColorAsset(name: "Theme/Mastodon/tab.bar.background") + public static let tableViewCellBackground = ColorAsset(name: "Theme/Mastodon/table.view.cell.background") + public static let tableViewCellSelectionBackground = ColorAsset(name: "Theme/Mastodon/table.view.cell.selection.background") + public static let tertiarySystemBackground = ColorAsset(name: "Theme/Mastodon/tertiary.system.background") + public static let tertiarySystemGroupedBackground = ColorAsset(name: "Theme/Mastodon/tertiary.system.grouped.background") + public static let notificationStatusBorderColor = ColorAsset(name: "Theme/Mastodon/notification.status.border.color") + public static let separator = ColorAsset(name: "Theme/Mastodon/separator") + public static let tabBarItemInactiveIconColor = ColorAsset(name: "Theme/Mastodon/tab.bar.item.inactive.icon.color") + } + public enum System { + public static let composeToolbarBackground = ColorAsset(name: "Theme/system/compose.toolbar.background") + public static let contentWarningOverlayBackground = ColorAsset(name: "Theme/system/content.warning.overlay.background") + public static let navigationBarBackground = ColorAsset(name: "Theme/system/navigation.bar.background") + public static let profileFieldCollectionViewBackground = ColorAsset(name: "Theme/system/profile.field.collection.view.background") + public static let secondaryGroupedSystemBackground = ColorAsset(name: "Theme/system/secondary.grouped.system.background") + public static let secondarySystemBackground = ColorAsset(name: "Theme/system/secondary.system.background") + public static let sidebarBackground = ColorAsset(name: "Theme/system/sidebar.background") + public static let systemBackground = ColorAsset(name: "Theme/system/system.background") + public static let systemElevatedBackground = ColorAsset(name: "Theme/system/system.elevated.background") + public static let systemGroupedBackground = ColorAsset(name: "Theme/system/system.grouped.background") + public static let tabBarBackground = ColorAsset(name: "Theme/system/tab.bar.background") + public static let tableViewCellBackground = ColorAsset(name: "Theme/system/table.view.cell.background") + public static let tableViewCellSelectionBackground = ColorAsset(name: "Theme/system/table.view.cell.selection.background") + public static let tertiarySystemBackground = ColorAsset(name: "Theme/system/tertiary.system.background") + public static let tertiarySystemGroupedBackground = ColorAsset(name: "Theme/system/tertiary.system.grouped.background") + public static let notificationStatusBorderColor = ColorAsset(name: "Theme/system/notification.status.border.color") + public static let separator = ColorAsset(name: "Theme/system/separator") + public static let tabBarItemInactiveIconColor = ColorAsset(name: "Theme/system/tab.bar.item.inactive.icon.color") + } + } +} +// swiftlint:enable identifier_name line_length nesting type_body_length type_name + +// MARK: - Implementation Details + +public final class ColorAsset { + public fileprivate(set) var name: String + + #if os(macOS) + public typealias Color = NSColor + #elseif os(iOS) || os(tvOS) || os(watchOS) + public typealias Color = UIColor + #endif + + @available(iOS 11.0, tvOS 11.0, watchOS 4.0, macOS 10.13, *) + public private(set) lazy var color: Color = { + guard let color = Color(asset: self) else { + fatalError("Unable to load color asset named \(name).") + } + return color + }() + + fileprivate init(name: String) { + self.name = name + } +} + +public extension ColorAsset.Color { + @available(iOS 11.0, tvOS 11.0, watchOS 4.0, macOS 10.13, *) + convenience init?(asset: ColorAsset) { + let bundle = Bundle.module + #if os(iOS) || os(tvOS) + self.init(named: asset.name, in: bundle, compatibleWith: nil) + #elseif os(macOS) + self.init(named: NSColor.Name(asset.name), bundle: bundle) + #elseif os(watchOS) + self.init(named: asset.name) + #endif + } +} + +public struct ImageAsset { + public fileprivate(set) var name: String + + #if os(macOS) + public typealias Image = NSImage + #elseif os(iOS) || os(tvOS) || os(watchOS) + public typealias Image = UIImage + #endif + + public var image: Image { + let bundle = Bundle.module + #if os(iOS) || os(tvOS) + let image = Image(named: name, in: bundle, compatibleWith: nil) + #elseif os(macOS) + let name = NSImage.Name(self.name) + let image = (bundle == .main) ? NSImage(named: name) : bundle.image(forResource: name) + #elseif os(watchOS) + let image = Image(named: name) + #endif + guard let result = image else { + fatalError("Unable to load image asset named \(name).") + } + return result + } +} + +public extension ImageAsset.Image { + @available(macOS, deprecated, + message: "This initializer is unsafe on macOS, please use the ImageAsset.image property") + convenience init?(asset: ImageAsset) { + #if os(iOS) || os(tvOS) + let bundle = Bundle.module + self.init(named: asset.name, in: bundle, compatibleWith: nil) + #elseif os(macOS) + self.init(named: NSImage.Name(asset.name)) + #elseif os(watchOS) + self.init(named: asset.name) + #endif + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Generated/Fonts.swift b/MastodonSDK/Sources/MastodonAsset/Generated/Fonts.swift new file mode 100644 index 000000000..22c6c9ed3 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Generated/Fonts.swift @@ -0,0 +1,78 @@ +// swiftlint:disable all +// Generated using SwiftGen — https://github.com/SwiftGen/SwiftGen + +#if os(OSX) + import AppKit.NSFont +#elseif os(iOS) || os(tvOS) || os(watchOS) + import UIKit.UIFont +#endif + +// Deprecated typealiases +@available(*, deprecated, renamed: "FontConvertible.Font", message: "This typealias will be removed in SwiftGen 7.0") +public typealias Font = FontConvertible.Font + +// swiftlint:disable superfluous_disable_command +// swiftlint:disable file_length + +// MARK: - Fonts + +// swiftlint:disable identifier_name line_length type_body_length +public enum FontFamily { + public enum Staatliches { + public static let regular = FontConvertible(name: "Staatliches-Regular", family: "Staatliches", path: "Staatliches-Regular.ttf") + public static let all: [FontConvertible] = [regular] + } + public static let allCustomFonts: [FontConvertible] = [Staatliches.all].flatMap { $0 } + public static func registerAllCustomFonts() { + allCustomFonts.forEach { $0.register() } + } +} +// swiftlint:enable identifier_name line_length type_body_length + +// MARK: - Implementation Details + +public struct FontConvertible { + public let name: String + public let family: String + public let path: String + + #if os(OSX) + public typealias Font = NSFont + #elseif os(iOS) || os(tvOS) || os(watchOS) + public typealias Font = UIFont + #endif + + public func font(size: CGFloat) -> Font { + guard let font = Font(font: self, size: size) else { + fatalError("Unable to initialize font '\(name)' (\(family))") + } + return font + } + + public func register() { + // swiftlint:disable:next conditional_returns_on_newline + guard let url = url else { return } + CTFontManagerRegisterFontsForURL(url as CFURL, .process, nil) + } + + fileprivate var url: URL? { + // swiftlint:disable:next implicit_return + return Bundle.module.url(forResource: path, withExtension: nil) + } +} + +public extension FontConvertible.Font { + convenience init?(font: FontConvertible, size: CGFloat) { + #if os(iOS) || os(tvOS) || os(watchOS) + if !UIFont.fontNames(forFamilyName: font.family).contains(font.name) { + font.register() + } + #elseif os(OSX) + if let url = font.url, CTFontManagerGetScopeForURL(url as CFURL) == .none { + font.register() + } + #endif + + self.init(name: font.name, size: size) + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/MastodonAsset+Bundle.swift b/MastodonSDK/Sources/MastodonAsset/MastodonAsset+Bundle.swift new file mode 100644 index 000000000..45d5b3371 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/MastodonAsset+Bundle.swift @@ -0,0 +1,14 @@ +// +// MastodonAsset+Bundle.swift +// +// +// Created by MainasuK on 2022-1-10. +// + +import Foundation + +public enum MastodonAsset { + public static var bundle: Bundle { + Bundle.module + } +} diff --git a/AppShared/AppName.swift b/MastodonSDK/Sources/MastodonCommon/AppName.swift similarity index 90% rename from AppShared/AppName.swift rename to MastodonSDK/Sources/MastodonCommon/AppName.swift index e2d356263..df052fb9d 100644 --- a/AppShared/AppName.swift +++ b/MastodonSDK/Sources/MastodonCommon/AppName.swift @@ -1,6 +1,6 @@ // // AppName.swift -// AppShared +// MastodonCommon // // Created by MainasuK Cirno on 2021-4-27. // diff --git a/Mastodon/Extension/CALayer.swift b/MastodonSDK/Sources/MastodonExtension/CALayer.swift similarity index 95% rename from Mastodon/Extension/CALayer.swift rename to MastodonSDK/Sources/MastodonExtension/CALayer.swift index 41ce739ee..684a4a706 100644 --- a/Mastodon/Extension/CALayer.swift +++ b/MastodonSDK/Sources/MastodonExtension/CALayer.swift @@ -9,7 +9,7 @@ import UIKit extension CALayer { - func setupShadow( + public func setupShadow( color: UIColor = .black, alpha: Float = 0.5, x: CGFloat = 0, @@ -43,9 +43,8 @@ extension CALayer { } } - func removeShadow() { + public func removeShadow() { shadowRadius = 0 } - - + } diff --git a/MastodonSDK/Sources/MastodonExtension/Collection.swift b/MastodonSDK/Sources/MastodonExtension/Collection.swift new file mode 100644 index 000000000..8892583df --- /dev/null +++ b/MastodonSDK/Sources/MastodonExtension/Collection.swift @@ -0,0 +1,66 @@ +// +// Collection.swift +// +// +// Created by MainasuK on 2021-12-7. +// + +import Foundation + +// https://gist.github.com/DougGregor/92a2e4f6e11f6d733fb5065e9d1c880f +extension Collection { + public func parallelMap<T>( + parallelism requestedParallelism: Int? = nil, + _ transform: @escaping (Element) async throws -> T + ) async rethrows -> [T] { + let defaultParallelism = 2 + let parallelism = requestedParallelism ?? defaultParallelism + + let n = count + if n == 0 { + return [] + } + return try await withThrowingTaskGroup(of: (Int, T).self, returning: [T].self) { group in + var result = [T?](repeatElement(nil, count: n)) + + var i = self.startIndex + var submitted = 0 + + func submitNext() async throws { + if i == self.endIndex { return } + + group.addTask { [submitted, i] in + let value = try await transform(self[i]) + return (submitted, value) + } + submitted += 1 + formIndex(after: &i) + } + + // submit first initial tasks + for _ in 0 ..< parallelism { + try await submitNext() + } + + // as each task completes, submit a new task until we run out of work + while let (index, taskResult) = try await group.next() { + result[index] = taskResult + + try Task.checkCancellation() + try await submitNext() + } + + assert(result.count == n) + return Array(result.compactMap { $0 }) + } + } + + func parallelEach( + parallelism requestedParallelism: Int? = nil, + _ work: @escaping (Element) async throws -> Void + ) async rethrows { + _ = try await parallelMap { + try await work($0) + } + } +} diff --git a/MastodonSDK/Sources/MastodonExtension/Publisher.swift b/MastodonSDK/Sources/MastodonExtension/Publisher.swift new file mode 100644 index 000000000..6bbf19f57 --- /dev/null +++ b/MastodonSDK/Sources/MastodonExtension/Publisher.swift @@ -0,0 +1,90 @@ +import Combine + +// Ref: https://www.swiftbysundell.com/articles/connecting-async-await-with-other-swift-code/ + +extension Publishers { + public struct MissingOutputError: Error {} +} + +extension Publisher { + public func singleOutput() async throws -> Output { + var cancellable: AnyCancellable? + var didReceiveValue = false + + return try await withCheckedThrowingContinuation { continuation in + cancellable = sink( + receiveCompletion: { completion in + switch completion { + case .failure(let error): + continuation.resume(throwing: error) + case .finished: + if !didReceiveValue { + continuation.resume( + throwing: Publishers.MissingOutputError() + ) + } + } + }, + receiveValue: { value in + guard !didReceiveValue else { return } + + didReceiveValue = true + cancellable?.cancel() + continuation.resume(returning: value) + } + ) + } + } +} + +// ref: https://www.swiftbysundell.com/articles/calling-async-functions-within-a-combine-pipeline/ + +extension Publisher { + public func asyncMap<T>( + _ transform: @escaping (Output) async -> T + ) -> Publishers.FlatMap<Future<T, Never>, Self> { + flatMap { value in + Future { promise in + Task { + let output = await transform(value) + promise(.success(output)) + } + } + } + } + + public func asyncMap<T>( + _ transform: @escaping (Output) async throws -> T + ) -> Publishers.FlatMap<Future<T, Error>, Self> { + flatMap { value in + Future { promise in + Task { + do { + let output = try await transform(value) + promise(.success(output)) + } catch { + promise(.failure(error)) + } + } + } + } + } + + public func asyncMap<T>( + _ transform: @escaping (Output) async throws -> T + ) -> Publishers.FlatMap<Future<T, Error>, + Publishers.SetFailureType<Self, Error>> { + flatMap { value in + Future { promise in + Task { + do { + let output = try await transform(value) + promise(.success(output)) + } catch { + promise(.failure(error)) + } + } + } + } + } +} diff --git a/Mastodon/Extension/UIButton.swift b/MastodonSDK/Sources/MastodonExtension/UIButton.swift similarity index 89% rename from Mastodon/Extension/UIButton.swift rename to MastodonSDK/Sources/MastodonExtension/UIButton.swift index 31043157a..6e939f3c6 100644 --- a/Mastodon/Extension/UIButton.swift +++ b/MastodonSDK/Sources/MastodonExtension/UIButton.swift @@ -1,14 +1,14 @@ // // UIButton.swift -// Mastodon +// // -// Created by sxiaojian on 2021/2/1. +// Created by MainasuK on 2022-1-17. // import UIKit extension UIButton { - func setInsets( + public func setInsets( forContentPadding contentPadding: UIEdgeInsets, imageTitlePadding: CGFloat ) { @@ -44,7 +44,7 @@ extension UIButton { } extension UIButton { - func setBackgroundColor(_ color: UIColor, for state: UIControl.State) { + public func setBackgroundColor(_ color: UIColor, for state: UIControl.State) { self.setBackgroundImage( UIImage.placeholder(color: color), for: state diff --git a/MastodonSDK/Sources/MastodonExtension/UIImage.swift b/MastodonSDK/Sources/MastodonExtension/UIImage.swift index 178d289db..e3560af63 100644 --- a/MastodonSDK/Sources/MastodonExtension/UIImage.swift +++ b/MastodonSDK/Sources/MastodonExtension/UIImage.swift @@ -10,12 +10,28 @@ import CoreImage.CIFilterBuiltins import UIKit extension UIImage { - public static func placeholder(size: CGSize = CGSize(width: 1, height: 1), color: UIColor) -> UIImage { + public static func placeholder( + size: CGSize = CGSize(width: 1, height: 1), + color: UIColor, + cornerRadius: CGFloat = 0 + ) -> UIImage { let render = UIGraphicsImageRenderer(size: size) return render.image { (context: UIGraphicsImageRendererContext) in + // set clear fill context.cgContext.setFillColor(color.cgColor) - context.fill(CGRect(origin: .zero, size: size)) + + let rect = CGRect(origin: .zero, size: size) + + // clip corner if needs + if cornerRadius > 0 { + let path = UIBezierPath(roundedRect: rect, cornerRadius: cornerRadius).cgPath + context.cgContext.addPath(path) + context.cgContext.clip(using: .evenOdd) + } + + // set fill + context.fill(rect) } } } diff --git a/MastodonSDK/Sources/MastodonExtension/UIView.swift b/MastodonSDK/Sources/MastodonExtension/UIView.swift new file mode 100644 index 000000000..5466c464d --- /dev/null +++ b/MastodonSDK/Sources/MastodonExtension/UIView.swift @@ -0,0 +1,14 @@ +// +// UIView.swift +// +// +// Created by MainasuK on 2022-1-17. +// + +import UIKit + +extension UIView { + public static var isZoomedMode: Bool { + return UIScreen.main.scale != UIScreen.main.nativeScale + } +} diff --git a/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift b/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift new file mode 100644 index 000000000..69e15f2d3 --- /dev/null +++ b/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift @@ -0,0 +1,1215 @@ +// swiftlint:disable all +// Generated using SwiftGen — https://github.com/SwiftGen/SwiftGen + +import Foundation + +// swiftlint:disable superfluous_disable_command file_length implicit_return + +// MARK: - Strings + +// swiftlint:disable explicit_type_interface function_parameter_count identifier_name line_length +// swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces +public enum L10n { + + public enum Common { + public enum Alerts { + public enum BlockDomain { + /// Block Domain + public static let blockEntireDomain = L10n.tr("Localizable", "Common.Alerts.BlockDomain.BlockEntireDomain") + /// 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. + public static func title(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Alerts.BlockDomain.Title", String(describing: p1)) + } + } + public enum CleanCache { + /// Successfully cleaned %@ cache. + public static func message(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Alerts.CleanCache.Message", String(describing: p1)) + } + /// Clean Cache + public static let title = L10n.tr("Localizable", "Common.Alerts.CleanCache.Title") + } + public enum Common { + /// Please try again. + public static let pleaseTryAgain = L10n.tr("Localizable", "Common.Alerts.Common.PleaseTryAgain") + /// Please try again later. + public static let pleaseTryAgainLater = L10n.tr("Localizable", "Common.Alerts.Common.PleaseTryAgainLater") + } + public enum DeletePost { + /// Are you sure you want to delete this post? + public static let message = L10n.tr("Localizable", "Common.Alerts.DeletePost.Message") + /// Delete Post + public static let title = L10n.tr("Localizable", "Common.Alerts.DeletePost.Title") + } + public enum DiscardPostContent { + /// Confirm to discard composed post content. + public static let message = L10n.tr("Localizable", "Common.Alerts.DiscardPostContent.Message") + /// Discard Draft + public static let title = L10n.tr("Localizable", "Common.Alerts.DiscardPostContent.Title") + } + public enum EditProfileFailure { + /// Cannot edit profile. Please try again. + public static let message = L10n.tr("Localizable", "Common.Alerts.EditProfileFailure.Message") + /// Edit Profile Error + public static let title = L10n.tr("Localizable", "Common.Alerts.EditProfileFailure.Title") + } + public enum PublishPostFailure { + /// Failed to publish the post.\nPlease check your internet connection. + public static let message = L10n.tr("Localizable", "Common.Alerts.PublishPostFailure.Message") + /// Publish Failure + public static let title = L10n.tr("Localizable", "Common.Alerts.PublishPostFailure.Title") + public enum AttachmentsMessage { + /// Cannot attach more than one video. + public static let moreThanOneVideo = L10n.tr("Localizable", "Common.Alerts.PublishPostFailure.AttachmentsMessage.MoreThanOneVideo") + /// Cannot attach a video to a post that already contains images. + public static let videoAttachWithPhoto = L10n.tr("Localizable", "Common.Alerts.PublishPostFailure.AttachmentsMessage.VideoAttachWithPhoto") + } + } + public enum SavePhotoFailure { + /// Please enable the photo library access permission to save the photo. + public static let message = L10n.tr("Localizable", "Common.Alerts.SavePhotoFailure.Message") + /// Save Photo Failure + public static let title = L10n.tr("Localizable", "Common.Alerts.SavePhotoFailure.Title") + } + public enum ServerError { + /// Server Error + public static let title = L10n.tr("Localizable", "Common.Alerts.ServerError.Title") + } + public enum SignOut { + /// Sign Out + public static let confirm = L10n.tr("Localizable", "Common.Alerts.SignOut.Confirm") + /// Are you sure you want to sign out? + public static let message = L10n.tr("Localizable", "Common.Alerts.SignOut.Message") + /// Sign Out + public static let title = L10n.tr("Localizable", "Common.Alerts.SignOut.Title") + } + public enum SignUpFailure { + /// Sign Up Failure + public static let title = L10n.tr("Localizable", "Common.Alerts.SignUpFailure.Title") + } + public enum VoteFailure { + /// The poll has ended + public static let pollEnded = L10n.tr("Localizable", "Common.Alerts.VoteFailure.PollEnded") + /// Vote Failure + public static let title = L10n.tr("Localizable", "Common.Alerts.VoteFailure.Title") + } + } + public enum Controls { + public enum Actions { + /// Add + public static let add = L10n.tr("Localizable", "Common.Controls.Actions.Add") + /// Back + public static let back = L10n.tr("Localizable", "Common.Controls.Actions.Back") + /// Block %@ + public static func blockDomain(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Actions.BlockDomain", String(describing: p1)) + } + /// Cancel + public static let cancel = L10n.tr("Localizable", "Common.Controls.Actions.Cancel") + /// Compose + public static let compose = L10n.tr("Localizable", "Common.Controls.Actions.Compose") + /// Confirm + public static let confirm = L10n.tr("Localizable", "Common.Controls.Actions.Confirm") + /// Continue + public static let `continue` = L10n.tr("Localizable", "Common.Controls.Actions.Continue") + /// Copy Photo + public static let copyPhoto = L10n.tr("Localizable", "Common.Controls.Actions.CopyPhoto") + /// Delete + public static let delete = L10n.tr("Localizable", "Common.Controls.Actions.Delete") + /// Discard + public static let discard = L10n.tr("Localizable", "Common.Controls.Actions.Discard") + /// Done + public static let done = L10n.tr("Localizable", "Common.Controls.Actions.Done") + /// Edit + public static let edit = L10n.tr("Localizable", "Common.Controls.Actions.Edit") + /// Find people to follow + public static let findPeople = L10n.tr("Localizable", "Common.Controls.Actions.FindPeople") + /// Manually search instead + public static let manuallySearch = L10n.tr("Localizable", "Common.Controls.Actions.ManuallySearch") + /// Next + public static let next = L10n.tr("Localizable", "Common.Controls.Actions.Next") + /// OK + public static let ok = L10n.tr("Localizable", "Common.Controls.Actions.Ok") + /// Open + public static let `open` = L10n.tr("Localizable", "Common.Controls.Actions.Open") + /// Open in Browser + public static let openInBrowser = L10n.tr("Localizable", "Common.Controls.Actions.OpenInBrowser") + /// Open in Safari + public static let openInSafari = L10n.tr("Localizable", "Common.Controls.Actions.OpenInSafari") + /// Preview + public static let preview = L10n.tr("Localizable", "Common.Controls.Actions.Preview") + /// Previous + public static let previous = L10n.tr("Localizable", "Common.Controls.Actions.Previous") + /// Remove + public static let remove = L10n.tr("Localizable", "Common.Controls.Actions.Remove") + /// Reply + public static let reply = L10n.tr("Localizable", "Common.Controls.Actions.Reply") + /// Report %@ + public static func reportUser(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Actions.ReportUser", String(describing: p1)) + } + /// Save + public static let save = L10n.tr("Localizable", "Common.Controls.Actions.Save") + /// Save Photo + public static let savePhoto = L10n.tr("Localizable", "Common.Controls.Actions.SavePhoto") + /// See More + public static let seeMore = L10n.tr("Localizable", "Common.Controls.Actions.SeeMore") + /// Settings + public static let settings = L10n.tr("Localizable", "Common.Controls.Actions.Settings") + /// Share + public static let share = L10n.tr("Localizable", "Common.Controls.Actions.Share") + /// Share Post + public static let sharePost = L10n.tr("Localizable", "Common.Controls.Actions.SharePost") + /// Share %@ + public static func shareUser(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Actions.ShareUser", String(describing: p1)) + } + /// Sign In + public static let signIn = L10n.tr("Localizable", "Common.Controls.Actions.SignIn") + /// Sign Up + public static let signUp = L10n.tr("Localizable", "Common.Controls.Actions.SignUp") + /// Skip + public static let skip = L10n.tr("Localizable", "Common.Controls.Actions.Skip") + /// Take Photo + public static let takePhoto = L10n.tr("Localizable", "Common.Controls.Actions.TakePhoto") + /// Try Again + public static let tryAgain = L10n.tr("Localizable", "Common.Controls.Actions.TryAgain") + /// Unblock %@ + public static func unblockDomain(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Actions.UnblockDomain", String(describing: p1)) + } + } + public enum Friendship { + /// Block + public static let block = L10n.tr("Localizable", "Common.Controls.Friendship.Block") + /// Block %@ + public static func blockDomain(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Friendship.BlockDomain", String(describing: p1)) + } + /// Blocked + public static let blocked = L10n.tr("Localizable", "Common.Controls.Friendship.Blocked") + /// Block %@ + public static func blockUser(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Friendship.BlockUser", String(describing: p1)) + } + /// Edit Info + public static let editInfo = L10n.tr("Localizable", "Common.Controls.Friendship.EditInfo") + /// Follow + public static let follow = L10n.tr("Localizable", "Common.Controls.Friendship.Follow") + /// Following + public static let following = L10n.tr("Localizable", "Common.Controls.Friendship.Following") + /// Mute + public static let mute = L10n.tr("Localizable", "Common.Controls.Friendship.Mute") + /// Muted + public static let muted = L10n.tr("Localizable", "Common.Controls.Friendship.Muted") + /// Mute %@ + public static func muteUser(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Friendship.MuteUser", String(describing: p1)) + } + /// Pending + public static let pending = L10n.tr("Localizable", "Common.Controls.Friendship.Pending") + /// Request + public static let request = L10n.tr("Localizable", "Common.Controls.Friendship.Request") + /// Unblock + public static let unblock = L10n.tr("Localizable", "Common.Controls.Friendship.Unblock") + /// Unblock %@ + public static func unblockUser(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Friendship.UnblockUser", String(describing: p1)) + } + /// Unmute + public static let unmute = L10n.tr("Localizable", "Common.Controls.Friendship.Unmute") + /// Unmute %@ + public static func unmuteUser(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Friendship.UnmuteUser", String(describing: p1)) + } + } + public enum Keyboard { + public enum Common { + /// Compose New Post + public static let composeNewPost = L10n.tr("Localizable", "Common.Controls.Keyboard.Common.ComposeNewPost") + /// Open Settings + public static let openSettings = L10n.tr("Localizable", "Common.Controls.Keyboard.Common.OpenSettings") + /// Show Favorites + public static let showFavorites = L10n.tr("Localizable", "Common.Controls.Keyboard.Common.ShowFavorites") + /// Switch to %@ + public static func switchToTab(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Keyboard.Common.SwitchToTab", String(describing: p1)) + } + } + public enum SegmentedControl { + /// Next Section + public static let nextSection = L10n.tr("Localizable", "Common.Controls.Keyboard.SegmentedControl.NextSection") + /// Previous Section + public static let previousSection = L10n.tr("Localizable", "Common.Controls.Keyboard.SegmentedControl.PreviousSection") + } + public enum Timeline { + /// Next Post + public static let nextStatus = L10n.tr("Localizable", "Common.Controls.Keyboard.Timeline.NextStatus") + /// Open Author's Profile + public static let openAuthorProfile = L10n.tr("Localizable", "Common.Controls.Keyboard.Timeline.OpenAuthorProfile") + /// Open Reblogger's Profile + public static let openRebloggerProfile = L10n.tr("Localizable", "Common.Controls.Keyboard.Timeline.OpenRebloggerProfile") + /// Open Post + public static let openStatus = L10n.tr("Localizable", "Common.Controls.Keyboard.Timeline.OpenStatus") + /// Preview Image + public static let previewImage = L10n.tr("Localizable", "Common.Controls.Keyboard.Timeline.PreviewImage") + /// Previous Post + public static let previousStatus = L10n.tr("Localizable", "Common.Controls.Keyboard.Timeline.PreviousStatus") + /// Reply to Post + public static let replyStatus = L10n.tr("Localizable", "Common.Controls.Keyboard.Timeline.ReplyStatus") + /// Toggle Content Warning + public static let toggleContentWarning = L10n.tr("Localizable", "Common.Controls.Keyboard.Timeline.ToggleContentWarning") + /// Toggle Favorite on Post + public static let toggleFavorite = L10n.tr("Localizable", "Common.Controls.Keyboard.Timeline.ToggleFavorite") + /// Toggle Reblog on Post + public static let toggleReblog = L10n.tr("Localizable", "Common.Controls.Keyboard.Timeline.ToggleReblog") + } + } + public enum Status { + /// Content Warning + public static let contentWarning = L10n.tr("Localizable", "Common.Controls.Status.ContentWarning") + /// Tap anywhere to reveal + public static let mediaContentWarning = L10n.tr("Localizable", "Common.Controls.Status.MediaContentWarning") + /// Show Post + public static let showPost = L10n.tr("Localizable", "Common.Controls.Status.ShowPost") + /// Show user profile + public static let showUserProfile = L10n.tr("Localizable", "Common.Controls.Status.ShowUserProfile") + /// %@ reblogged + public static func userReblogged(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Status.UserReblogged", String(describing: p1)) + } + /// Replied to %@ + public static func userRepliedTo(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Status.UserRepliedTo", String(describing: p1)) + } + public enum Actions { + /// Favorite + public static let favorite = L10n.tr("Localizable", "Common.Controls.Status.Actions.Favorite") + /// Hide + public static let hide = L10n.tr("Localizable", "Common.Controls.Status.Actions.Hide") + /// Menu + public static let menu = L10n.tr("Localizable", "Common.Controls.Status.Actions.Menu") + /// Reblog + public static let reblog = L10n.tr("Localizable", "Common.Controls.Status.Actions.Reblog") + /// Reply + public static let reply = L10n.tr("Localizable", "Common.Controls.Status.Actions.Reply") + /// Unfavorite + public static let unfavorite = L10n.tr("Localizable", "Common.Controls.Status.Actions.Unfavorite") + /// Undo reblog + public static let unreblog = L10n.tr("Localizable", "Common.Controls.Status.Actions.Unreblog") + } + public enum Poll { + /// Closed + public static let closed = L10n.tr("Localizable", "Common.Controls.Status.Poll.Closed") + /// Vote + public static let vote = L10n.tr("Localizable", "Common.Controls.Status.Poll.Vote") + } + public enum Tag { + /// Email + public static let email = L10n.tr("Localizable", "Common.Controls.Status.Tag.Email") + /// Emoji + public static let emoji = L10n.tr("Localizable", "Common.Controls.Status.Tag.Emoji") + /// Hashtag + public static let hashtag = L10n.tr("Localizable", "Common.Controls.Status.Tag.Hashtag") + /// Link + public static let link = L10n.tr("Localizable", "Common.Controls.Status.Tag.Link") + /// Mention + public static let mention = L10n.tr("Localizable", "Common.Controls.Status.Tag.Mention") + /// URL + public static let url = L10n.tr("Localizable", "Common.Controls.Status.Tag.Url") + } + public enum Visibility { + /// Only mentioned user can see this post. + public static let direct = L10n.tr("Localizable", "Common.Controls.Status.Visibility.Direct") + /// Only their followers can see this post. + public static let `private` = L10n.tr("Localizable", "Common.Controls.Status.Visibility.Private") + /// Only my followers can see this post. + public static let privateFromMe = L10n.tr("Localizable", "Common.Controls.Status.Visibility.PrivateFromMe") + /// Everyone can see this post but not display in the public timeline. + public static let unlisted = L10n.tr("Localizable", "Common.Controls.Status.Visibility.Unlisted") + } + } + public enum Tabs { + /// Home + public static let home = L10n.tr("Localizable", "Common.Controls.Tabs.Home") + /// Notification + public static let notification = L10n.tr("Localizable", "Common.Controls.Tabs.Notification") + /// Profile + public static let profile = L10n.tr("Localizable", "Common.Controls.Tabs.Profile") + /// Search + public static let search = L10n.tr("Localizable", "Common.Controls.Tabs.Search") + } + public enum Timeline { + /// Filtered + public static let filtered = L10n.tr("Localizable", "Common.Controls.Timeline.Filtered") + public enum Header { + /// You can’t view this user’s profile\nuntil they unblock you. + public static let blockedWarning = L10n.tr("Localizable", "Common.Controls.Timeline.Header.BlockedWarning") + /// You can’t view this user's profile\nuntil you unblock them.\nYour profile looks like this to them. + public static let blockingWarning = L10n.tr("Localizable", "Common.Controls.Timeline.Header.BlockingWarning") + /// No Post Found + public static let noStatusFound = L10n.tr("Localizable", "Common.Controls.Timeline.Header.NoStatusFound") + /// This user has been suspended. + public static let suspendedWarning = L10n.tr("Localizable", "Common.Controls.Timeline.Header.SuspendedWarning") + /// You can’t view %@’s profile\nuntil they unblock you. + public static func userBlockedWarning(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Timeline.Header.UserBlockedWarning", String(describing: p1)) + } + /// You can’t view %@’s profile\nuntil you unblock them.\nYour profile looks like this to them. + public static func userBlockingWarning(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Timeline.Header.UserBlockingWarning", String(describing: p1)) + } + /// %@’s account has been suspended. + public static func userSuspendedWarning(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Timeline.Header.UserSuspendedWarning", String(describing: p1)) + } + } + public enum Loader { + /// Loading missing posts... + public static let loadingMissingPosts = L10n.tr("Localizable", "Common.Controls.Timeline.Loader.LoadingMissingPosts") + /// Load missing posts + public static let loadMissingPosts = L10n.tr("Localizable", "Common.Controls.Timeline.Loader.LoadMissingPosts") + /// Show more replies + public static let showMoreReplies = L10n.tr("Localizable", "Common.Controls.Timeline.Loader.ShowMoreReplies") + } + public enum Timestamp { + /// Now + public static let now = L10n.tr("Localizable", "Common.Controls.Timeline.Timestamp.Now") + } + } + } + } + + public enum Scene { + public enum AccountList { + /// Add Account + public static let addAccount = L10n.tr("Localizable", "Scene.AccountList.AddAccount") + /// Dismiss Account Switcher + public static let dismissAccountSwitcher = L10n.tr("Localizable", "Scene.AccountList.DismissAccountSwitcher") + /// Current selected profile: %@. Double tap then hold to show account switcher + public static func tabBarHint(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.AccountList.TabBarHint", String(describing: p1)) + } + } + public enum Compose { + /// Publish + public static let composeAction = L10n.tr("Localizable", "Scene.Compose.ComposeAction") + /// Type or paste what’s on your mind + public static let contentInputPlaceholder = L10n.tr("Localizable", "Scene.Compose.ContentInputPlaceholder") + /// replying to %@ + public static func replyingToUser(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Compose.ReplyingToUser", String(describing: p1)) + } + public enum Accessibility { + /// Add Attachment + public static let appendAttachment = L10n.tr("Localizable", "Scene.Compose.Accessibility.AppendAttachment") + /// Add Poll + public static let appendPoll = L10n.tr("Localizable", "Scene.Compose.Accessibility.AppendPoll") + /// Custom Emoji Picker + public static let customEmojiPicker = L10n.tr("Localizable", "Scene.Compose.Accessibility.CustomEmojiPicker") + /// Disable Content Warning + public static let disableContentWarning = L10n.tr("Localizable", "Scene.Compose.Accessibility.DisableContentWarning") + /// Enable Content Warning + public static let enableContentWarning = L10n.tr("Localizable", "Scene.Compose.Accessibility.EnableContentWarning") + /// Post Visibility Menu + public static let postVisibilityMenu = L10n.tr("Localizable", "Scene.Compose.Accessibility.PostVisibilityMenu") + /// Remove Poll + public static let removePoll = L10n.tr("Localizable", "Scene.Compose.Accessibility.RemovePoll") + } + public enum Attachment { + /// This %@ is broken and can’t be\nuploaded to Mastodon. + public static func attachmentBroken(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Compose.Attachment.AttachmentBroken", String(describing: p1)) + } + /// Describe the photo for the visually-impaired... + public static let descriptionPhoto = L10n.tr("Localizable", "Scene.Compose.Attachment.DescriptionPhoto") + /// Describe the video for the visually-impaired... + public static let descriptionVideo = L10n.tr("Localizable", "Scene.Compose.Attachment.DescriptionVideo") + /// photo + public static let photo = L10n.tr("Localizable", "Scene.Compose.Attachment.Photo") + /// video + public static let video = L10n.tr("Localizable", "Scene.Compose.Attachment.Video") + } + public enum AutoComplete { + /// Space to add + public static let spaceToAdd = L10n.tr("Localizable", "Scene.Compose.AutoComplete.SpaceToAdd") + } + public enum ContentWarning { + /// Write an accurate warning here... + public static let placeholder = L10n.tr("Localizable", "Scene.Compose.ContentWarning.Placeholder") + } + public enum Keyboard { + /// Add Attachment - %@ + public static func appendAttachmentEntry(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Compose.Keyboard.AppendAttachmentEntry", String(describing: p1)) + } + /// Discard Post + public static let discardPost = L10n.tr("Localizable", "Scene.Compose.Keyboard.DiscardPost") + /// Publish Post + public static let publishPost = L10n.tr("Localizable", "Scene.Compose.Keyboard.PublishPost") + /// Select Visibility - %@ + public static func selectVisibilityEntry(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Compose.Keyboard.SelectVisibilityEntry", String(describing: p1)) + } + /// Toggle Content Warning + public static let toggleContentWarning = L10n.tr("Localizable", "Scene.Compose.Keyboard.ToggleContentWarning") + /// Toggle Poll + public static let togglePoll = L10n.tr("Localizable", "Scene.Compose.Keyboard.TogglePoll") + } + public enum MediaSelection { + /// Browse + public static let browse = L10n.tr("Localizable", "Scene.Compose.MediaSelection.Browse") + /// Take Photo + public static let camera = L10n.tr("Localizable", "Scene.Compose.MediaSelection.Camera") + /// Photo Library + public static let photoLibrary = L10n.tr("Localizable", "Scene.Compose.MediaSelection.PhotoLibrary") + } + public enum Poll { + /// Duration: %@ + public static func durationTime(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Compose.Poll.DurationTime", String(describing: p1)) + } + /// 1 Day + public static let oneDay = L10n.tr("Localizable", "Scene.Compose.Poll.OneDay") + /// 1 Hour + public static let oneHour = L10n.tr("Localizable", "Scene.Compose.Poll.OneHour") + /// Option %ld + public static func optionNumber(_ p1: Int) -> String { + return L10n.tr("Localizable", "Scene.Compose.Poll.OptionNumber", p1) + } + /// 7 Days + public static let sevenDays = L10n.tr("Localizable", "Scene.Compose.Poll.SevenDays") + /// 6 Hours + public static let sixHours = L10n.tr("Localizable", "Scene.Compose.Poll.SixHours") + /// 30 minutes + public static let thirtyMinutes = L10n.tr("Localizable", "Scene.Compose.Poll.ThirtyMinutes") + /// 3 Days + public static let threeDays = L10n.tr("Localizable", "Scene.Compose.Poll.ThreeDays") + } + public enum Title { + /// New Post + public static let newPost = L10n.tr("Localizable", "Scene.Compose.Title.NewPost") + /// New Reply + public static let newReply = L10n.tr("Localizable", "Scene.Compose.Title.NewReply") + } + public enum Visibility { + /// Only people I mention + public static let direct = L10n.tr("Localizable", "Scene.Compose.Visibility.Direct") + /// Followers only + public static let `private` = L10n.tr("Localizable", "Scene.Compose.Visibility.Private") + /// Public + public static let `public` = L10n.tr("Localizable", "Scene.Compose.Visibility.Public") + /// Unlisted + public static let unlisted = L10n.tr("Localizable", "Scene.Compose.Visibility.Unlisted") + } + } + public enum ConfirmEmail { + /// Tap the link we emailed to you to verify your account. + public static let subtitle = L10n.tr("Localizable", "Scene.ConfirmEmail.Subtitle") + /// One last thing. + public static let title = L10n.tr("Localizable", "Scene.ConfirmEmail.Title") + public enum Button { + /// Open Email App + public static let openEmailApp = L10n.tr("Localizable", "Scene.ConfirmEmail.Button.OpenEmailApp") + /// Resend + public static let resend = L10n.tr("Localizable", "Scene.ConfirmEmail.Button.Resend") + } + public enum DontReceiveEmail { + /// Check if your email address is correct as well as your junk folder if you haven’t. + public static let description = L10n.tr("Localizable", "Scene.ConfirmEmail.DontReceiveEmail.Description") + /// Resend Email + public static let resendEmail = L10n.tr("Localizable", "Scene.ConfirmEmail.DontReceiveEmail.ResendEmail") + /// Check your email + public static let title = L10n.tr("Localizable", "Scene.ConfirmEmail.DontReceiveEmail.Title") + } + public enum OpenEmailApp { + /// We just sent you an email. Check your junk folder if you haven’t. + public static let description = L10n.tr("Localizable", "Scene.ConfirmEmail.OpenEmailApp.Description") + /// Mail + public static let mail = L10n.tr("Localizable", "Scene.ConfirmEmail.OpenEmailApp.Mail") + /// Open Email Client + public static let openEmailClient = L10n.tr("Localizable", "Scene.ConfirmEmail.OpenEmailApp.OpenEmailClient") + /// Check your inbox. + public static let title = L10n.tr("Localizable", "Scene.ConfirmEmail.OpenEmailApp.Title") + } + } + public enum Favorite { + /// Your Favorites + public static let title = L10n.tr("Localizable", "Scene.Favorite.Title") + } + public enum Follower { + /// Followers from other servers are not displayed. + public static let footer = L10n.tr("Localizable", "Scene.Follower.Footer") + } + public enum Following { + /// Follows from other servers are not displayed. + public static let footer = L10n.tr("Localizable", "Scene.Following.Footer") + } + public enum HomeTimeline { + /// Home + public static let title = L10n.tr("Localizable", "Scene.HomeTimeline.Title") + public enum NavigationBarState { + /// See new posts + public static let newPosts = L10n.tr("Localizable", "Scene.HomeTimeline.NavigationBarState.NewPosts") + /// Offline + public static let offline = L10n.tr("Localizable", "Scene.HomeTimeline.NavigationBarState.Offline") + /// Published! + public static let published = L10n.tr("Localizable", "Scene.HomeTimeline.NavigationBarState.Published") + /// Publishing post... + public static let publishing = L10n.tr("Localizable", "Scene.HomeTimeline.NavigationBarState.Publishing") + } + } + public enum Notification { + public enum Keyobard { + /// Show Everything + public static let showEverything = L10n.tr("Localizable", "Scene.Notification.Keyobard.ShowEverything") + /// Show Mentions + public static let showMentions = L10n.tr("Localizable", "Scene.Notification.Keyobard.ShowMentions") + } + public enum NotificationDescription { + /// favorited your post + public static let favoritedYourPost = L10n.tr("Localizable", "Scene.Notification.NotificationDescription.FavoritedYourPost") + /// followed you + public static let followedYou = L10n.tr("Localizable", "Scene.Notification.NotificationDescription.FollowedYou") + /// mentioned you + public static let mentionedYou = L10n.tr("Localizable", "Scene.Notification.NotificationDescription.MentionedYou") + /// poll has ended + public static let pollHasEnded = L10n.tr("Localizable", "Scene.Notification.NotificationDescription.PollHasEnded") + /// reblogged your post + public static let rebloggedYourPost = L10n.tr("Localizable", "Scene.Notification.NotificationDescription.RebloggedYourPost") + /// request to follow you + public static let requestToFollowYou = L10n.tr("Localizable", "Scene.Notification.NotificationDescription.RequestToFollowYou") + } + public enum Title { + /// Everything + public static let everything = L10n.tr("Localizable", "Scene.Notification.Title.Everything") + /// Mentions + public static let mentions = L10n.tr("Localizable", "Scene.Notification.Title.Mentions") + } + } + public enum Preview { + public enum Keyboard { + /// Close Preview + public static let closePreview = L10n.tr("Localizable", "Scene.Preview.Keyboard.ClosePreview") + /// Show Next + public static let showNext = L10n.tr("Localizable", "Scene.Preview.Keyboard.ShowNext") + /// Show Previous + public static let showPrevious = L10n.tr("Localizable", "Scene.Preview.Keyboard.ShowPrevious") + } + } + public enum Profile { + public enum Dashboard { + /// followers + public static let followers = L10n.tr("Localizable", "Scene.Profile.Dashboard.Followers") + /// following + public static let following = L10n.tr("Localizable", "Scene.Profile.Dashboard.Following") + /// posts + public static let posts = L10n.tr("Localizable", "Scene.Profile.Dashboard.Posts") + } + public enum Fields { + /// Add Row + public static let addRow = L10n.tr("Localizable", "Scene.Profile.Fields.AddRow") + public enum Placeholder { + /// Content + public static let content = L10n.tr("Localizable", "Scene.Profile.Fields.Placeholder.Content") + /// Label + public static let label = L10n.tr("Localizable", "Scene.Profile.Fields.Placeholder.Label") + } + } + public enum RelationshipActionAlert { + public enum ConfirmBlockUser { + /// Confirm to block %@ + public static func message(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Profile.RelationshipActionAlert.ConfirmBlockUser.Message", String(describing: p1)) + } + /// Block Account + public static let title = L10n.tr("Localizable", "Scene.Profile.RelationshipActionAlert.ConfirmBlockUser.Title") + } + public enum ConfirmMuteUser { + /// Confirm to mute %@ + public static func message(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Profile.RelationshipActionAlert.ConfirmMuteUser.Message", String(describing: p1)) + } + /// Mute Account + public static let title = L10n.tr("Localizable", "Scene.Profile.RelationshipActionAlert.ConfirmMuteUser.Title") + } + public enum ConfirmUnblockUser { + /// Confirm to unblock %@ + public static func message(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Profile.RelationshipActionAlert.ConfirmUnblockUser.Message", String(describing: p1)) + } + /// Unblock Account + public static let title = L10n.tr("Localizable", "Scene.Profile.RelationshipActionAlert.ConfirmUnblockUser.Title") + } + public enum ConfirmUnmuteUser { + /// Confirm to unmute %@ + public static func message(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Message", String(describing: p1)) + } + /// Unmute Account + public static let title = L10n.tr("Localizable", "Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Title") + } + } + public enum SegmentedControl { + /// About + public static let about = L10n.tr("Localizable", "Scene.Profile.SegmentedControl.About") + /// Media + public static let media = L10n.tr("Localizable", "Scene.Profile.SegmentedControl.Media") + /// Posts + public static let posts = L10n.tr("Localizable", "Scene.Profile.SegmentedControl.Posts") + /// Posts and Replies + public static let postsAndReplies = L10n.tr("Localizable", "Scene.Profile.SegmentedControl.PostsAndReplies") + /// Replies + public static let replies = L10n.tr("Localizable", "Scene.Profile.SegmentedControl.Replies") + } + } + public enum Register { + /// Let’s get you set up on %@ + public static func title(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Register.Title", String(describing: p1)) + } + public enum Error { + public enum Item { + /// Agreement + public static let agreement = L10n.tr("Localizable", "Scene.Register.Error.Item.Agreement") + /// Email + public static let email = L10n.tr("Localizable", "Scene.Register.Error.Item.Email") + /// Locale + public static let locale = L10n.tr("Localizable", "Scene.Register.Error.Item.Locale") + /// Password + public static let password = L10n.tr("Localizable", "Scene.Register.Error.Item.Password") + /// Reason + public static let reason = L10n.tr("Localizable", "Scene.Register.Error.Item.Reason") + /// Username + public static let username = L10n.tr("Localizable", "Scene.Register.Error.Item.Username") + } + public enum Reason { + /// %@ must be accepted + public static func accepted(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Register.Error.Reason.Accepted", String(describing: p1)) + } + /// %@ is required + public static func blank(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Register.Error.Reason.Blank", String(describing: p1)) + } + /// %@ contains a disallowed email provider + public static func blocked(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Register.Error.Reason.Blocked", String(describing: p1)) + } + /// %@ is not a supported value + public static func inclusion(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Register.Error.Reason.Inclusion", String(describing: p1)) + } + /// %@ is invalid + public static func invalid(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Register.Error.Reason.Invalid", String(describing: p1)) + } + /// %@ is a reserved keyword + public static func reserved(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Register.Error.Reason.Reserved", String(describing: p1)) + } + /// %@ is already in use + public static func taken(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Register.Error.Reason.Taken", String(describing: p1)) + } + /// %@ is too long + public static func tooLong(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Register.Error.Reason.TooLong", String(describing: p1)) + } + /// %@ is too short + public static func tooShort(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Register.Error.Reason.TooShort", String(describing: p1)) + } + /// %@ does not seem to exist + public static func unreachable(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Register.Error.Reason.Unreachable", String(describing: p1)) + } + } + public enum Special { + /// This is not a valid email address + public static let emailInvalid = L10n.tr("Localizable", "Scene.Register.Error.Special.EmailInvalid") + /// Password is too short (must be at least 8 characters) + public static let passwordTooShort = L10n.tr("Localizable", "Scene.Register.Error.Special.PasswordTooShort") + /// Username must only contain alphanumeric characters and underscores + public static let usernameInvalid = L10n.tr("Localizable", "Scene.Register.Error.Special.UsernameInvalid") + /// Username is too long (can’t be longer than 30 characters) + public static let usernameTooLong = L10n.tr("Localizable", "Scene.Register.Error.Special.UsernameTooLong") + } + } + public enum Input { + public enum Avatar { + /// Delete + public static let delete = L10n.tr("Localizable", "Scene.Register.Input.Avatar.Delete") + } + public enum DisplayName { + /// display name + public static let placeholder = L10n.tr("Localizable", "Scene.Register.Input.DisplayName.Placeholder") + } + public enum Email { + /// email + public static let placeholder = L10n.tr("Localizable", "Scene.Register.Input.Email.Placeholder") + } + public enum Invite { + /// Why do you want to join? + public static let registrationUserInviteRequest = L10n.tr("Localizable", "Scene.Register.Input.Invite.RegistrationUserInviteRequest") + } + public enum Password { + /// 8 characters + public static let characterLimit = L10n.tr("Localizable", "Scene.Register.Input.Password.CharacterLimit") + /// Your password needs at least eight characters + public static let hint = L10n.tr("Localizable", "Scene.Register.Input.Password.Hint") + /// password + public static let placeholder = L10n.tr("Localizable", "Scene.Register.Input.Password.Placeholder") + /// Your password needs at least: + public static let require = L10n.tr("Localizable", "Scene.Register.Input.Password.Require") + public enum Accessibility { + /// checked + public static let checked = L10n.tr("Localizable", "Scene.Register.Input.Password.Accessibility.Checked") + /// unchecked + public static let unchecked = L10n.tr("Localizable", "Scene.Register.Input.Password.Accessibility.Unchecked") + } + } + public enum Username { + /// This username is taken. + public static let duplicatePrompt = L10n.tr("Localizable", "Scene.Register.Input.Username.DuplicatePrompt") + /// username + public static let placeholder = L10n.tr("Localizable", "Scene.Register.Input.Username.Placeholder") + } + } + } + public enum Report { + /// Are there any other posts you’d like to add to the report? + public static let content1 = L10n.tr("Localizable", "Scene.Report.Content1") + /// Is there anything the moderators should know about this report? + public static let content2 = L10n.tr("Localizable", "Scene.Report.Content2") + /// REPORTED + public static let reported = L10n.tr("Localizable", "Scene.Report.Reported") + /// Thanks for reporting, we’ll look into this. + public static let reportSentTitle = L10n.tr("Localizable", "Scene.Report.ReportSentTitle") + /// Send Report + public static let send = L10n.tr("Localizable", "Scene.Report.Send") + /// Send without comment + public static let skipToSend = L10n.tr("Localizable", "Scene.Report.SkipToSend") + /// Step 1 of 2 + public static let step1 = L10n.tr("Localizable", "Scene.Report.Step1") + /// Step 2 of 2 + public static let step2 = L10n.tr("Localizable", "Scene.Report.Step2") + /// Type or paste additional comments + public static let textPlaceholder = L10n.tr("Localizable", "Scene.Report.TextPlaceholder") + /// Report %@ + public static func title(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Report.Title", String(describing: p1)) + } + /// Report + public static let titleReport = L10n.tr("Localizable", "Scene.Report.TitleReport") + } + public enum Search { + /// Search + public static let title = L10n.tr("Localizable", "Scene.Search.Title") + public enum Recommend { + /// See All + public static let buttonText = L10n.tr("Localizable", "Scene.Search.Recommend.ButtonText") + public enum Accounts { + /// You may like to follow these accounts + public static let description = L10n.tr("Localizable", "Scene.Search.Recommend.Accounts.Description") + /// Follow + public static let follow = L10n.tr("Localizable", "Scene.Search.Recommend.Accounts.Follow") + /// Accounts you might like + public static let title = L10n.tr("Localizable", "Scene.Search.Recommend.Accounts.Title") + } + public enum HashTag { + /// Hashtags that are getting quite a bit of attention + public static let description = L10n.tr("Localizable", "Scene.Search.Recommend.HashTag.Description") + /// %@ people are talking + public static func peopleTalking(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Search.Recommend.HashTag.PeopleTalking", String(describing: p1)) + } + /// Trending on Mastodon + public static let title = L10n.tr("Localizable", "Scene.Search.Recommend.HashTag.Title") + } + } + public enum SearchBar { + /// Cancel + public static let cancel = L10n.tr("Localizable", "Scene.Search.SearchBar.Cancel") + /// Search hashtags and users + public static let placeholder = L10n.tr("Localizable", "Scene.Search.SearchBar.Placeholder") + } + public enum Searching { + /// Clear + public static let clear = L10n.tr("Localizable", "Scene.Search.Searching.Clear") + /// Recent searches + public static let recentSearch = L10n.tr("Localizable", "Scene.Search.Searching.RecentSearch") + public enum EmptyState { + /// No results + public static let noResults = L10n.tr("Localizable", "Scene.Search.Searching.EmptyState.NoResults") + } + public enum Segment { + /// All + public static let all = L10n.tr("Localizable", "Scene.Search.Searching.Segment.All") + /// Hashtags + public static let hashtags = L10n.tr("Localizable", "Scene.Search.Searching.Segment.Hashtags") + /// People + public static let people = L10n.tr("Localizable", "Scene.Search.Searching.Segment.People") + /// Posts + public static let posts = L10n.tr("Localizable", "Scene.Search.Searching.Segment.Posts") + } + } + } + public enum ServerPicker { + /// Pick a community based on your interests, region, or a general purpose one. + public static let subtitle = L10n.tr("Localizable", "Scene.ServerPicker.Subtitle") + /// Pick a community based on your interests, region, or a general purpose one. Each community is operated by an entirely independent organization or individual. + public static let subtitleExtend = L10n.tr("Localizable", "Scene.ServerPicker.SubtitleExtend") + /// Mastodon is made of users in different communities. + public static let title = L10n.tr("Localizable", "Scene.ServerPicker.Title") + public enum Button { + /// See Less + public static let seeLess = L10n.tr("Localizable", "Scene.ServerPicker.Button.SeeLess") + /// See More + public static let seeMore = L10n.tr("Localizable", "Scene.ServerPicker.Button.SeeMore") + public enum Category { + /// academia + public static let academia = L10n.tr("Localizable", "Scene.ServerPicker.Button.Category.Academia") + /// activism + public static let activism = L10n.tr("Localizable", "Scene.ServerPicker.Button.Category.Activism") + /// All + public static let all = L10n.tr("Localizable", "Scene.ServerPicker.Button.Category.All") + /// Category: All + public static let allAccessiblityDescription = L10n.tr("Localizable", "Scene.ServerPicker.Button.Category.AllAccessiblityDescription") + /// art + public static let art = L10n.tr("Localizable", "Scene.ServerPicker.Button.Category.Art") + /// food + public static let food = L10n.tr("Localizable", "Scene.ServerPicker.Button.Category.Food") + /// furry + public static let furry = L10n.tr("Localizable", "Scene.ServerPicker.Button.Category.Furry") + /// games + public static let games = L10n.tr("Localizable", "Scene.ServerPicker.Button.Category.Games") + /// general + public static let general = L10n.tr("Localizable", "Scene.ServerPicker.Button.Category.General") + /// journalism + public static let journalism = L10n.tr("Localizable", "Scene.ServerPicker.Button.Category.Journalism") + /// lgbt + public static let lgbt = L10n.tr("Localizable", "Scene.ServerPicker.Button.Category.Lgbt") + /// music + public static let music = L10n.tr("Localizable", "Scene.ServerPicker.Button.Category.Music") + /// regional + public static let regional = L10n.tr("Localizable", "Scene.ServerPicker.Button.Category.Regional") + /// tech + public static let tech = L10n.tr("Localizable", "Scene.ServerPicker.Button.Category.Tech") + } + } + public enum EmptyState { + /// Something went wrong while loading the data. Check your internet connection. + public static let badNetwork = L10n.tr("Localizable", "Scene.ServerPicker.EmptyState.BadNetwork") + /// Finding available servers... + public static let findingServers = L10n.tr("Localizable", "Scene.ServerPicker.EmptyState.FindingServers") + /// No results + public static let noResults = L10n.tr("Localizable", "Scene.ServerPicker.EmptyState.NoResults") + } + public enum Input { + /// Search communities + public static let placeholder = L10n.tr("Localizable", "Scene.ServerPicker.Input.Placeholder") + } + public enum Label { + /// CATEGORY + public static let category = L10n.tr("Localizable", "Scene.ServerPicker.Label.Category") + /// LANGUAGE + public static let language = L10n.tr("Localizable", "Scene.ServerPicker.Label.Language") + /// USERS + public static let users = L10n.tr("Localizable", "Scene.ServerPicker.Label.Users") + } + } + public enum ServerRules { + /// privacy policy + public static let privacyPolicy = L10n.tr("Localizable", "Scene.ServerRules.PrivacyPolicy") + /// By continuing, you’re subject to the terms of service and privacy policy for %@. + public static func prompt(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.ServerRules.Prompt", String(describing: p1)) + } + /// These are set and enforced by the %@ moderators. + public static func subtitle(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.ServerRules.Subtitle", String(describing: p1)) + } + /// terms of service + public static let termsOfService = L10n.tr("Localizable", "Scene.ServerRules.TermsOfService") + /// Some ground rules. + public static let title = L10n.tr("Localizable", "Scene.ServerRules.Title") + public enum Button { + /// I Agree + public static let confirm = L10n.tr("Localizable", "Scene.ServerRules.Button.Confirm") + } + } + public enum Settings { + /// Settings + public static let title = L10n.tr("Localizable", "Scene.Settings.Title") + public enum Footer { + /// Mastodon is open source software. You can report issues on GitHub at %@ (%@) + public static func mastodonDescription(_ p1: Any, _ p2: Any) -> String { + return L10n.tr("Localizable", "Scene.Settings.Footer.MastodonDescription", String(describing: p1), String(describing: p2)) + } + } + public enum Keyboard { + /// Close Settings Window + public static let closeSettingsWindow = L10n.tr("Localizable", "Scene.Settings.Keyboard.CloseSettingsWindow") + } + public enum Section { + public enum Appearance { + /// Automatic + public static let automatic = L10n.tr("Localizable", "Scene.Settings.Section.Appearance.Automatic") + /// Always Dark + public static let dark = L10n.tr("Localizable", "Scene.Settings.Section.Appearance.Dark") + /// Always Light + public static let light = L10n.tr("Localizable", "Scene.Settings.Section.Appearance.Light") + /// Appearance + public static let title = L10n.tr("Localizable", "Scene.Settings.Section.Appearance.Title") + } + public enum BoringZone { + /// Account Settings + public static let accountSettings = L10n.tr("Localizable", "Scene.Settings.Section.BoringZone.AccountSettings") + /// Privacy Policy + public static let privacy = L10n.tr("Localizable", "Scene.Settings.Section.BoringZone.Privacy") + /// Terms of Service + public static let terms = L10n.tr("Localizable", "Scene.Settings.Section.BoringZone.Terms") + /// The Boring Zone + public static let title = L10n.tr("Localizable", "Scene.Settings.Section.BoringZone.Title") + } + public enum LookAndFeel { + /// Light + public static let light = L10n.tr("Localizable", "Scene.Settings.Section.LookAndFeel.Light") + /// Really Dark + public static let reallyDark = L10n.tr("Localizable", "Scene.Settings.Section.LookAndFeel.ReallyDark") + /// Sorta Dark + public static let sortaDark = L10n.tr("Localizable", "Scene.Settings.Section.LookAndFeel.SortaDark") + /// Look and Feel + public static let title = L10n.tr("Localizable", "Scene.Settings.Section.LookAndFeel.Title") + /// Use System + public static let useSystem = L10n.tr("Localizable", "Scene.Settings.Section.LookAndFeel.UseSystem") + } + public enum Notifications { + /// Reblogs my post + public static let boosts = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Boosts") + /// Favorites my post + public static let favorites = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Favorites") + /// Follows me + public static let follows = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Follows") + /// Mentions me + public static let mentions = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Mentions") + /// Notifications + public static let title = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Title") + public enum Trigger { + /// anyone + public static let anyone = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Trigger.Anyone") + /// anyone I follow + public static let follow = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Trigger.Follow") + /// a follower + public static let follower = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Trigger.Follower") + /// no one + public static let noone = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Trigger.Noone") + /// Notify me when + public static let title = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Trigger.Title") + } + } + public enum Preference { + /// Disable animated avatars + public static let disableAvatarAnimation = L10n.tr("Localizable", "Scene.Settings.Section.Preference.DisableAvatarAnimation") + /// Disable animated emojis + public static let disableEmojiAnimation = L10n.tr("Localizable", "Scene.Settings.Section.Preference.DisableEmojiAnimation") + /// Preferences + public static let title = L10n.tr("Localizable", "Scene.Settings.Section.Preference.Title") + /// True black dark mode + public static let trueBlackDarkMode = L10n.tr("Localizable", "Scene.Settings.Section.Preference.TrueBlackDarkMode") + /// Use default browser to open links + public static let usingDefaultBrowser = L10n.tr("Localizable", "Scene.Settings.Section.Preference.UsingDefaultBrowser") + } + public enum SpicyZone { + /// Clear Media Cache + public static let clear = L10n.tr("Localizable", "Scene.Settings.Section.SpicyZone.Clear") + /// Sign Out + public static let signout = L10n.tr("Localizable", "Scene.Settings.Section.SpicyZone.Signout") + /// The Spicy Zone + public static let title = L10n.tr("Localizable", "Scene.Settings.Section.SpicyZone.Title") + } + } + } + public enum SuggestionAccount { + /// When you follow someone, you’ll see their posts in your home feed. + public static let followExplain = L10n.tr("Localizable", "Scene.SuggestionAccount.FollowExplain") + /// Find People to Follow + public static let title = L10n.tr("Localizable", "Scene.SuggestionAccount.Title") + } + public enum Thread { + /// Post + public static let backTitle = L10n.tr("Localizable", "Scene.Thread.BackTitle") + /// Post from %@ + public static func title(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Thread.Title", String(describing: p1)) + } + } + public enum Welcome { + /// Get Started + public static let getStarted = L10n.tr("Localizable", "Scene.Welcome.GetStarted") + /// Log In + public static let logIn = L10n.tr("Localizable", "Scene.Welcome.LogIn") + /// Social networking\nback in your hands. + public static let slogan = L10n.tr("Localizable", "Scene.Welcome.Slogan") + } + public enum Wizard { + /// Double tap to dismiss this wizard + public static let accessibilityHint = L10n.tr("Localizable", "Scene.Wizard.AccessibilityHint") + /// Switch between multiple accounts by holding the profile button. + public static let multipleAccountSwitchIntroDescription = L10n.tr("Localizable", "Scene.Wizard.MultipleAccountSwitchIntroDescription") + /// New in Mastodon + public static let newInMastodon = L10n.tr("Localizable", "Scene.Wizard.NewInMastodon") + } + } + + public enum A11y { + public enum Plural { + public enum Count { + /// Plural format key: "Input limit exceeds %#@character_count@" + public static func inputLimitExceeds(_ p1: Int) -> String { + return L10n.tr("Localizable", "a11y.plural.count.input_limit_exceeds", p1) + } + /// Plural format key: "Input limit remains %#@character_count@" + public static func inputLimitRemains(_ p1: Int) -> String { + return L10n.tr("Localizable", "a11y.plural.count.input_limit_remains", p1) + } + public enum Unread { + /// Plural format key: "%#@notification_count_unread_notification@" + public static func notification(_ p1: Int) -> String { + return L10n.tr("Localizable", "a11y.plural.count.unread.notification", p1) + } + } + } + } + } + + public enum Date { + public enum Day { + /// Plural format key: "%#@count_day_left@" + public static func `left`(_ p1: Int) -> String { + return L10n.tr("Localizable", "date.day.left", p1) + } + public enum Ago { + /// Plural format key: "%#@count_day_ago_abbr@" + public static func abbr(_ p1: Int) -> String { + return L10n.tr("Localizable", "date.day.ago.abbr", p1) + } + } + } + public enum Hour { + /// Plural format key: "%#@count_hour_left@" + public static func `left`(_ p1: Int) -> String { + return L10n.tr("Localizable", "date.hour.left", p1) + } + public enum Ago { + /// Plural format key: "%#@count_hour_ago_abbr@" + public static func abbr(_ p1: Int) -> String { + return L10n.tr("Localizable", "date.hour.ago.abbr", p1) + } + } + } + public enum Minute { + /// Plural format key: "%#@count_minute_left@" + public static func `left`(_ p1: Int) -> String { + return L10n.tr("Localizable", "date.minute.left", p1) + } + public enum Ago { + /// Plural format key: "%#@count_minute_ago_abbr@" + public static func abbr(_ p1: Int) -> String { + return L10n.tr("Localizable", "date.minute.ago.abbr", p1) + } + } + } + public enum Month { + /// Plural format key: "%#@count_month_left@" + public static func `left`(_ p1: Int) -> String { + return L10n.tr("Localizable", "date.month.left", p1) + } + public enum Ago { + /// Plural format key: "%#@count_month_ago_abbr@" + public static func abbr(_ p1: Int) -> String { + return L10n.tr("Localizable", "date.month.ago.abbr", p1) + } + } + } + public enum Second { + /// Plural format key: "%#@count_second_left@" + public static func `left`(_ p1: Int) -> String { + return L10n.tr("Localizable", "date.second.left", p1) + } + public enum Ago { + /// Plural format key: "%#@count_second_ago_abbr@" + public static func abbr(_ p1: Int) -> String { + return L10n.tr("Localizable", "date.second.ago.abbr", p1) + } + } + } + public enum Year { + /// Plural format key: "%#@count_year_left@" + public static func `left`(_ p1: Int) -> String { + return L10n.tr("Localizable", "date.year.left", p1) + } + public enum Ago { + /// Plural format key: "%#@count_year_ago_abbr@" + public static func abbr(_ p1: Int) -> String { + return L10n.tr("Localizable", "date.year.ago.abbr", p1) + } + } + } + } + + public enum Plural { + /// Plural format key: "%#@count_people_talking@" + public static func peopleTalking(_ p1: Int) -> String { + return L10n.tr("Localizable", "plural.people_talking", p1) + } + public enum Count { + /// Plural format key: "%#@favorite_count@" + public static func favorite(_ p1: Int) -> String { + return L10n.tr("Localizable", "plural.count.favorite", p1) + } + /// Plural format key: "%#@count_follower@" + public static func follower(_ p1: Int) -> String { + return L10n.tr("Localizable", "plural.count.follower", p1) + } + /// Plural format key: "%#@count_following@" + public static func following(_ p1: Int) -> String { + return L10n.tr("Localizable", "plural.count.following", p1) + } + /// Plural format key: "%#@post_count@" + public static func post(_ p1: Int) -> String { + return L10n.tr("Localizable", "plural.count.post", p1) + } + /// Plural format key: "%#@reblog_count@" + public static func reblog(_ p1: Int) -> String { + return L10n.tr("Localizable", "plural.count.reblog", p1) + } + /// Plural format key: "%#@vote_count@" + public static func vote(_ p1: Int) -> String { + return L10n.tr("Localizable", "plural.count.vote", p1) + } + /// Plural format key: "%#@voter_count@" + public static func voter(_ p1: Int) -> String { + return L10n.tr("Localizable", "plural.count.voter", p1) + } + public enum MetricFormatted { + /// Plural format key: "%@ %#@post_count@" + public static func post(_ p1: Any, _ p2: Int) -> String { + return L10n.tr("Localizable", "plural.count.metric_formatted.post", String(describing: p1), p2) + } + } + } + } +} +// swiftlint:enable explicit_type_interface function_parameter_count identifier_name line_length +// swiftlint:enable nesting type_body_length type_name vertical_whitespace_opening_braces + +// MARK: - Implementation Details + +extension L10n { + private static func tr(_ table: String, _ key: String, _ args: CVarArg...) -> String { + let format = Bundle.module.localizedString(forKey: key, value: nil, table: table) + return String(format: format, locale: Locale.current, arguments: args) + } +} diff --git a/MastodonSDK/Sources/MastodonLocalization/Resources/ar.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/ar.lproj/Localizable.strings new file mode 100644 index 000000000..668c7788a --- /dev/null +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/ar.lproj/Localizable.strings @@ -0,0 +1,377 @@ +"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.Message" = "هَل أنتَ مُتأكِدٌ مِن رَغبتِكَ فِي حَذفِ هَذَا المَنشُور؟"; +"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.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.FindPeople" = "ابحث عن أشخاص لِمُتابعتهم"; +"Common.Controls.Actions.ManuallySearch" = "البحث يدويًا بدلًا من ذلك"; +"Common.Controls.Actions.Next" = "التالي"; +"Common.Controls.Actions.Ok" = "حسنًا"; +"Common.Controls.Actions.Open" = "فتح"; +"Common.Controls.Actions.OpenInBrowser" = "الفَتحُ في المُتَصَفِّح"; +"Common.Controls.Actions.OpenInSafari" = "الفَتحُ في Safari"; +"Common.Controls.Actions.Preview" = "مُعاينة"; +"Common.Controls.Actions.Previous" = "السابق"; +"Common.Controls.Actions.Remove" = "حذف"; +"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.Skip" = "تخطي"; +"Common.Controls.Actions.TakePhoto" = "التقاط صورة"; +"Common.Controls.Actions.TryAgain" = "المُحاولة مرة أُخرى"; +"Common.Controls.Actions.UnblockDomain" = "رفع الحظر عن %@"; +"Common.Controls.Friendship.Block" = "حظر"; +"Common.Controls.Friendship.BlockDomain" = "حظر %@"; +"Common.Controls.Friendship.BlockUser" = "حظر %@"; +"Common.Controls.Friendship.Blocked" = "محظور"; +"Common.Controls.Friendship.EditInfo" = "تعديل المعلومات"; +"Common.Controls.Friendship.Follow" = "مُتابَعَة"; +"Common.Controls.Friendship.Following" = "مُتابَع"; +"Common.Controls.Friendship.Mute" = "كَتم"; +"Common.Controls.Friendship.MuteUser" = "كَتم %@"; +"Common.Controls.Friendship.Muted" = "مكتوم"; +"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.OpenSettings" = "فَتحُ الإعدادات"; +"Common.Controls.Keyboard.Common.ShowFavorites" = "إظهار المُفضَّلة"; +"Common.Controls.Keyboard.Common.SwitchToTab" = "التبديل إلى %@"; +"Common.Controls.Keyboard.SegmentedControl.NextSection" = "القسم التالي"; +"Common.Controls.Keyboard.SegmentedControl.PreviousSection" = "القسم السابق"; +"Common.Controls.Keyboard.Timeline.NextStatus" = "المنشور التالي"; +"Common.Controls.Keyboard.Timeline.OpenAuthorProfile" = "فتح الملف التعريفي للمؤلف"; +"Common.Controls.Keyboard.Timeline.OpenRebloggerProfile" = "فتح الملف التعريفي لمُعيد تدوين المنشور"; +"Common.Controls.Keyboard.Timeline.OpenStatus" = "فتح المنشور"; +"Common.Controls.Keyboard.Timeline.PreviewImage" = "معاينة الصورة"; +"Common.Controls.Keyboard.Timeline.PreviousStatus" = "المنشور السابق"; +"Common.Controls.Keyboard.Timeline.ReplyStatus" = "الرَّد على مَنشور"; +"Common.Controls.Keyboard.Timeline.ToggleContentWarning" = "تبديل تحذير المُحتَوى"; +"Common.Controls.Keyboard.Timeline.ToggleFavorite" = "تبديل المفضلة لِمنشور"; +"Common.Controls.Keyboard.Timeline.ToggleReblog" = "تبديل إعادة تدوين مَنشور"; +"Common.Controls.Status.Actions.Favorite" = "التفضيل"; +"Common.Controls.Status.Actions.Hide" = "إخفاء"; +"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" = "رمز تعبيري"; +"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.Status.Visibility.Direct" = "المُستخدمِونَ المُشارِ إليهم فَقَطْ مَن يُمكِنُهُم رُؤيَةُ هَذَا المَنشُور."; +"Common.Controls.Status.Visibility.Private" = "فَقَطْ مُتابِعينَهُم مَن يُمكِنُهُم رُؤيَةُ هَذَا المَنشُور."; +"Common.Controls.Status.Visibility.PrivateFromMe" = "فَقَطْ مُتابِعيني أنَا مَن يُمكِنُهُم رُؤيَةُ هَذَا المَنشُور."; +"Common.Controls.Status.Visibility.Unlisted" = "يُمكِنُ لِلجَميعِ رُؤيَةُ هَذَا المَنشورِ وَلكِنَّهُ لَا يُعرَضُ فِي الخَطِّ الزَمنيّ العام."; +"Common.Controls.Tabs.Home" = "الرَّئِيسَة"; +"Common.Controls.Tabs.Notification" = "الإشعارات"; +"Common.Controls.Tabs.Profile" = "الملف التعريفي"; +"Common.Controls.Tabs.Search" = "البَحث"; +"Common.Controls.Timeline.Filtered" = "مُصفَّى"; +"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" = "لا يُمكِنُكَ عَرض ملف %@ التَعريفي +حتَّى يَرفَعَ الحَظر عَنك."; +"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" = "منتقي الرموز التعبيرية المُخصَّص"; +"Scene.Compose.Accessibility.DisableContentWarning" = "تعطيل تحذير المُحتَوى"; +"Scene.Compose.Accessibility.EnableContentWarning" = "تفعيل تحذير المُحتَوى"; +"Scene.Compose.Accessibility.PostVisibilityMenu" = "قائمة ظهور المنشور"; +"Scene.Compose.Accessibility.RemovePoll" = "إزالة الاستطلاع"; +"Scene.Compose.Attachment.AttachmentBroken" = "هذا ال%@ مُعطَّل +ويتعذَّرُ رفعُه إلى ماستودون."; +"Scene.Compose.Attachment.DescriptionPhoto" = "صِف الصورة للمَكفوفين..."; +"Scene.Compose.Attachment.DescriptionVideo" = "صِف المقطع المرئي للمَكفوفين..."; +"Scene.Compose.Attachment.Photo" = "صورة"; +"Scene.Compose.Attachment.Video" = "مقطع مرئي"; +"Scene.Compose.AutoComplete.SpaceToAdd" = "انقر على مساحة لإضافتِها"; +"Scene.Compose.ComposeAction" = "نَشر"; +"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" = "المُدَّة: %@"; +"Scene.Compose.Poll.OneDay" = "يومٌ واحِد"; +"Scene.Compose.Poll.OneHour" = "ساعةٌ واحدة"; +"Scene.Compose.Poll.OptionNumber" = "الخيار %ld"; +"Scene.Compose.Poll.SevenDays" = "سبعةُ أيام"; +"Scene.Compose.Poll.SixHours" = "سِتُّ ساعات"; +"Scene.Compose.Poll.ThirtyMinutes" = "ثلاثون دقيقة"; +"Scene.Compose.Poll.ThreeDays" = "ثلاثةُ أيام"; +"Scene.Compose.ReplyingToUser" = "رَدًا على %@"; +"Scene.Compose.Title.NewPost" = "منشور جديد"; +"Scene.Compose.Title.NewReply" = "رَدٌّ جديد"; +"Scene.Compose.Visibility.Direct" = "للأشخاص المُشار إليهم فقط"; +"Scene.Compose.Visibility.Private" = "للمُتابِعينَ فقط"; +"Scene.Compose.Visibility.Public" = "للعامة"; +"Scene.Compose.Visibility.Unlisted" = "غير مُدرَج"; +"Scene.ConfirmEmail.Button.OpenEmailApp" = "فتح تطبيق البريد الإلكتروني"; +"Scene.ConfirmEmail.Button.Resend" = "إعادَةُ الإرسال"; +"Scene.ConfirmEmail.DontReceiveEmail.Description" = "تحقق ممَّ إذا كان عنوان بريدك الإلكتروني صحيحًا، وكذلك تأكد مِن مجلد البريد غير الهام إذا لم تكن قد فعلت ذلك."; +"Scene.ConfirmEmail.DontReceiveEmail.ResendEmail" = "إعادة إرسال البريد الإلكتروني"; +"Scene.ConfirmEmail.DontReceiveEmail.Title" = "تحقق من بريدك الإلكتروني"; +"Scene.ConfirmEmail.OpenEmailApp.Description" = "لقد أرسلنا لك بريدًا إلكترونيًا للتو. تحقق من مجلد البريد غير الهام الخاص بك إذا لم تكن قد فعلت ذلك."; +"Scene.ConfirmEmail.OpenEmailApp.Mail" = "البريد"; +"Scene.ConfirmEmail.OpenEmailApp.OpenEmailClient" = "فتح عميل البريد الإلكتروني"; +"Scene.ConfirmEmail.OpenEmailApp.Title" = "تحقَّق من بريدك الوارِد."; +"Scene.ConfirmEmail.Subtitle" = "لقد أرسلنا للتو بريد إلكتروني إلى %@، +انقر على الرابط لتأكيد حسابك."; +"Scene.ConfirmEmail.Title" = "شيءٌ أخير."; +"Scene.Favorite.Title" = "مُفضَّلَتُك"; +"Scene.Follower.Footer" = "لا يُمكِن عَرض المُتابِعين مِنَ الخوادم الأُخرى."; +"Scene.Following.Footer" = "لا يُمكِن عَرض المُتابَعات مِنَ الخوادم الأُخرى."; +"Scene.HomeTimeline.NavigationBarState.NewPosts" = "إظهار منشورات جديدة"; +"Scene.HomeTimeline.NavigationBarState.Offline" = "غَير مُتَّصِل"; +"Scene.HomeTimeline.NavigationBarState.Published" = "تمَّ النَّشر!"; +"Scene.HomeTimeline.NavigationBarState.Publishing" = "يَجري نَشر المُشارَكَة..."; +"Scene.HomeTimeline.Title" = "الرَّئِيسَة"; +"Scene.Notification.Keyobard.ShowEverything" = "إظهار كل شيء"; +"Scene.Notification.Keyobard.ShowMentions" = "إظهار الإشارات"; +"Scene.Notification.NotificationDescription.FavoritedYourPost" = "فَضَّلَ مَنشُورَك"; +"Scene.Notification.NotificationDescription.FollowedYou" = "بَدَأ بِمُتابَعَتِك"; +"Scene.Notification.NotificationDescription.MentionedYou" = "أشارَ إليك"; +"Scene.Notification.NotificationDescription.PollHasEnded" = "انتهى استطلاعُ الرأي"; +"Scene.Notification.NotificationDescription.RebloggedYourPost" = "أعادَ تَدوينَ مَنشُورَك"; +"Scene.Notification.NotificationDescription.RequestToFollowYou" = "طَلَبَ مُتابَعتَك"; +"Scene.Notification.Title.Everything" = "كُلُّ شيء"; +"Scene.Notification.Title.Mentions" = "الإشارات"; +"Scene.Preview.Keyboard.ClosePreview" = "إغلاق المُعايَنَة"; +"Scene.Preview.Keyboard.ShowNext" = "إظهار التالي"; +"Scene.Preview.Keyboard.ShowPrevious" = "إظهار السابق"; +"Scene.Profile.Dashboard.Followers" = "متابِع"; +"Scene.Profile.Dashboard.Following" = "مُتابَع"; +"Scene.Profile.Dashboard.Posts" = "مَنشورات"; +"Scene.Profile.Fields.AddRow" = "إضافة صف"; +"Scene.Profile.Fields.Placeholder.Content" = "المُحتَوى"; +"Scene.Profile.Fields.Placeholder.Label" = "التسمية"; +"Scene.Profile.RelationshipActionAlert.ConfirmBlockUser.Message" = "تأكيدُ حَظر %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmBlockUser.Title" = "حَظرُ الحِساب"; +"Scene.Profile.RelationshipActionAlert.ConfirmMuteUser.Message" = "تأكيدُ كَتم %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmMuteUser.Title" = "كَتمُ الحِساب"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUser.Message" = "تأكيدُ رَفع الحَظرِ عَن %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUser.Title" = "رَفعُ الحَظرِ عَنِ الحِساب"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Message" = "أكِّد لرفع الكتمْ عن %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Title" = "رفع الكتم عن الحساب"; +"Scene.Profile.SegmentedControl.About" = "حَول"; +"Scene.Profile.SegmentedControl.Media" = "وَسائِط"; +"Scene.Profile.SegmentedControl.Posts" = "مَنشورات"; +"Scene.Profile.SegmentedControl.PostsAndReplies" = "المَنشوراتُ وَالرُدود"; +"Scene.Profile.SegmentedControl.Replies" = "رُدُود"; +"Scene.Register.Error.Item.Agreement" = "الاِتِّفاقيَّة"; +"Scene.Register.Error.Item.Email" = "البريد الإلكتروني"; +"Scene.Register.Error.Item.Locale" = "اللغة المحلية"; +"Scene.Register.Error.Item.Password" = "الرمز السري"; +"Scene.Register.Error.Item.Reason" = "السبب"; +"Scene.Register.Error.Item.Username" = "اِسم المُستَخدِم"; +"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" = "رمز السر قصير جدًا (يجب أن يتكون من ثمان خانات على الأقل)"; +"Scene.Register.Error.Special.UsernameInvalid" = "يُمكِن أن يحتوي اسم المستخدم على أحرف أبجدية، أرقام وشرطات سفلية فقط"; +"Scene.Register.Error.Special.UsernameTooLong" = "اِسم المُستَخدِم طويل جداً (يَجِبُ ألّا يكون أطول من ثلاثين خانة)"; +"Scene.Register.Input.Avatar.Delete" = "حذف"; +"Scene.Register.Input.DisplayName.Placeholder" = "اِسم العَرض"; +"Scene.Register.Input.Email.Placeholder" = "بريد إلكتروني"; +"Scene.Register.Input.Invite.RegistrationUserInviteRequest" = "لماذا ترغب في الانضمام؟"; +"Scene.Register.Input.Password.Accessibility.Checked" = "مُتَحَققٌ مِنه"; +"Scene.Register.Input.Password.Accessibility.Unchecked" = "غيرُ مُتَحَققٍ مِنه"; +"Scene.Register.Input.Password.CharacterLimit" = "ثمانيةُ خانات"; +"Scene.Register.Input.Password.Hint" = "يجب أن يكون رمزك السري مكوَّن من ثمان خانات على الأقل"; +"Scene.Register.Input.Password.Placeholder" = "رمز سري"; +"Scene.Register.Input.Password.Require" = "رمز المرور الخاص بك يجب أن يحتوي على الأقل:"; +"Scene.Register.Input.Username.DuplicatePrompt" = "اِسم المُستَخدِم هذا مأخوذٌ بالفعل."; +"Scene.Register.Input.Username.Placeholder" = "اِسم مُستَخدِم"; +"Scene.Register.Title" = "أخبرنا عن نفسك."; +"Scene.Report.Content1" = "هل ترغب في إضافة أي منشورات أُخرى إلى البلاغ؟"; +"Scene.Report.Content2" = "هل هناك أي شيء يجب أن يعرفه المُراقبين حول هذا البلاغ؟"; +"Scene.Report.ReportSentTitle" = "شُكرًا لَكَ على الإبلاغ، سَوفَ نَنظُرُ فِي هَذَا الأمر."; +"Scene.Report.Reported" = "مُبْلَغٌ عَنه"; +"Scene.Report.Send" = "إرسال البلاغ"; +"Scene.Report.SkipToSend" = "إرسال بدون تعليق"; +"Scene.Report.Step1" = "الخطوة الأولى مِن أصل اثنتين"; +"Scene.Report.Step2" = "الخطوة الثانية والأخيرة"; +"Scene.Report.TextPlaceholder" = "اكتب أو الصق تعليقات إضافيَّة"; +"Scene.Report.Title" = "الإبلاغ عن %@"; +"Scene.Report.TitleReport" = "إبلاغ"; +"Scene.Search.Recommend.Accounts.Description" = "قَد تَرغَب في مُتابَعَةِ هَذِهِ الحِسابات"; +"Scene.Search.Recommend.Accounts.Follow" = "مُتابَعَة"; +"Scene.Search.Recommend.Accounts.Title" = "حِساباتٍ قَد تُعجِبُك"; +"Scene.Search.Recommend.ButtonText" = "إظهار الكُل"; +"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" = "مَحو"; +"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" = "أكاديمي"; +"Scene.ServerPicker.Button.Category.Activism" = "النشطاء"; +"Scene.ServerPicker.Button.Category.All" = "الكل"; +"Scene.ServerPicker.Button.Category.AllAccessiblityDescription" = "الفئة: الكل"; +"Scene.ServerPicker.Button.Category.Art" = "فنون"; +"Scene.ServerPicker.Button.Category.Food" = "الطعام"; +"Scene.ServerPicker.Button.Category.Furry" = "مكسو بالفرو"; +"Scene.ServerPicker.Button.Category.Games" = "ألعاب"; +"Scene.ServerPicker.Button.Category.General" = "عام"; +"Scene.ServerPicker.Button.Category.Journalism" = "صحافة"; +"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" = "حدث خطأٌ ما أثناء تحميل البيانات. تحقَّق من اتصالك بالإنترنت."; +"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.Subtitle" = "اختر مجتمعًا بناءً على اهتماماتك، منطقتك أو يمكنك حتى اختيارُ مجتمعٍ ذي غرضٍ عام."; +"Scene.ServerPicker.SubtitleExtend" = "اختر مجتمعًا بناءً على اهتماماتك، منطقتك أو يمكنك حتى اختيارُ مجتمعٍ ذي غرضٍ عام. تُشغَّل جميعُ المجتمعِ مِن قِبَلِ مُنظمَةٍ أو فردٍ مُستقلٍ تمامًا."; +"Scene.ServerPicker.Title" = "اِختر خادِم، +أيًّا مِنهُم."; +"Scene.ServerRules.Button.Confirm" = "أنا مُوافِق"; +"Scene.ServerRules.PrivacyPolicy" = "سِياسَة الخُصُوصيَّة"; +"Scene.ServerRules.Prompt" = "في حال إختيارك للمواصلة، أنت تخضع لشروط الخدمة وسياسة الخصوصية لِـ%@."; +"Scene.ServerRules.Subtitle" = "سُنَّت هذه القواعد من قِبل مشرفي %@."; +"Scene.ServerRules.TermsOfService" = "شُرُوط الخِدمَة"; +"Scene.ServerRules.Title" = "بعض القواعد الأساسية."; +"Scene.Settings.Footer.MastodonDescription" = "ماستودون بَرنامجٌ مَفتُوحُ المَصدَر. يُمكِنُكَ المُساهَمَةُ، أوِ الإبلاغُ عَنِ المُشكِلات عَن طريق مِنصَّة جيت هاب (GitHub) في %@ (%@)"; +"Scene.Settings.Keyboard.CloseSettingsWindow" = "إغلاق نافذة الإعدادات"; +"Scene.Settings.Section.Appearance.Automatic" = "تلقائي"; +"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" = "المنطِقَةُ المُملَّة"; +"Scene.Settings.Section.LookAndFeel.Light" = "مُضيء"; +"Scene.Settings.Section.LookAndFeel.ReallyDark" = "مُظلمٌ حَقًّا"; +"Scene.Settings.Section.LookAndFeel.SortaDark" = "مُظلمٌ نوعًا ما"; +"Scene.Settings.Section.LookAndFeel.Title" = "المَظهَرُ وَالشُّعُور"; +"Scene.Settings.Section.LookAndFeel.UseSystem" = "استخدم النِظام"; +"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" = "أيُّ شخصٍ"; +"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" = "النَّمَطُ الأسوَدُ الداكِنُ الحَقيقي"; +"Scene.Settings.Section.Preference.UsingDefaultBrowser" = "اِستِخدامُ المُتصفِّحِ الاِفتراضي لِفتحِ الرَّوابِط"; +"Scene.Settings.Section.SpicyZone.Clear" = "مَحوُ ذاكِرَةُ التَّخزينِ المُؤقت لِلوسائِط"; +"Scene.Settings.Section.SpicyZone.Signout" = "تَسجيلُ الخُروج"; +"Scene.Settings.Section.SpicyZone.Title" = "المنطِقَةُ اللَّاذِعَة"; +"Scene.Settings.Title" = "الإعدادات"; +"Scene.SuggestionAccount.FollowExplain" = "عِندَ مُتابَعَتِكَ لأحدِهِم، سَوف تَرى مَنشوراته في تغذيَتِكَ الرئيسة."; +"Scene.SuggestionAccount.Title" = "ابحث عن أشخاص لمتابعتهم"; +"Scene.Thread.BackTitle" = "منشور"; +"Scene.Thread.Title" = "مَنشور مِن %@"; +"Scene.Welcome.GetStarted" = "ابدأ الآن"; +"Scene.Welcome.LogIn" = "تسجيلُ الدخول"; +"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/MastodonSDK/Sources/MastodonLocalization/Resources/ar.lproj/Localizable.stringsdict similarity index 100% rename from Mastodon/Resources/ar.lproj/Localizable.stringsdict rename to MastodonSDK/Sources/MastodonLocalization/Resources/ar.lproj/Localizable.stringsdict diff --git a/Mastodon/Resources/ca.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/ca.lproj/Localizable.strings similarity index 87% rename from Mastodon/Resources/ca.lproj/Localizable.strings rename to MastodonSDK/Sources/MastodonLocalization/Resources/ca.lproj/Localizable.strings index 1642fc8a5..8213aa3d7 100644 --- a/Mastodon/Resources/ca.lproj/Localizable.strings +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/ca.lproj/Localizable.strings @@ -4,7 +4,7 @@ "Common.Alerts.CleanCache.Title" = "Neteja la memòria cau"; "Common.Alerts.Common.PleaseTryAgain" = "Si us plau intenta-ho de nou."; "Common.Alerts.Common.PleaseTryAgainLater" = "Si us plau, prova-ho més tard."; -"Common.Alerts.DeletePost.Delete" = "Esborra"; +"Common.Alerts.DeletePost.Message" = "Estàs segur que vols suprimir aquesta publicació?"; "Common.Alerts.DeletePost.Title" = "Estàs segur que vols suprimir aquesta publicació?"; "Common.Alerts.DiscardPostContent.Message" = "Confirma per a descartar el contingut de la publicació composta."; "Common.Alerts.DiscardPostContent.Title" = "Descarta l'esborrany"; @@ -41,6 +41,7 @@ Comprova la teva connexió a Internet."; "Common.Controls.Actions.Next" = "Següent"; "Common.Controls.Actions.Ok" = "D'acord"; "Common.Controls.Actions.Open" = "Obre"; +"Common.Controls.Actions.OpenInBrowser" = "Obre al navegador"; "Common.Controls.Actions.OpenInSafari" = "Obrir a Safari"; "Common.Controls.Actions.Preview" = "Vista prèvia"; "Common.Controls.Actions.Previous" = "Anterior"; @@ -93,6 +94,7 @@ Comprova la teva connexió a Internet."; "Common.Controls.Keyboard.Timeline.ToggleFavorite" = "Commuta el Favorit de la publicació"; "Common.Controls.Keyboard.Timeline.ToggleReblog" = "Commuta l'impuls de la publicació"; "Common.Controls.Status.Actions.Favorite" = "Favorit"; +"Common.Controls.Status.Actions.Hide" = "Amaga"; "Common.Controls.Status.Actions.Menu" = "Menú"; "Common.Controls.Status.Actions.Reblog" = "Impuls"; "Common.Controls.Status.Actions.Reply" = "Respon"; @@ -112,6 +114,10 @@ Comprova la teva connexió a Internet."; "Common.Controls.Status.Tag.Url" = "URL"; "Common.Controls.Status.UserReblogged" = "%@ ha impulsat"; "Common.Controls.Status.UserRepliedTo" = "Ha respòs a %@"; +"Common.Controls.Status.Visibility.Direct" = "Només l'usuari mencionat pot veure aquesta publicació."; +"Common.Controls.Status.Visibility.Private" = "Només els seus seguidors poden veure aquesta publicació."; +"Common.Controls.Status.Visibility.PrivateFromMe" = "Només els meus seguidors poden veure aquesta publicació."; +"Common.Controls.Status.Visibility.Unlisted" = "Tothom pot veure aquesta publicació però no es mostra en la línia de temps pública."; "Common.Controls.Tabs.Home" = "Inici"; "Common.Controls.Tabs.Notification" = "Notificació"; "Common.Controls.Tabs.Profile" = "Perfil"; @@ -178,8 +184,8 @@ carregat a Mastodon."; "Scene.Compose.Visibility.Private" = "Només seguidors"; "Scene.Compose.Visibility.Public" = "Públic"; "Scene.Compose.Visibility.Unlisted" = "No llistat"; -"Scene.ConfirmEmail.Button.DontReceiveEmail" = "No he rebut cap correu electrònic"; "Scene.ConfirmEmail.Button.OpenEmailApp" = "Obre l'aplicació de correu"; +"Scene.ConfirmEmail.Button.Resend" = "Reenvia"; "Scene.ConfirmEmail.DontReceiveEmail.Description" = "Comprova que la teva adreça de correu electrònic és correcte i revisa la carpeta de correu brossa si encara no ho has fet."; "Scene.ConfirmEmail.DontReceiveEmail.ResendEmail" = "Torna a enviar el correu"; "Scene.ConfirmEmail.DontReceiveEmail.Title" = "Comprova el teu correu"; @@ -200,14 +206,14 @@ toca l'enllaç per a confirmar el teu compte."; "Scene.HomeTimeline.Title" = "Inici"; "Scene.Notification.Keyobard.ShowEverything" = "Mostrar-ho tot"; "Scene.Notification.Keyobard.ShowMentions" = "Mostrar Mencions"; +"Scene.Notification.NotificationDescription.FavoritedYourPost" = "ha afavorit la teva publicació"; +"Scene.Notification.NotificationDescription.FollowedYou" = "et segueix"; +"Scene.Notification.NotificationDescription.MentionedYou" = "t'ha mencionat"; +"Scene.Notification.NotificationDescription.PollHasEnded" = "la enquesta ha finalitzat"; +"Scene.Notification.NotificationDescription.RebloggedYourPost" = "ha impulsat la teva publicació"; +"Scene.Notification.NotificationDescription.RequestToFollowYou" = "ha sol·licitat seguir-te"; "Scene.Notification.Title.Everything" = "Tot"; "Scene.Notification.Title.Mentions" = "Mencions"; -"Scene.Notification.UserFavorited Your Post" = "%@ ha afavorit el teu estat"; -"Scene.Notification.UserFollowedYou" = "%@ et segueix"; -"Scene.Notification.UserMentionedYou" = "%@ t'ha esmentat"; -"Scene.Notification.UserRebloggedYourPost" = "%@ ha impulsat el teu estat"; -"Scene.Notification.UserRequestedToFollowYou" = "%@ ha sol·licitat seguir-te"; -"Scene.Notification.UserYourPollHasEnded" = "%@ L'enquesta ha finalitzat"; "Scene.Preview.Keyboard.ClosePreview" = "Tanca la Vista Prèvia"; "Scene.Preview.Keyboard.ShowNext" = "Mostrar Següent"; "Scene.Preview.Keyboard.ShowPrevious" = "Mostrar Anterior"; @@ -217,12 +223,18 @@ toca l'enllaç per a confirmar el teu compte."; "Scene.Profile.Fields.AddRow" = "Afegeix fila"; "Scene.Profile.Fields.Placeholder.Content" = "Contingut"; "Scene.Profile.Fields.Placeholder.Label" = "Etiqueta"; -"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Message" = "Confirma desbloquejar a %@"; -"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Title" = "Desbloquejar Compte"; +"Scene.Profile.RelationshipActionAlert.ConfirmBlockUser.Message" = "Confirma per a bloquejar %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmBlockUser.Title" = "Bloqueja el Compte"; +"Scene.Profile.RelationshipActionAlert.ConfirmMuteUser.Message" = "Confirma per a silenciar %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmMuteUser.Title" = "Silencia el Compte"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUser.Message" = "Confirma per a desbloquejar %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUser.Title" = "Desbloqueja el Compte"; "Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Message" = "Confirma deixar de silenciar a %@"; "Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Title" = "Desfer silenciar compte"; +"Scene.Profile.SegmentedControl.About" = "Quant a"; "Scene.Profile.SegmentedControl.Media" = "Mèdia"; "Scene.Profile.SegmentedControl.Posts" = "Publicacions"; +"Scene.Profile.SegmentedControl.PostsAndReplies" = "Publicacions i Respostes"; "Scene.Profile.SegmentedControl.Replies" = "Respostes"; "Scene.Register.Error.Item.Agreement" = "Acord"; "Scene.Register.Error.Item.Email" = "Correu electrònic"; @@ -248,19 +260,26 @@ toca l'enllaç per a confirmar el teu compte."; "Scene.Register.Input.DisplayName.Placeholder" = "nom visible"; "Scene.Register.Input.Email.Placeholder" = "correu electrònic"; "Scene.Register.Input.Invite.RegistrationUserInviteRequest" = "Perquè vols unir-te?"; +"Scene.Register.Input.Password.Accessibility.Checked" = "verificat"; +"Scene.Register.Input.Password.Accessibility.Unchecked" = "no verificat"; +"Scene.Register.Input.Password.CharacterLimit" = "8 caràcters"; "Scene.Register.Input.Password.Hint" = "La teva contrasenya ha de tenir com a mínim buit caràcters"; "Scene.Register.Input.Password.Placeholder" = "contrasenya"; +"Scene.Register.Input.Password.Require" = "La teva contrasenya com a mínim necessita:"; "Scene.Register.Input.Username.DuplicatePrompt" = "Aquest nom d'usuari ja està en ús."; "Scene.Register.Input.Username.Placeholder" = "nom d'usuari"; "Scene.Register.Title" = "Parla'ns de tu."; "Scene.Report.Content1" = "Hi ha alguna altre publicació que vulguis afegir a l'informe?"; "Scene.Report.Content2" = "Hi ha alguna cosa que els moderadors hagin de saber sobre aquest informe?"; +"Scene.Report.ReportSentTitle" = "Gràcies per informar, ho investigarem."; +"Scene.Report.Reported" = "REPORTAT"; "Scene.Report.Send" = "Envia Informe"; "Scene.Report.SkipToSend" = "Envia sense comentaris"; "Scene.Report.Step1" = "Pas 1 de 2"; "Scene.Report.Step2" = "Pas 2 de 2"; "Scene.Report.TextPlaceholder" = "Escriu o enganxa comentaris addicionals"; "Scene.Report.Title" = "Informa sobre %@"; +"Scene.Report.TitleReport" = "Informe"; "Scene.Search.Recommend.Accounts.Description" = "Potser t'agradaria seguir aquests comptes"; "Scene.Search.Recommend.Accounts.Follow" = "Segueix"; "Scene.Search.Recommend.Accounts.Title" = "Comptes que et podrien agradar"; @@ -301,6 +320,8 @@ toca l'enllaç per a confirmar el teu compte."; "Scene.ServerPicker.Label.Category" = "CATEGORIA"; "Scene.ServerPicker.Label.Language" = "LLENGUATGE"; "Scene.ServerPicker.Label.Users" = "USUARIS"; +"Scene.ServerPicker.Subtitle" = "Tria una comunitat segons els teus interessos, regió o una de propòsit general."; +"Scene.ServerPicker.SubtitleExtend" = "Tria una comunitat segons els teus interessos, regió o una de propòsit general. Cada comunitat és operada per una organització totalment independent o individualment."; "Scene.ServerPicker.Title" = "Tria un servidor, qualsevol servidor."; "Scene.ServerRules.Button.Confirm" = "Hi estic d'acord"; @@ -319,6 +340,11 @@ qualsevol servidor."; "Scene.Settings.Section.BoringZone.Privacy" = "Política de Privacitat"; "Scene.Settings.Section.BoringZone.Terms" = "Termes de Servei"; "Scene.Settings.Section.BoringZone.Title" = "La Zona Avorrida"; +"Scene.Settings.Section.LookAndFeel.Light" = "Clar"; +"Scene.Settings.Section.LookAndFeel.ReallyDark" = "Realment Negre"; +"Scene.Settings.Section.LookAndFeel.SortaDark" = "Una Mena de Fosc"; +"Scene.Settings.Section.LookAndFeel.Title" = "Aspecte i Comportament"; +"Scene.Settings.Section.LookAndFeel.UseSystem" = "Usa el del Sistema"; "Scene.Settings.Section.Notifications.Boosts" = "Ha impulsat el meu estat"; "Scene.Settings.Section.Notifications.Favorites" = "Ha afavorit el meu estat"; "Scene.Settings.Section.Notifications.Follows" = "Em segueix"; @@ -342,6 +368,8 @@ qualsevol servidor."; "Scene.SuggestionAccount.Title" = "Cerca Persones per Seguir"; "Scene.Thread.BackTitle" = "Publicació"; "Scene.Thread.Title" = "Publicació de %@"; +"Scene.Welcome.GetStarted" = "Comença"; +"Scene.Welcome.LogIn" = "Inicia sessió"; "Scene.Welcome.Slogan" = "Xarxa social de nou a les teves mans."; "Scene.Wizard.AccessibilityHint" = "Toca dues vegades per descartar l'assistent"; diff --git a/Mastodon/Resources/ca.lproj/Localizable.stringsdict b/MastodonSDK/Sources/MastodonLocalization/Resources/ca.lproj/Localizable.stringsdict similarity index 100% rename from Mastodon/Resources/ca.lproj/Localizable.stringsdict rename to MastodonSDK/Sources/MastodonLocalization/Resources/ca.lproj/Localizable.stringsdict diff --git a/Mastodon/Resources/de.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/de.lproj/Localizable.strings similarity index 86% rename from Mastodon/Resources/de.lproj/Localizable.strings rename to MastodonSDK/Sources/MastodonLocalization/Resources/de.lproj/Localizable.strings index 12fba5387..8808e8d90 100644 --- a/Mastodon/Resources/de.lproj/Localizable.strings +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/de.lproj/Localizable.strings @@ -4,7 +4,7 @@ "Common.Alerts.CleanCache.Title" = "Zwischenspeicher leeren"; "Common.Alerts.Common.PleaseTryAgain" = "Bitte versuche es erneut."; "Common.Alerts.Common.PleaseTryAgainLater" = "Bitte versuche es später nochmal."; -"Common.Alerts.DeletePost.Delete" = "Löschen"; +"Common.Alerts.DeletePost.Message" = "Bist du dir sicher, dass du diesen Beitrag löschen willst?"; "Common.Alerts.DeletePost.Title" = "Bist du dir sicher, dass du diesen Beitrag löschen möchtest?"; "Common.Alerts.DiscardPostContent.Message" = "Bestätige, um den Beitrag zu verwerfen."; "Common.Alerts.DiscardPostContent.Title" = "Entwurf verwerfen"; @@ -28,7 +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.Compose" = "Neue Nachricht"; "Common.Controls.Actions.Confirm" = "Bestätigen"; "Common.Controls.Actions.Continue" = "Fortfahren"; "Common.Controls.Actions.CopyPhoto" = "Foto kopieren"; @@ -41,6 +41,7 @@ Bitte überprüfe deine Internetverbindung."; "Common.Controls.Actions.Next" = "Weiter"; "Common.Controls.Actions.Ok" = "OK"; "Common.Controls.Actions.Open" = "Öffnen"; +"Common.Controls.Actions.OpenInBrowser" = "Im Browser anzeigen"; "Common.Controls.Actions.OpenInSafari" = "In Safari öffnen"; "Common.Controls.Actions.Preview" = "Vorschau"; "Common.Controls.Actions.Previous" = "Vorheriges"; @@ -93,6 +94,7 @@ Bitte überprüfe deine Internetverbindung."; "Common.Controls.Keyboard.Timeline.ToggleFavorite" = "Favorit vom Beitrag umschalten"; "Common.Controls.Keyboard.Timeline.ToggleReblog" = "Teilen vom Beitrag umschalten"; "Common.Controls.Status.Actions.Favorite" = "Favorit"; +"Common.Controls.Status.Actions.Hide" = "Verstecken"; "Common.Controls.Status.Actions.Menu" = "Menü"; "Common.Controls.Status.Actions.Reblog" = "Teilen"; "Common.Controls.Status.Actions.Reply" = "Antworten"; @@ -112,6 +114,10 @@ Bitte überprüfe deine Internetverbindung."; "Common.Controls.Status.Tag.Url" = "URL"; "Common.Controls.Status.UserReblogged" = "%@ teilte"; "Common.Controls.Status.UserRepliedTo" = "Antwortet auf %@"; +"Common.Controls.Status.Visibility.Direct" = "Nur erwähnte Benutzer können diesen Beitrag sehen."; +"Common.Controls.Status.Visibility.Private" = "Nur Follower des Authors können diesen Beitrag sehen."; +"Common.Controls.Status.Visibility.PrivateFromMe" = "Nur meine Follower können diesen Beitrag sehen."; +"Common.Controls.Status.Visibility.Unlisted" = "Jeder kann diesen Post sehen, aber nicht in der öffentlichen Timeline zeigen."; "Common.Controls.Tabs.Home" = "Startseite"; "Common.Controls.Tabs.Notification" = "Benachrichtigungen"; "Common.Controls.Tabs.Profile" = "Profil"; @@ -135,7 +141,7 @@ Dein Profil sieht für diesen Benutzer auch so aus."; "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.DismissAccountSwitcher" = "Dialog zum Wechseln des Kontos schließen"; "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"; @@ -178,8 +184,8 @@ kann nicht auf Mastodon hochgeladen werden."; "Scene.Compose.Visibility.Private" = "Nur für Folgende"; "Scene.Compose.Visibility.Public" = "Öffentlich"; "Scene.Compose.Visibility.Unlisted" = "Nicht gelistet"; -"Scene.ConfirmEmail.Button.DontReceiveEmail" = "Ich habe keine E-Mail erhalten."; "Scene.ConfirmEmail.Button.OpenEmailApp" = "E-Mail-App öffnen"; +"Scene.ConfirmEmail.Button.Resend" = "Erneut senden"; "Scene.ConfirmEmail.DontReceiveEmail.Description" = "Überprüfe, ob deine E-Mail-Adresse korrekt ist und sieh im Spam-Ordner nach, falls du es noch nicht getan hast."; "Scene.ConfirmEmail.DontReceiveEmail.ResendEmail" = "E-Mail erneut versenden"; "Scene.ConfirmEmail.DontReceiveEmail.Title" = "Bitte überprüfe deine E-Mails"; @@ -191,8 +197,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.Follower.Footer" = "Follower von anderen Servern werden nicht angezeigt."; +"Scene.Following.Footer" = "Wem das Konto folgt wird von anderen Servern werden nicht angezeigt."; "Scene.HomeTimeline.NavigationBarState.NewPosts" = "Neue Beiträge anzeigen"; "Scene.HomeTimeline.NavigationBarState.Offline" = "Offline"; "Scene.HomeTimeline.NavigationBarState.Published" = "Veröffentlicht!"; @@ -200,14 +206,14 @@ tippe darin auf den Link, um Dein Konto zu bestätigen."; "Scene.HomeTimeline.Title" = "Startseite"; "Scene.Notification.Keyobard.ShowEverything" = "Alles anzeigen"; "Scene.Notification.Keyobard.ShowMentions" = "Erwähnungen anzeigen"; +"Scene.Notification.NotificationDescription.FavoritedYourPost" = "hat deinen Beitrag favorisiert"; +"Scene.Notification.NotificationDescription.FollowedYou" = "folgt dir"; +"Scene.Notification.NotificationDescription.MentionedYou" = "hat dich erwähnt"; +"Scene.Notification.NotificationDescription.PollHasEnded" = "Umfrage wurde beendet"; +"Scene.Notification.NotificationDescription.RebloggedYourPost" = "hat deinen Beitrag geteilt"; +"Scene.Notification.NotificationDescription.RequestToFollowYou" = "Folgeanfrage"; "Scene.Notification.Title.Everything" = "Alles"; "Scene.Notification.Title.Mentions" = "Erwähnungen"; -"Scene.Notification.UserFavorited Your Post" = "%@ favorisierte deinen Beitrag"; -"Scene.Notification.UserFollowedYou" = "%@ folgte dir"; -"Scene.Notification.UserMentionedYou" = "%@ erwähnte dich"; -"Scene.Notification.UserRebloggedYourPost" = "%@ teilte deinen Beitrag"; -"Scene.Notification.UserRequestedToFollowYou" = "%@ beantragte dir zu folgen"; -"Scene.Notification.UserYourPollHasEnded" = "%@ deine Umfrage ist beendet"; "Scene.Preview.Keyboard.ClosePreview" = "Vorschau schließen"; "Scene.Preview.Keyboard.ShowNext" = "Nächstes anzeigen"; "Scene.Preview.Keyboard.ShowPrevious" = "Vorheriges anzeigen"; @@ -217,12 +223,18 @@ tippe darin auf den Link, um Dein Konto zu bestätigen."; "Scene.Profile.Fields.AddRow" = "Zeile hinzufügen"; "Scene.Profile.Fields.Placeholder.Content" = "Inhalt"; "Scene.Profile.Fields.Placeholder.Label" = "Bezeichnung"; -"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Message" = "Bestätigen zum Entsperren von %@"; -"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Title" = "Konto entsperren"; +"Scene.Profile.RelationshipActionAlert.ConfirmBlockUser.Message" = "Bestätige %@ zu blockieren"; +"Scene.Profile.RelationshipActionAlert.ConfirmBlockUser.Title" = "Konto blockieren"; +"Scene.Profile.RelationshipActionAlert.ConfirmMuteUser.Message" = "Bestätige %@ stumm zu schalten"; +"Scene.Profile.RelationshipActionAlert.ConfirmMuteUser.Title" = "Konto stummschalten"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUser.Message" = "Bestätige %@ zu entsperren"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUser.Title" = "Konto entsperren"; "Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Message" = "Bestätige um %@ nicht mehr stummzuschalten"; "Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Title" = "Ton einschalten"; +"Scene.Profile.SegmentedControl.About" = "Über"; "Scene.Profile.SegmentedControl.Media" = "Medien"; "Scene.Profile.SegmentedControl.Posts" = "Beiträge"; +"Scene.Profile.SegmentedControl.PostsAndReplies" = "Beiträge und Antworten"; "Scene.Profile.SegmentedControl.Replies" = "Antworten"; "Scene.Register.Error.Item.Agreement" = "Vereinbarung"; "Scene.Register.Error.Item.Email" = "E-Mail"; @@ -248,19 +260,26 @@ tippe darin auf den Link, um Dein Konto zu bestätigen."; "Scene.Register.Input.DisplayName.Placeholder" = "Anzeigename"; "Scene.Register.Input.Email.Placeholder" = "E-Mail"; "Scene.Register.Input.Invite.RegistrationUserInviteRequest" = "Warum möchtest du beitreten?"; +"Scene.Register.Input.Password.Accessibility.Checked" = "Häkchen gesetzt"; +"Scene.Register.Input.Password.Accessibility.Unchecked" = "Häkchen entfernt"; +"Scene.Register.Input.Password.CharacterLimit" = "8 Zeichen"; "Scene.Register.Input.Password.Hint" = "Ihr Passwort muss mindestens 8 Zeichen lang sein"; "Scene.Register.Input.Password.Placeholder" = "Passwort"; +"Scene.Register.Input.Password.Require" = "Anforderungen an dein Passwort:"; "Scene.Register.Input.Username.DuplicatePrompt" = "Dieser Benutzername ist vergeben."; "Scene.Register.Input.Username.Placeholder" = "Benutzername"; "Scene.Register.Title" = "Erzähle uns von dir."; "Scene.Report.Content1" = "Gibt es noch weitere Beiträge, die du der Meldung hinzufügen möchtest?"; "Scene.Report.Content2" = "Gibt es etwas, was die Moderatoren über diese Meldung wissen sollten?"; +"Scene.Report.ReportSentTitle" = "Danke für deine Meldung, wir werden uns damit beschäftigen."; +"Scene.Report.Reported" = "GEMELDET"; "Scene.Report.Send" = "Meldung abschicken"; "Scene.Report.SkipToSend" = "Ohne Kommentar abschicken"; "Scene.Report.Step1" = "Schritt 1 von 2"; "Scene.Report.Step2" = "Schritt 2 von 2"; "Scene.Report.TextPlaceholder" = "Zusätzliche Kommentare eingeben oder einfügen"; "Scene.Report.Title" = "%@ melden"; +"Scene.Report.TitleReport" = "Melden"; "Scene.Search.Recommend.Accounts.Description" = "Vielleicht gefallen dir diese Benutzer"; "Scene.Search.Recommend.Accounts.Follow" = "Folgen"; "Scene.Search.Recommend.Accounts.Title" = "Konten, die dir gefallen könnten"; @@ -301,6 +320,8 @@ tippe darin auf den Link, um Dein Konto zu bestätigen."; "Scene.ServerPicker.Label.Category" = "KATEGORIE"; "Scene.ServerPicker.Label.Language" = "SPRACHE"; "Scene.ServerPicker.Label.Users" = "BENUTZER"; +"Scene.ServerPicker.Subtitle" = "Wähle eine Gemeinschaft, die auf deinen Interessen, Region oder einem allgemeinen Zweck basiert."; +"Scene.ServerPicker.SubtitleExtend" = "Wähle eine Gemeinschaft basierend auf deinen Interessen, deiner Region oder einem allgemeinen Zweck. Jede Gemeinschaft wird von einer völlig unabhängigen Organisation oder Einzelperson betrieben."; "Scene.ServerPicker.Title" = "Wähle einen Server, beliebigen Server."; "Scene.ServerRules.Button.Confirm" = "Ich stimme zu"; @@ -318,7 +339,12 @@ beliebigen Server."; "Scene.Settings.Section.BoringZone.AccountSettings" = "Kontoeinstellungen"; "Scene.Settings.Section.BoringZone.Privacy" = "Datenschutzerklärung"; "Scene.Settings.Section.BoringZone.Terms" = "Allgemeine Geschäftsbedingungen"; -"Scene.Settings.Section.BoringZone.Title" = "Der Langweiliger Bereich"; +"Scene.Settings.Section.BoringZone.Title" = "Der langweilige Bereich"; +"Scene.Settings.Section.LookAndFeel.Light" = "Hell"; +"Scene.Settings.Section.LookAndFeel.ReallyDark" = "Wirklich dunkel"; +"Scene.Settings.Section.LookAndFeel.SortaDark" = "Ziemlich dunkel"; +"Scene.Settings.Section.LookAndFeel.Title" = "Erscheinungsbild"; +"Scene.Settings.Section.LookAndFeel.UseSystem" = "Systemeinstellung benutzen"; "Scene.Settings.Section.Notifications.Boosts" = "Meinen Beitrag teilt"; "Scene.Settings.Section.Notifications.Favorites" = "Meinen Beitrag favorisiert"; "Scene.Settings.Section.Notifications.Follows" = "Mir folgt"; @@ -342,6 +368,8 @@ beliebigen Server."; "Scene.SuggestionAccount.Title" = "Finde Personen zum Folgen"; "Scene.Thread.BackTitle" = "Beitrag"; "Scene.Thread.Title" = "Beitrag von %@"; +"Scene.Welcome.GetStarted" = "Erste Schritte"; +"Scene.Welcome.LogIn" = "Anmelden"; "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."; diff --git a/Mastodon/Resources/de.lproj/Localizable.stringsdict b/MastodonSDK/Sources/MastodonLocalization/Resources/de.lproj/Localizable.stringsdict similarity index 100% rename from Mastodon/Resources/de.lproj/Localizable.stringsdict rename to MastodonSDK/Sources/MastodonLocalization/Resources/de.lproj/Localizable.stringsdict diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/en.lproj/Localizable.strings similarity index 85% rename from Mastodon/Resources/en.lproj/Localizable.strings rename to MastodonSDK/Sources/MastodonLocalization/Resources/en.lproj/Localizable.strings index 0f3ed66ae..1a03cd56a 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/en.lproj/Localizable.strings @@ -4,8 +4,8 @@ "Common.Alerts.CleanCache.Title" = "Clean Cache"; "Common.Alerts.Common.PleaseTryAgain" = "Please try again."; "Common.Alerts.Common.PleaseTryAgainLater" = "Please try again later."; -"Common.Alerts.DeletePost.Delete" = "Delete"; -"Common.Alerts.DeletePost.Title" = "Are you sure you want to delete this post?"; +"Common.Alerts.DeletePost.Message" = "Are you sure you want to delete this post?"; +"Common.Alerts.DeletePost.Title" = "Delete Post"; "Common.Alerts.DiscardPostContent.Message" = "Confirm to discard composed post content."; "Common.Alerts.DiscardPostContent.Title" = "Discard Draft"; "Common.Alerts.EditProfileFailure.Message" = "Cannot edit profile. Please try again."; @@ -41,6 +41,7 @@ Please check your internet connection."; "Common.Controls.Actions.Next" = "Next"; "Common.Controls.Actions.Ok" = "OK"; "Common.Controls.Actions.Open" = "Open"; +"Common.Controls.Actions.OpenInBrowser" = "Open in Browser"; "Common.Controls.Actions.OpenInSafari" = "Open in Safari"; "Common.Controls.Actions.Preview" = "Preview"; "Common.Controls.Actions.Previous" = "Previous"; @@ -93,6 +94,7 @@ Please check your internet connection."; "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.Hide" = "Hide"; "Common.Controls.Status.Actions.Menu" = "Menu"; "Common.Controls.Status.Actions.Reblog" = "Reblog"; "Common.Controls.Status.Actions.Reply" = "Reply"; @@ -112,6 +114,10 @@ Please check your internet connection."; "Common.Controls.Status.Tag.Url" = "URL"; "Common.Controls.Status.UserReblogged" = "%@ reblogged"; "Common.Controls.Status.UserRepliedTo" = "Replied to %@"; +"Common.Controls.Status.Visibility.Direct" = "Only mentioned user can see this post."; +"Common.Controls.Status.Visibility.Private" = "Only their followers can see this post."; +"Common.Controls.Status.Visibility.PrivateFromMe" = "Only my followers can see this post."; +"Common.Controls.Status.Visibility.Unlisted" = "Everyone can see this post but not display in the public timeline."; "Common.Controls.Tabs.Home" = "Home"; "Common.Controls.Tabs.Notification" = "Notification"; "Common.Controls.Tabs.Profile" = "Profile"; @@ -178,8 +184,8 @@ uploaded to Mastodon."; "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.Button.Resend" = "Resend"; "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"; @@ -187,8 +193,7 @@ uploaded to Mastodon."; "Scene.ConfirmEmail.OpenEmailApp.Mail" = "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.Subtitle" = "Tap the link we emailed to you to verify your account."; "Scene.ConfirmEmail.Title" = "One last thing."; "Scene.Favorite.Title" = "Your Favorites"; "Scene.Follower.Footer" = "Followers from other servers are not displayed."; @@ -200,14 +205,14 @@ tap the link to confirm your account."; "Scene.HomeTimeline.Title" = "Home"; "Scene.Notification.Keyobard.ShowEverything" = "Show Everything"; "Scene.Notification.Keyobard.ShowMentions" = "Show Mentions"; +"Scene.Notification.NotificationDescription.FavoritedYourPost" = "favorited your post"; +"Scene.Notification.NotificationDescription.FollowedYou" = "followed you"; +"Scene.Notification.NotificationDescription.MentionedYou" = "mentioned you"; +"Scene.Notification.NotificationDescription.PollHasEnded" = "poll has ended"; +"Scene.Notification.NotificationDescription.RebloggedYourPost" = "reblogged your post"; +"Scene.Notification.NotificationDescription.RequestToFollowYou" = "request to follow you"; "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" = "Close Preview"; "Scene.Preview.Keyboard.ShowNext" = "Show Next"; "Scene.Preview.Keyboard.ShowPrevious" = "Show Previous"; @@ -217,12 +222,18 @@ tap the link to confirm your account."; "Scene.Profile.Fields.AddRow" = "Add Row"; "Scene.Profile.Fields.Placeholder.Content" = "Content"; "Scene.Profile.Fields.Placeholder.Label" = "Label"; -"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Message" = "Confirm to unblock %@"; -"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Title" = "Unblock Account"; +"Scene.Profile.RelationshipActionAlert.ConfirmBlockUser.Message" = "Confirm to block %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmBlockUser.Title" = "Block Account"; +"Scene.Profile.RelationshipActionAlert.ConfirmMuteUser.Message" = "Confirm to mute %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmMuteUser.Title" = "Mute Account"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUser.Message" = "Confirm to unblock %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUser.Title" = "Unblock Account"; "Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Message" = "Confirm to unmute %@"; "Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Title" = "Unmute Account"; +"Scene.Profile.SegmentedControl.About" = "About"; "Scene.Profile.SegmentedControl.Media" = "Media"; "Scene.Profile.SegmentedControl.Posts" = "Posts"; +"Scene.Profile.SegmentedControl.PostsAndReplies" = "Posts and Replies"; "Scene.Profile.SegmentedControl.Replies" = "Replies"; "Scene.Register.Error.Item.Agreement" = "Agreement"; "Scene.Register.Error.Item.Email" = "Email"; @@ -248,19 +259,26 @@ tap the link to confirm your account."; "Scene.Register.Input.DisplayName.Placeholder" = "display name"; "Scene.Register.Input.Email.Placeholder" = "email"; "Scene.Register.Input.Invite.RegistrationUserInviteRequest" = "Why do you want to join?"; +"Scene.Register.Input.Password.Accessibility.Checked" = "checked"; +"Scene.Register.Input.Password.Accessibility.Unchecked" = "unchecked"; +"Scene.Register.Input.Password.CharacterLimit" = "8 characters"; "Scene.Register.Input.Password.Hint" = "Your password needs at least eight characters"; "Scene.Register.Input.Password.Placeholder" = "password"; +"Scene.Register.Input.Password.Require" = "Your password needs at least:"; "Scene.Register.Input.Username.DuplicatePrompt" = "This username is taken."; "Scene.Register.Input.Username.Placeholder" = "username"; -"Scene.Register.Title" = "Tell us about you."; +"Scene.Register.Title" = "Let’s get you set up on %@"; "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.ReportSentTitle" = "Thanks for reporting, we’ll look into this."; +"Scene.Report.Reported" = "REPORTED"; "Scene.Report.Send" = "Send Report"; "Scene.Report.SkipToSend" = "Send without comment"; "Scene.Report.Step1" = "Step 1 of 2"; "Scene.Report.Step2" = "Step 2 of 2"; "Scene.Report.TextPlaceholder" = "Type or paste additional comments"; "Scene.Report.Title" = "Report %@"; +"Scene.Report.TitleReport" = "Report"; "Scene.Search.Recommend.Accounts.Description" = "You may like to follow these accounts"; "Scene.Search.Recommend.Accounts.Follow" = "Follow"; "Scene.Search.Recommend.Accounts.Title" = "Accounts you might like"; @@ -297,16 +315,17 @@ tap the link to confirm your account."; "Scene.ServerPicker.EmptyState.BadNetwork" = "Something went wrong while loading the data. Check your internet connection."; "Scene.ServerPicker.EmptyState.FindingServers" = "Finding available servers..."; "Scene.ServerPicker.EmptyState.NoResults" = "No results"; -"Scene.ServerPicker.Input.Placeholder" = "Find a server or join your own..."; +"Scene.ServerPicker.Input.Placeholder" = "Search communities"; "Scene.ServerPicker.Label.Category" = "CATEGORY"; "Scene.ServerPicker.Label.Language" = "LANGUAGE"; "Scene.ServerPicker.Label.Users" = "USERS"; -"Scene.ServerPicker.Title" = "Pick a server, -any server."; +"Scene.ServerPicker.Subtitle" = "Pick a community based on your interests, region, or a general purpose one."; +"Scene.ServerPicker.SubtitleExtend" = "Pick a community based on your interests, region, or a general purpose one. Each community is operated by an entirely independent organization or individual."; +"Scene.ServerPicker.Title" = "Mastodon is made of users in different communities."; "Scene.ServerRules.Button.Confirm" = "I Agree"; "Scene.ServerRules.PrivacyPolicy" = "privacy policy"; "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.Subtitle" = "These are set and enforced by the %@ moderators."; "Scene.ServerRules.TermsOfService" = "terms of service"; "Scene.ServerRules.Title" = "Some ground rules."; "Scene.Settings.Footer.MastodonDescription" = "Mastodon is open source software. You can report issues on GitHub at %@ (%@)"; @@ -319,6 +338,11 @@ any server."; "Scene.Settings.Section.BoringZone.Privacy" = "Privacy Policy"; "Scene.Settings.Section.BoringZone.Terms" = "Terms of Service"; "Scene.Settings.Section.BoringZone.Title" = "The Boring Zone"; +"Scene.Settings.Section.LookAndFeel.Light" = "Light"; +"Scene.Settings.Section.LookAndFeel.ReallyDark" = "Really Dark"; +"Scene.Settings.Section.LookAndFeel.SortaDark" = "Sorta Dark"; +"Scene.Settings.Section.LookAndFeel.Title" = "Look and Feel"; +"Scene.Settings.Section.LookAndFeel.UseSystem" = "Use System"; "Scene.Settings.Section.Notifications.Boosts" = "Reblogs my post"; "Scene.Settings.Section.Notifications.Favorites" = "Favorites my post"; "Scene.Settings.Section.Notifications.Follows" = "Follows me"; @@ -342,6 +366,8 @@ any server."; "Scene.SuggestionAccount.Title" = "Find People to Follow"; "Scene.Thread.BackTitle" = "Post"; "Scene.Thread.Title" = "Post from %@"; +"Scene.Welcome.GetStarted" = "Get Started"; +"Scene.Welcome.LogIn" = "Log In"; "Scene.Welcome.Slogan" = "Social networking back in your hands."; "Scene.Wizard.AccessibilityHint" = "Double tap to dismiss this wizard"; diff --git a/MastodonSDK/Sources/MastodonLocalization/Resources/en.lproj/Localizable.stringsdict b/MastodonSDK/Sources/MastodonLocalization/Resources/en.lproj/Localizable.stringsdict new file mode 100644 index 000000000..730e2902a --- /dev/null +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/en.lproj/Localizable.stringsdict @@ -0,0 +1,390 @@ +<?xml version="1.0" encoding="utf-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> + <dict> + <key>a11y.plural.count.unread.notification</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@notification_count_unread_notification@</string> + <key>notification_count_unread_notification</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 unread notification</string> + <key>other</key> + <string>%ld unread notification</string> + </dict> + </dict> + <key>a11y.plural.count.input_limit_exceeds</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>Input limit exceeds %#@character_count@</string> + <key>character_count</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 character</string> + <key>other</key> + <string>%ld characters</string> + </dict> + </dict> + <key>a11y.plural.count.input_limit_remains</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>Input limit remains %#@character_count@</string> + <key>character_count</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 character</string> + <key>other</key> + <string>%ld characters</string> + </dict> + </dict> + <key>plural.count.metric_formatted.post</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%@ %#@post_count@</string> + <key>post_count</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>post</string> + <key>other</key> + <string>posts</string> + </dict> + </dict> + <key>plural.count.post</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@post_count@</string> + <key>post_count</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 post</string> + <key>other</key> + <string>%ld posts</string> + </dict> + </dict> + <key>plural.count.favorite</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@favorite_count@</string> + <key>favorite_count</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 favorite</string> + <key>other</key> + <string>%ld favorites</string> + </dict> + </dict> + <key>plural.count.reblog</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@reblog_count@</string> + <key>reblog_count</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 reblog</string> + <key>other</key> + <string>%ld reblogs</string> + </dict> + </dict> + <key>plural.count.vote</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@vote_count@</string> + <key>vote_count</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 vote</string> + <key>other</key> + <string>%ld votes</string> + </dict> + </dict> + <key>plural.count.voter</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@voter_count@</string> + <key>voter_count</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 voter</string> + <key>other</key> + <string>%ld voters</string> + </dict> + </dict> + <key>plural.people_talking</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_people_talking@</string> + <key>count_people_talking</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 people talking</string> + <key>other</key> + <string>%ld people talking</string> + </dict> + </dict> + <key>plural.count.following</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_following@</string> + <key>count_following</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 following</string> + <key>other</key> + <string>%ld following</string> + </dict> + </dict> + <key>plural.count.follower</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_follower@</string> + <key>count_follower</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 follower</string> + <key>other</key> + <string>%ld followers</string> + </dict> + </dict> + <key>date.year.left</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_year_left@</string> + <key>count_year_left</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 year left</string> + <key>other</key> + <string>%ld years left</string> + </dict> + </dict> + <key>date.month.left</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_month_left@</string> + <key>count_month_left</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 months left</string> + <key>other</key> + <string>%ld months left</string> + </dict> + </dict> + <key>date.day.left</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_day_left@</string> + <key>count_day_left</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 day left</string> + <key>other</key> + <string>%ld days left</string> + </dict> + </dict> + <key>date.hour.left</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_hour_left@</string> + <key>count_hour_left</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 hour left</string> + <key>other</key> + <string>%ld hours left</string> + </dict> + </dict> + <key>date.minute.left</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_minute_left@</string> + <key>count_minute_left</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 minute left</string> + <key>other</key> + <string>%ld minutes left</string> + </dict> + </dict> + <key>date.second.left</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_second_left@</string> + <key>count_second_left</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 second left</string> + <key>other</key> + <string>%ld seconds left</string> + </dict> + </dict> + <key>date.year.ago.abbr</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_year_ago_abbr@</string> + <key>count_year_ago_abbr</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1y ago</string> + <key>other</key> + <string>%ldy ago</string> + </dict> + </dict> + <key>date.month.ago.abbr</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_month_ago_abbr@</string> + <key>count_month_ago_abbr</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1M ago</string> + <key>other</key> + <string>%ldM ago</string> + </dict> + </dict> + <key>date.day.ago.abbr</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_day_ago_abbr@</string> + <key>count_day_ago_abbr</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1d ago</string> + <key>other</key> + <string>%ldd ago</string> + </dict> + </dict> + <key>date.hour.ago.abbr</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_hour_ago_abbr@</string> + <key>count_hour_ago_abbr</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1h ago</string> + <key>other</key> + <string>%ldh ago</string> + </dict> + </dict> + <key>date.minute.ago.abbr</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_minute_ago_abbr@</string> + <key>count_minute_ago_abbr</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1m ago</string> + <key>other</key> + <string>%ldm ago</string> + </dict> + </dict> + <key>date.second.ago.abbr</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_second_ago_abbr@</string> + <key>count_second_ago_abbr</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1s ago</string> + <key>other</key> + <string>%lds ago</string> + </dict> + </dict> + </dict> +</plist> diff --git a/Mastodon/Resources/es-419.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/es-419.lproj/Localizable.strings similarity index 88% rename from Mastodon/Resources/es-419.lproj/Localizable.strings rename to MastodonSDK/Sources/MastodonLocalization/Resources/es-419.lproj/Localizable.strings index cf97fe803..d149865a6 100644 --- a/Mastodon/Resources/es-419.lproj/Localizable.strings +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/es-419.lproj/Localizable.strings @@ -4,7 +4,7 @@ "Common.Alerts.CleanCache.Title" = "Limpiar caché"; "Common.Alerts.Common.PleaseTryAgain" = "Por favor, intentá de nuevo."; "Common.Alerts.Common.PleaseTryAgainLater" = "Por favor, intentá de nuevo más tarde."; -"Common.Alerts.DeletePost.Delete" = "Eliminar"; +"Common.Alerts.DeletePost.Message" = "¿Estás seguro que querés eliminar este mensaje?"; "Common.Alerts.DeletePost.Title" = "¿Estás seguro que querés eliminar este mensaje?"; "Common.Alerts.DiscardPostContent.Message" = "Confirmá para descartar el contenido del mensaje redactado."; "Common.Alerts.DiscardPostContent.Title" = "Descartar borrador"; @@ -41,6 +41,7 @@ Por favor, revisá tu conexión a Internet."; "Common.Controls.Actions.Next" = "Siguiente"; "Common.Controls.Actions.Ok" = "Aceptar"; "Common.Controls.Actions.Open" = "Abrir"; +"Common.Controls.Actions.OpenInBrowser" = "Abrir en el navegador"; "Common.Controls.Actions.OpenInSafari" = "Abrir en Safari"; "Common.Controls.Actions.Preview" = "Previsualización"; "Common.Controls.Actions.Previous" = "Anterior"; @@ -93,6 +94,7 @@ Por favor, revisá tu conexión a Internet."; "Common.Controls.Keyboard.Timeline.ToggleFavorite" = "Cambiar la marca de favorito en el mensaje"; "Common.Controls.Keyboard.Timeline.ToggleReblog" = "Cambiar la adhesión en el mensaje"; "Common.Controls.Status.Actions.Favorite" = "Marcar como favorito"; +"Common.Controls.Status.Actions.Hide" = "Ocultar"; "Common.Controls.Status.Actions.Menu" = "Menú"; "Common.Controls.Status.Actions.Reblog" = "Adherir"; "Common.Controls.Status.Actions.Reply" = "Responder"; @@ -112,6 +114,10 @@ Por favor, revisá tu conexión a Internet."; "Common.Controls.Status.Tag.Url" = "Dirección web"; "Common.Controls.Status.UserReblogged" = "%@ adhirió"; "Common.Controls.Status.UserRepliedTo" = "Respondió a %@"; +"Common.Controls.Status.Visibility.Direct" = "Sólo el usuario mencionado puede ver este mensaje."; +"Common.Controls.Status.Visibility.Private" = "Sólo sus seguidores pueden ver este mensaje."; +"Common.Controls.Status.Visibility.PrivateFromMe" = "Sólo mis seguidores pueden ver este mensaje."; +"Common.Controls.Status.Visibility.Unlisted" = "Todo el mundo puede ver este mensaje pero no mostrarse en la línea temporal pública."; "Common.Controls.Tabs.Home" = "Principal"; "Common.Controls.Tabs.Notification" = "Notificación"; "Common.Controls.Tabs.Profile" = "Perfil"; @@ -178,8 +184,8 @@ y no se puede subir a Mastodon."; "Scene.Compose.Visibility.Private" = "Sólo para seguidores"; "Scene.Compose.Visibility.Public" = "Público"; "Scene.Compose.Visibility.Unlisted" = "No listado"; -"Scene.ConfirmEmail.Button.DontReceiveEmail" = "Nunca recibí un correo electrónico"; "Scene.ConfirmEmail.Button.OpenEmailApp" = "Abrir aplicación de correo electrónico"; +"Scene.ConfirmEmail.Button.Resend" = "Reenviar"; "Scene.ConfirmEmail.DontReceiveEmail.Description" = "Revisá si tu dirección de correo electrónico es correcta así como tu carpeta de correo basura / correo no deseado / spam, si todavía no lo hiciste."; "Scene.ConfirmEmail.DontReceiveEmail.ResendEmail" = "Reenviar correo electrónico"; "Scene.ConfirmEmail.DontReceiveEmail.Title" = "Revisá tu correo electrónico"; @@ -200,14 +206,14 @@ pulsá en el enlace para confirmar tu cuenta."; "Scene.HomeTimeline.Title" = "Principal"; "Scene.Notification.Keyobard.ShowEverything" = "Mostrar todo"; "Scene.Notification.Keyobard.ShowMentions" = "Mostrar menciones"; +"Scene.Notification.NotificationDescription.FavoritedYourPost" = "marcó como favorito tu mensaje"; +"Scene.Notification.NotificationDescription.FollowedYou" = "te sigue"; +"Scene.Notification.NotificationDescription.MentionedYou" = "te mencionó"; +"Scene.Notification.NotificationDescription.PollHasEnded" = "la encuesta terminó"; +"Scene.Notification.NotificationDescription.RebloggedYourPost" = "adhirió a tu mensaje"; +"Scene.Notification.NotificationDescription.RequestToFollowYou" = "solicitó seguirte"; "Scene.Notification.Title.Everything" = "Todo"; "Scene.Notification.Title.Mentions" = "Menciones"; -"Scene.Notification.UserFavorited Your Post" = "%@ marcó tu msj. como favorito"; -"Scene.Notification.UserFollowedYou" = "%@ te sigue"; -"Scene.Notification.UserMentionedYou" = "%@ te mencionó"; -"Scene.Notification.UserRebloggedYourPost" = "%@ adhirió a tu mensaje"; -"Scene.Notification.UserRequestedToFollowYou" = "%@ solicitó seguirte"; -"Scene.Notification.UserYourPollHasEnded" = "%@, tu encuesta finalizó"; "Scene.Preview.Keyboard.ClosePreview" = "Cerrar previsualización"; "Scene.Preview.Keyboard.ShowNext" = "Mostrar siguiente"; "Scene.Preview.Keyboard.ShowPrevious" = "Mostrar anterior"; @@ -217,12 +223,18 @@ pulsá en el enlace para confirmar tu cuenta."; "Scene.Profile.Fields.AddRow" = "Agregar fila"; "Scene.Profile.Fields.Placeholder.Content" = "Valor de campo"; "Scene.Profile.Fields.Placeholder.Label" = "Nombre de campo"; -"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Message" = "Confirmá para desbloquear a %@"; -"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Title" = "Desbloquear cuenta"; +"Scene.Profile.RelationshipActionAlert.ConfirmBlockUser.Message" = "Confirmá para desbloquear a %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmBlockUser.Title" = "Bloquear cuenta"; +"Scene.Profile.RelationshipActionAlert.ConfirmMuteUser.Message" = "Confirmá para silenciar a %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmMuteUser.Title" = "Silenciar cuenta"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUser.Message" = "Confirmá para desbloquear a %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUser.Title" = "Desbloquear cuenta"; "Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Message" = "Confirmá para dejar de silenciar a %@"; "Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Title" = "Dejar de silenciar cuenta"; +"Scene.Profile.SegmentedControl.About" = "Información"; "Scene.Profile.SegmentedControl.Media" = "Medios"; "Scene.Profile.SegmentedControl.Posts" = "Mensajes"; +"Scene.Profile.SegmentedControl.PostsAndReplies" = "Mensajes y respuestas"; "Scene.Profile.SegmentedControl.Replies" = "Respuestas"; "Scene.Register.Error.Item.Agreement" = "Acuerdo"; "Scene.Register.Error.Item.Email" = "Correo electrónico"; @@ -248,19 +260,26 @@ pulsá en el enlace para confirmar tu cuenta."; "Scene.Register.Input.DisplayName.Placeholder" = "nombre para mostrar"; "Scene.Register.Input.Email.Placeholder" = "correo electrónico"; "Scene.Register.Input.Invite.RegistrationUserInviteRequest" = "¿Por qué querés unirte?"; +"Scene.Register.Input.Password.Accessibility.Checked" = "marcado"; +"Scene.Register.Input.Password.Accessibility.Unchecked" = "sin marcar"; +"Scene.Register.Input.Password.CharacterLimit" = "8 caracteres"; "Scene.Register.Input.Password.Hint" = "Tu contraseña necesita al menos ocho caracteres"; "Scene.Register.Input.Password.Placeholder" = "contraseña"; +"Scene.Register.Input.Password.Require" = "Tu contraseña necesita al menos:"; "Scene.Register.Input.Username.DuplicatePrompt" = "Este nombre de usuario ya está en uso."; "Scene.Register.Input.Username.Placeholder" = "nombre de usuario"; "Scene.Register.Title" = "Contanos sobre vos."; "Scene.Report.Content1" = "¿Hay otros mensajes que te gustaría agregar a la denuncia?"; "Scene.Report.Content2" = "¿Hay algo que los moderadores deban saber sobre esta denuncia?"; +"Scene.Report.ReportSentTitle" = "Gracias por tu denuncia, vamos a revisarla."; +"Scene.Report.Reported" = "DENUNCIADA"; "Scene.Report.Send" = "Enviar denuncia"; "Scene.Report.SkipToSend" = "Enviar sin comentarios"; "Scene.Report.Step1" = "Paso 1 de 2"; "Scene.Report.Step2" = "Paso 2 de 2"; "Scene.Report.TextPlaceholder" = "Escribí o pegá comentarios adicionales"; "Scene.Report.Title" = "Denunciar a %@"; +"Scene.Report.TitleReport" = "Denunciar"; "Scene.Search.Recommend.Accounts.Description" = "Puede que te guste seguir estas cuentas"; "Scene.Search.Recommend.Accounts.Follow" = "Seguir"; "Scene.Search.Recommend.Accounts.Title" = "Cuentas que te pueden gustar"; @@ -301,6 +320,8 @@ pulsá en el enlace para confirmar tu cuenta."; "Scene.ServerPicker.Label.Category" = "CATEGORÍA"; "Scene.ServerPicker.Label.Language" = "IDIOMA"; "Scene.ServerPicker.Label.Users" = "CUENTAS"; +"Scene.ServerPicker.Subtitle" = "Elegí una comunidad basada en tus intereses, región o una de propósitos generales."; +"Scene.ServerPicker.SubtitleExtend" = "Elegí una comunidad basada en tus intereses, región o una de propósitos generales. Cada comunidad es operada por una organización o individuo totalmente independiente."; "Scene.ServerPicker.Title" = "Elegí un servidor, el que quieras."; "Scene.ServerRules.Button.Confirm" = "Estoy de acuerdo"; @@ -319,6 +340,11 @@ el que quieras."; "Scene.Settings.Section.BoringZone.Privacy" = "Política de privacidad"; "Scene.Settings.Section.BoringZone.Terms" = "Términos del servicio"; "Scene.Settings.Section.BoringZone.Title" = "La zona aburrida"; +"Scene.Settings.Section.LookAndFeel.Light" = "Claro"; +"Scene.Settings.Section.LookAndFeel.ReallyDark" = "Oscuro de verdad"; +"Scene.Settings.Section.LookAndFeel.SortaDark" = "Algo oscuro"; +"Scene.Settings.Section.LookAndFeel.Title" = "Apariencia"; +"Scene.Settings.Section.LookAndFeel.UseSystem" = "Usar sistema"; "Scene.Settings.Section.Notifications.Boosts" = "Adhirió a mi mensaje"; "Scene.Settings.Section.Notifications.Favorites" = "Marcó como favorito mi mensaje"; "Scene.Settings.Section.Notifications.Follows" = "Me sigue"; @@ -342,6 +368,8 @@ el que quieras."; "Scene.SuggestionAccount.Title" = "Encontrá cuentas para seguir"; "Scene.Thread.BackTitle" = "Mensaje"; "Scene.Thread.Title" = "Mensaje de %@"; +"Scene.Welcome.GetStarted" = "Comenzá"; +"Scene.Welcome.LogIn" = "Iniciar sesión"; "Scene.Welcome.Slogan" = "La red social, nuevamente en tu poder."; "Scene.Wizard.AccessibilityHint" = "Tocá dos veces para descartar este asistente"; diff --git a/Mastodon/Resources/es-419.lproj/Localizable.stringsdict b/MastodonSDK/Sources/MastodonLocalization/Resources/es-419.lproj/Localizable.stringsdict similarity index 100% rename from Mastodon/Resources/es-419.lproj/Localizable.stringsdict rename to MastodonSDK/Sources/MastodonLocalization/Resources/es-419.lproj/Localizable.stringsdict diff --git a/Mastodon/Resources/es.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/es.lproj/Localizable.strings similarity index 85% rename from Mastodon/Resources/es.lproj/Localizable.strings rename to MastodonSDK/Sources/MastodonLocalization/Resources/es.lproj/Localizable.strings index dcf12dfe2..09814c91e 100644 --- a/Mastodon/Resources/es.lproj/Localizable.strings +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/es.lproj/Localizable.strings @@ -4,7 +4,7 @@ "Common.Alerts.CleanCache.Title" = "Limpiar Caché"; "Common.Alerts.Common.PleaseTryAgain" = "Por favor, vuelve a intentarlo."; "Common.Alerts.Common.PleaseTryAgainLater" = "Por favor, vuelve a intentarlo más tarde."; -"Common.Alerts.DeletePost.Delete" = "Eliminar"; +"Common.Alerts.DeletePost.Message" = "¿Estás seguro de que quieres borrar esta publicación?"; "Common.Alerts.DeletePost.Title" = "¿Estás seguro de que deseas eliminar esta publicación?"; "Common.Alerts.DiscardPostContent.Message" = "Confirma para descartar el contenido de la publicación."; "Common.Alerts.DiscardPostContent.Title" = "Descartar borrador"; @@ -41,6 +41,7 @@ Por favor, revise su conexión a internet."; "Common.Controls.Actions.Next" = "Siguiente"; "Common.Controls.Actions.Ok" = "Aceptar"; "Common.Controls.Actions.Open" = "Abrir"; +"Common.Controls.Actions.OpenInBrowser" = "Abrir en el navegador"; "Common.Controls.Actions.OpenInSafari" = "Abrir en Safari"; "Common.Controls.Actions.Preview" = "Vista previa"; "Common.Controls.Actions.Previous" = "Anterior"; @@ -93,6 +94,7 @@ Por favor, revise su conexión a internet."; "Common.Controls.Keyboard.Timeline.ToggleFavorite" = "Conmutar la Marca de Favorito en la Publicación"; "Common.Controls.Keyboard.Timeline.ToggleReblog" = "Conmutar el Reblogueo en la Publicación"; "Common.Controls.Status.Actions.Favorite" = "Favorito"; +"Common.Controls.Status.Actions.Hide" = "Ocultar"; "Common.Controls.Status.Actions.Menu" = "Menú"; "Common.Controls.Status.Actions.Reblog" = "Rebloguear"; "Common.Controls.Status.Actions.Reply" = "Responder"; @@ -112,6 +114,10 @@ Por favor, revise su conexión a internet."; "Common.Controls.Status.Tag.Url" = "URL"; "Common.Controls.Status.UserReblogged" = "%@ lo reblogueó"; "Common.Controls.Status.UserRepliedTo" = "En respuesta a %@"; +"Common.Controls.Status.Visibility.Direct" = "Sólo el usuario mencionado puede ver este mensaje."; +"Common.Controls.Status.Visibility.Private" = "Sólo sus seguidores pueden ver este mensaje."; +"Common.Controls.Status.Visibility.PrivateFromMe" = "Sólo mis seguidores pueden ver este mensaje."; +"Common.Controls.Status.Visibility.Unlisted" = "Todo el mundo puede ver este post pero no mostrar en la línea de tiempo pública."; "Common.Controls.Tabs.Home" = "Inicio"; "Common.Controls.Tabs.Notification" = "Notificación"; "Common.Controls.Tabs.Profile" = "Perfil"; @@ -134,9 +140,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.AccountList.AddAccount" = "Añadir cuenta"; +"Scene.AccountList.DismissAccountSwitcher" = "Descartar el selector de cuentas"; +"Scene.AccountList.TabBarHint" = "Perfil seleccionado actualmente: %@. Haz un doble toque y mantén pulsado para mostrar el selector de cuentas"; "Scene.Compose.Accessibility.AppendAttachment" = "Añadir Adjunto"; "Scene.Compose.Accessibility.AppendPoll" = "Añadir Encuesta"; "Scene.Compose.Accessibility.CustomEmojiPicker" = "Selector de Emojis Personalizados"; @@ -178,8 +184,8 @@ subirse a Mastodon."; "Scene.Compose.Visibility.Private" = "Solo seguidores"; "Scene.Compose.Visibility.Public" = "Pública"; "Scene.Compose.Visibility.Unlisted" = "Sin listar"; -"Scene.ConfirmEmail.Button.DontReceiveEmail" = "No he recibido el correo electrónico"; "Scene.ConfirmEmail.Button.OpenEmailApp" = "Abrir Aplicación de Correo Electrónico"; +"Scene.ConfirmEmail.Button.Resend" = "Reenviar"; "Scene.ConfirmEmail.DontReceiveEmail.Description" = "Comprueba que tu dirección de correo electrónico sea correcta y revisa la carpeta de correo no deseado si no lo has hecho ya."; "Scene.ConfirmEmail.DontReceiveEmail.ResendEmail" = "Volver a Enviar Correo Electrónico"; "Scene.ConfirmEmail.DontReceiveEmail.Title" = "Revisa tu correo electrónico"; @@ -200,14 +206,14 @@ pulsa en el enlace para confirmar tu cuenta."; "Scene.HomeTimeline.Title" = "Inicio"; "Scene.Notification.Keyobard.ShowEverything" = "Mostrar Todo"; "Scene.Notification.Keyobard.ShowMentions" = "Mostrar Menciones"; +"Scene.Notification.NotificationDescription.FavoritedYourPost" = "ha marcado como favorita tu publicación"; +"Scene.Notification.NotificationDescription.FollowedYou" = "te siguió"; +"Scene.Notification.NotificationDescription.MentionedYou" = "te mencionó"; +"Scene.Notification.NotificationDescription.PollHasEnded" = "encuesta ha terminado"; +"Scene.Notification.NotificationDescription.RebloggedYourPost" = "reblogueó tu publicación"; +"Scene.Notification.NotificationDescription.RequestToFollowYou" = "solicitó seguirte"; "Scene.Notification.Title.Everything" = "Todo"; "Scene.Notification.Title.Mentions" = "Menciones"; -"Scene.Notification.UserFavorited Your Post" = "%@ marcó tu post como favorito"; -"Scene.Notification.UserFollowedYou" = "%@ te ha empezado a seguir"; -"Scene.Notification.UserMentionedYou" = "%@ te ha mencionado"; -"Scene.Notification.UserRebloggedYourPost" = "%@ reblogueó tu publicación"; -"Scene.Notification.UserRequestedToFollowYou" = "%@ ha solicitado seguirte"; -"Scene.Notification.UserYourPollHasEnded" = "%@ Tu encuesta ha terminado"; "Scene.Preview.Keyboard.ClosePreview" = "Cerrar Previsualización"; "Scene.Preview.Keyboard.ShowNext" = "Mostrar Siguiente"; "Scene.Preview.Keyboard.ShowPrevious" = "Mostrar Anterior"; @@ -217,12 +223,18 @@ pulsa en el enlace para confirmar tu cuenta."; "Scene.Profile.Fields.AddRow" = "Añadir Fila"; "Scene.Profile.Fields.Placeholder.Content" = "Contenido"; "Scene.Profile.Fields.Placeholder.Label" = "Nombre para el campo"; -"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Message" = "Confirmar para desbloquear a %@"; -"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Title" = "Desbloquear Cuenta"; +"Scene.Profile.RelationshipActionAlert.ConfirmBlockUser.Message" = "Confirmar para bloquear a %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmBlockUser.Title" = "Bloquear cuenta"; +"Scene.Profile.RelationshipActionAlert.ConfirmMuteUser.Message" = "Confirmar para silenciar %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmMuteUser.Title" = "Silenciar cuenta"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUser.Message" = "Confirmar para desbloquear a %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUser.Title" = "Desbloquear cuenta"; "Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Message" = "Confirmar para dejar de silenciar a %@"; "Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Title" = "Dejar de Silenciar Cuenta"; +"Scene.Profile.SegmentedControl.About" = "Acerca de"; "Scene.Profile.SegmentedControl.Media" = "Multimedia"; "Scene.Profile.SegmentedControl.Posts" = "Publicaciones"; +"Scene.Profile.SegmentedControl.PostsAndReplies" = "Publicaciones y respuestas"; "Scene.Profile.SegmentedControl.Replies" = "Respuestas"; "Scene.Register.Error.Item.Agreement" = "Aceptación"; "Scene.Register.Error.Item.Email" = "Correo electrónico"; @@ -248,19 +260,26 @@ pulsa en el enlace para confirmar tu cuenta."; "Scene.Register.Input.DisplayName.Placeholder" = "nombre a mostrar"; "Scene.Register.Input.Email.Placeholder" = "correo electrónico"; "Scene.Register.Input.Invite.RegistrationUserInviteRequest" = "¿Por qué quieres unirte?"; +"Scene.Register.Input.Password.Accessibility.Checked" = "marcado"; +"Scene.Register.Input.Password.Accessibility.Unchecked" = "sin marcar"; +"Scene.Register.Input.Password.CharacterLimit" = "8 caracteres"; "Scene.Register.Input.Password.Hint" = "Tu contraseña necesita tener al menos ocho caracteres"; "Scene.Register.Input.Password.Placeholder" = "contraseña"; +"Scene.Register.Input.Password.Require" = "Tu contraseña debe contener como mínimo:"; "Scene.Register.Input.Username.DuplicatePrompt" = "Este nombre de usuario ya está en uso."; "Scene.Register.Input.Username.Placeholder" = "nombre de usuario"; "Scene.Register.Title" = "Háblanos de ti."; "Scene.Report.Content1" = "¿Hay alguna otra publicación que te gustaría añadir al reporte?"; "Scene.Report.Content2" = "¿Hay algo que los moderadores deberían saber acerca de este reporte?"; +"Scene.Report.ReportSentTitle" = "Gracias por reportar, estudiaremos esto."; +"Scene.Report.Reported" = "REPORTADO"; "Scene.Report.Send" = "Enviar Reporte"; "Scene.Report.SkipToSend" = "Enviar sin comentarios"; "Scene.Report.Step1" = "Paso 1 de 2"; "Scene.Report.Step2" = "Paso 2 de 2"; "Scene.Report.TextPlaceholder" = "Escribe o pega comentarios adicionales"; "Scene.Report.Title" = "Reportar %@"; +"Scene.Report.TitleReport" = "Reportar"; "Scene.Search.Recommend.Accounts.Description" = "Puede que guste seguir estas cuentas"; "Scene.Search.Recommend.Accounts.Follow" = "Seguir"; "Scene.Search.Recommend.Accounts.Title" = "Cuentas que quizá quieras seguir"; @@ -301,6 +320,8 @@ pulsa en el enlace para confirmar tu cuenta."; "Scene.ServerPicker.Label.Category" = "CATEGORÍA"; "Scene.ServerPicker.Label.Language" = "IDIOMA"; "Scene.ServerPicker.Label.Users" = "USUARIOS"; +"Scene.ServerPicker.Subtitle" = "Elige una comunidad relacionada con tus intereses, con tu región o una más genérica."; +"Scene.ServerPicker.SubtitleExtend" = "Elige una comunidad relacionada con tus intereses, con tu región o una más genérica. Cada comunidad está operada por una organización o individuo completamente independiente."; "Scene.ServerPicker.Title" = "Elige un servidor, cualquier servidor."; "Scene.ServerRules.Button.Confirm" = "Acepto"; @@ -319,6 +340,11 @@ cualquier servidor."; "Scene.Settings.Section.BoringZone.Privacy" = "Política de Privacidad"; "Scene.Settings.Section.BoringZone.Terms" = "Términos de Servicio"; "Scene.Settings.Section.BoringZone.Title" = "La Zona Aburrida"; +"Scene.Settings.Section.LookAndFeel.Light" = "Claro"; +"Scene.Settings.Section.LookAndFeel.ReallyDark" = "Realmente Oscuro"; +"Scene.Settings.Section.LookAndFeel.SortaDark" = "Más o Menos Oscuro"; +"Scene.Settings.Section.LookAndFeel.Title" = "Apariencia"; +"Scene.Settings.Section.LookAndFeel.UseSystem" = "Uso del sistema"; "Scene.Settings.Section.Notifications.Boosts" = "Rebloguee mi publicación"; "Scene.Settings.Section.Notifications.Favorites" = "Marque como favorita mi publicación"; "Scene.Settings.Section.Notifications.Follows" = "Me siga"; @@ -342,8 +368,10 @@ cualquier servidor."; "Scene.SuggestionAccount.Title" = "Encuentra Gente a la que Seguir"; "Scene.Thread.BackTitle" = "Publicación"; "Scene.Thread.Title" = "Publicación de %@"; +"Scene.Welcome.GetStarted" = "Empezar"; +"Scene.Welcome.LogIn" = "Iniciar sesión"; "Scene.Welcome.Slogan" = "Las redes sociales 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 +"Scene.Wizard.AccessibilityHint" = "Haz doble toque para descartar este asistente"; +"Scene.Wizard.MultipleAccountSwitchIntroDescription" = "Cambie entre varias cuentas manteniendo presionado el botón de perfil."; +"Scene.Wizard.NewInMastodon" = "Nuevo en Mastodon"; \ No newline at end of file diff --git a/Mastodon/Resources/es.lproj/Localizable.stringsdict b/MastodonSDK/Sources/MastodonLocalization/Resources/es.lproj/Localizable.stringsdict similarity index 99% rename from Mastodon/Resources/es.lproj/Localizable.stringsdict rename to MastodonSDK/Sources/MastodonLocalization/Resources/es.lproj/Localizable.stringsdict index d31d8825b..186218af6 100644 --- a/Mastodon/Resources/es.lproj/Localizable.stringsdict +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/es.lproj/Localizable.stringsdict @@ -13,9 +13,9 @@ <key>NSStringFormatValueTypeKey</key> <string>ld</string> <key>one</key> - <string>1 unread notification</string> + <string>1 notificación no leída</string> <key>other</key> - <string>%ld unread notification</string> + <string>%ld notificaciones no leídas</string> </dict> </dict> <key>a11y.plural.count.input_limit_exceeds</key> diff --git a/MastodonSDK/Sources/MastodonLocalization/Resources/eu-ES.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/eu-ES.lproj/Localizable.strings new file mode 100644 index 000000000..7feec0a73 --- /dev/null +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/eu-ES.lproj/Localizable.strings @@ -0,0 +1,377 @@ +"Common.Alerts.BlockDomain.BlockEntireDomain" = "Blokeatu domeinua"; +"Common.Alerts.BlockDomain.Title" = "Ziur, erabat ziur, %@ domeinu osoa blokeatu nahi duzula? Gehienetan erabiltzaile gutxi batzuk blokeatu edo mututzearekin nahikoa da. Ez duzu domeinu horretako edukirik ikusiko eta domeinu horretako zure jarraitzaileak kenduko dira."; +"Common.Alerts.CleanCache.Message" = "Behar bezala garbitu da %@ cache-a."; +"Common.Alerts.CleanCache.Title" = "Garbitu cache-a"; +"Common.Alerts.Common.PleaseTryAgain" = "Mesedez, saiatu berriro."; +"Common.Alerts.Common.PleaseTryAgainLater" = "Mesedez beranduago saiatu."; +"Common.Alerts.DeletePost.Message" = "Ziur bidalketa hau ezabatu nahi duzula?"; +"Common.Alerts.DeletePost.Title" = "Ziur zaude bidalketa hau ezabatu nahi duzula?"; +"Common.Alerts.DiscardPostContent.Message" = "Berretsi idatzitako bidalketaren edukia baztertzea."; +"Common.Alerts.DiscardPostContent.Title" = "Baztertu zirriborroa"; +"Common.Alerts.EditProfileFailure.Message" = "Ezin da profila editatu. Mesedez saiatu berriro."; +"Common.Alerts.EditProfileFailure.Title" = "Errorea profila editatzean"; +"Common.Alerts.PublishPostFailure.AttachmentsMessage.MoreThanOneVideo" = "Ezin da bideo bat baino gehiago erantsi."; +"Common.Alerts.PublishPostFailure.AttachmentsMessage.VideoAttachWithPhoto" = "Ezin da irudiak dituen bidalketa batean bideo bat erantsi."; +"Common.Alerts.PublishPostFailure.Message" = "Huts egin du bidalketa argitaratzean. +Egiaztatu Interneteko konexioa."; +"Common.Alerts.PublishPostFailure.Title" = "Hutsegitea argitaratzean"; +"Common.Alerts.SavePhotoFailure.Message" = "Gaitu argazki galeriarako sarbidearen baimena argazkia gordetzeko."; +"Common.Alerts.SavePhotoFailure.Title" = "Hutsegitea argazkia gordetzean"; +"Common.Alerts.ServerError.Title" = "Zerbitzari-errorea"; +"Common.Alerts.SignOut.Confirm" = "Amaitu saioa"; +"Common.Alerts.SignOut.Message" = "Ziur saioa amaitu nahi duzula?"; +"Common.Alerts.SignOut.Title" = "Amaitu saioa"; +"Common.Alerts.SignUpFailure.Title" = "Hutsegitea izen-ematean"; +"Common.Alerts.VoteFailure.PollEnded" = "Inkesta amaitu da"; +"Common.Alerts.VoteFailure.Title" = "Hutsegitea botoa ematean"; +"Common.Controls.Actions.Add" = "Gehitu"; +"Common.Controls.Actions.Back" = "Atzera"; +"Common.Controls.Actions.BlockDomain" = "Blokeatu %@"; +"Common.Controls.Actions.Cancel" = "Utzi"; +"Common.Controls.Actions.Compose" = "Idatzi"; +"Common.Controls.Actions.Confirm" = "Berretsi"; +"Common.Controls.Actions.Continue" = "Jarraitu"; +"Common.Controls.Actions.CopyPhoto" = "Kopiatu argazkia"; +"Common.Controls.Actions.Delete" = "Ezabatu"; +"Common.Controls.Actions.Discard" = "Baztertu"; +"Common.Controls.Actions.Done" = "Egina"; +"Common.Controls.Actions.Edit" = "Editatu"; +"Common.Controls.Actions.FindPeople" = "Bilatu jarraitzeko jendea"; +"Common.Controls.Actions.ManuallySearch" = "Eskuz bilatu"; +"Common.Controls.Actions.Next" = "Hurrengoa"; +"Common.Controls.Actions.Ok" = "Ados"; +"Common.Controls.Actions.Open" = "Ireki"; +"Common.Controls.Actions.OpenInBrowser" = "Ireki nabigatzailean"; +"Common.Controls.Actions.OpenInSafari" = "Ireki Safarin"; +"Common.Controls.Actions.Preview" = "Aurrebista"; +"Common.Controls.Actions.Previous" = "Aurrekoa"; +"Common.Controls.Actions.Remove" = "Kendu"; +"Common.Controls.Actions.Reply" = "Erantzun"; +"Common.Controls.Actions.ReportUser" = "Salatu %@"; +"Common.Controls.Actions.Save" = "Gorde"; +"Common.Controls.Actions.SavePhoto" = "Gorde argazkia"; +"Common.Controls.Actions.SeeMore" = "Ikusi gehiago"; +"Common.Controls.Actions.Settings" = "Ezarpenak"; +"Common.Controls.Actions.Share" = "Partekatu"; +"Common.Controls.Actions.SharePost" = "Partekatu bidalketa"; +"Common.Controls.Actions.ShareUser" = "Partekatu %@"; +"Common.Controls.Actions.SignIn" = "Hasi saioa"; +"Common.Controls.Actions.SignUp" = "Eman Izena"; +"Common.Controls.Actions.Skip" = "Saltatu"; +"Common.Controls.Actions.TakePhoto" = "Atera argazkia"; +"Common.Controls.Actions.TryAgain" = "Saiatu berriro"; +"Common.Controls.Actions.UnblockDomain" = "Desblokeatu %@"; +"Common.Controls.Friendship.Block" = "Blokeatu"; +"Common.Controls.Friendship.BlockDomain" = "Blokeatu %@"; +"Common.Controls.Friendship.BlockUser" = "Blokeatu %@"; +"Common.Controls.Friendship.Blocked" = "Blokeatuta"; +"Common.Controls.Friendship.EditInfo" = "Editatu informazioa"; +"Common.Controls.Friendship.Follow" = "Jarraitu"; +"Common.Controls.Friendship.Following" = "Jarraitzen"; +"Common.Controls.Friendship.Mute" = "Mututu"; +"Common.Controls.Friendship.MuteUser" = "Mututu %@"; +"Common.Controls.Friendship.Muted" = "Mutututa"; +"Common.Controls.Friendship.Pending" = "Zain"; +"Common.Controls.Friendship.Request" = "Eskaera"; +"Common.Controls.Friendship.Unblock" = "Desblokeatu"; +"Common.Controls.Friendship.UnblockUser" = "Desblokeatu %@"; +"Common.Controls.Friendship.Unmute" = "Desmututu"; +"Common.Controls.Friendship.UnmuteUser" = "Desmututu %@"; +"Common.Controls.Keyboard.Common.ComposeNewPost" = "Idatzi bidalketa berria"; +"Common.Controls.Keyboard.Common.OpenSettings" = "Ireki ezarpenak"; +"Common.Controls.Keyboard.Common.ShowFavorites" = "Erakutsi gogokoak"; +"Common.Controls.Keyboard.Common.SwitchToTab" = "Aldatu %@(e)ra"; +"Common.Controls.Keyboard.SegmentedControl.NextSection" = "Hurrengo sekzioa"; +"Common.Controls.Keyboard.SegmentedControl.PreviousSection" = "Aurreko sekzioa"; +"Common.Controls.Keyboard.Timeline.NextStatus" = "Hurrengo bidalketa"; +"Common.Controls.Keyboard.Timeline.OpenAuthorProfile" = "Ireki egilearen profila"; +"Common.Controls.Keyboard.Timeline.OpenRebloggerProfile" = "Ireki bultzada eman duenaren profila"; +"Common.Controls.Keyboard.Timeline.OpenStatus" = "Ireki bidalketa"; +"Common.Controls.Keyboard.Timeline.PreviewImage" = "Aurreikusi irudia"; +"Common.Controls.Keyboard.Timeline.PreviousStatus" = "Aurreko bidalketa"; +"Common.Controls.Keyboard.Timeline.ReplyStatus" = "Erantzun bidalketari"; +"Common.Controls.Keyboard.Timeline.ToggleContentWarning" = "Txandakatu edukiaren abisua"; +"Common.Controls.Keyboard.Timeline.ToggleFavorite" = "Txandakatu bidalketa gogoko egitea"; +"Common.Controls.Keyboard.Timeline.ToggleReblog" = "Txandakatu bidalketaren bultzada"; +"Common.Controls.Status.Actions.Favorite" = "Gogokoa"; +"Common.Controls.Status.Actions.Hide" = "Ezkutatu"; +"Common.Controls.Status.Actions.Menu" = "Menua"; +"Common.Controls.Status.Actions.Reblog" = "Bultzada"; +"Common.Controls.Status.Actions.Reply" = "Erantzun"; +"Common.Controls.Status.Actions.Unfavorite" = "Kendu gogokoa"; +"Common.Controls.Status.Actions.Unreblog" = "Desegin bultzada"; +"Common.Controls.Status.ContentWarning" = "Edukiaren abisua"; +"Common.Controls.Status.MediaContentWarning" = "Ukitu edonon bistaratzeko"; +"Common.Controls.Status.Poll.Closed" = "Itxita"; +"Common.Controls.Status.Poll.Vote" = "Bozkatu"; +"Common.Controls.Status.ShowPost" = "Erakutsi bidalketa"; +"Common.Controls.Status.ShowUserProfile" = "Erakutsi erabiltzailearen profila"; +"Common.Controls.Status.Tag.Email" = "Eposta"; +"Common.Controls.Status.Tag.Emoji" = "Emojia"; +"Common.Controls.Status.Tag.Hashtag" = "Traola"; +"Common.Controls.Status.Tag.Link" = "Esteka"; +"Common.Controls.Status.Tag.Mention" = "Aipatu"; +"Common.Controls.Status.Tag.Url" = "URLa"; +"Common.Controls.Status.UserReblogged" = "%@ erabiltzaileak bultzada eman dio"; +"Common.Controls.Status.UserRepliedTo" = "%@(r)i erantzuten"; +"Common.Controls.Status.Visibility.Direct" = "Aipatutako erabiltzaileek soilik ikus dezakete bidalketa hau."; +"Common.Controls.Status.Visibility.Private" = "Beren jarraitzaileek soilik ikus dezakete bidalketa hau."; +"Common.Controls.Status.Visibility.PrivateFromMe" = "Nire jarraitzaileek soilik ikus dezakete bidalketa hau."; +"Common.Controls.Status.Visibility.Unlisted" = "Edozeinek ikusi dezake bidalketa hau baina ez da denbora-lerro publikoan bistaratuko."; +"Common.Controls.Tabs.Home" = "Hasiera"; +"Common.Controls.Tabs.Notification" = "Jakinarazpena"; +"Common.Controls.Tabs.Profile" = "Profila"; +"Common.Controls.Tabs.Search" = "Bilatu"; +"Common.Controls.Timeline.Filtered" = "Iragazita"; +"Common.Controls.Timeline.Header.BlockedWarning" = "Ezin duzu erabiltzaile honen profila ikusi +desblokeatzen zaituen arte."; +"Common.Controls.Timeline.Header.BlockingWarning" = "Ezin duzu erabiltzaile honen profila ikusi +desblokeatzen duzun arte. +Zure profilak itxura hau du berarentzat."; +"Common.Controls.Timeline.Header.NoStatusFound" = "Ez da bidalketa aurkitu"; +"Common.Controls.Timeline.Header.SuspendedWarning" = "Erabiltzaile hau kanporatua izan da."; +"Common.Controls.Timeline.Header.UserBlockedWarning" = "Ezin duzu %@ erabiltzailearen +profila ikusi desblokeatzen zaituen arte."; +"Common.Controls.Timeline.Header.UserBlockingWarning" = "Ezin duzu %@ erabiltzailearen +profila ikusi desblokeatzen duzun arte. +Zure profilak itxura hau du berarentzat."; +"Common.Controls.Timeline.Header.UserSuspendedWarning" = "%@ kontua kanporatua izan da."; +"Common.Controls.Timeline.Loader.LoadMissingPosts" = "Kargatu falta diren bidalketak"; +"Common.Controls.Timeline.Loader.LoadingMissingPosts" = "Falta diren bidalketak kargatzen..."; +"Common.Controls.Timeline.Loader.ShowMoreReplies" = "Erakutsi erantzun gehiago"; +"Common.Controls.Timeline.Timestamp.Now" = "Orain"; +"Scene.AccountList.AddAccount" = "Gehitu kontua"; +"Scene.AccountList.DismissAccountSwitcher" = "Baztertu kontu-aldatzailea"; +"Scene.AccountList.TabBarHint" = "Unean hautatutako profila: %@. Ukitu birritan, ondoren eduki sakatuta kontu-aldatzailea erakusteko"; +"Scene.Compose.Accessibility.AppendAttachment" = "Gehitu eranskina"; +"Scene.Compose.Accessibility.AppendPoll" = "Gehitu inkesta"; +"Scene.Compose.Accessibility.CustomEmojiPicker" = "Emoji pertsonalizatuen hautatzailea"; +"Scene.Compose.Accessibility.DisableContentWarning" = "Desgaitu edukiaren abisua"; +"Scene.Compose.Accessibility.EnableContentWarning" = "Gaitu edukiaren abisua"; +"Scene.Compose.Accessibility.PostVisibilityMenu" = "Bidalketaren ikusgaitasunaren menua"; +"Scene.Compose.Accessibility.RemovePoll" = "Kendu inkesta"; +"Scene.Compose.Attachment.AttachmentBroken" = "%@ hondatuta dago eta ezin da +Mastodonera igo."; +"Scene.Compose.Attachment.DescriptionPhoto" = "Deskribatu argazkia ikusmen arazoak dituztenentzat..."; +"Scene.Compose.Attachment.DescriptionVideo" = "Deskribatu bideoa ikusmen arazoak dituztenentzat..."; +"Scene.Compose.Attachment.Photo" = "argazkia"; +"Scene.Compose.Attachment.Video" = "bideoa"; +"Scene.Compose.AutoComplete.SpaceToAdd" = "Sakatu zuriunea gehitzeko"; +"Scene.Compose.ComposeAction" = "Argitaratu"; +"Scene.Compose.ContentInputPlaceholder" = "Idatzi edo itsatsi buruan duzuna"; +"Scene.Compose.ContentWarning.Placeholder" = "Idatzi abisu zehatz bat hemen..."; +"Scene.Compose.Keyboard.AppendAttachmentEntry" = "Gehitu eranskina - %@"; +"Scene.Compose.Keyboard.DiscardPost" = "Baztertu bidalketa"; +"Scene.Compose.Keyboard.PublishPost" = "Argitaratu bidalketa"; +"Scene.Compose.Keyboard.SelectVisibilityEntry" = "Hautatu ikusgaitasuna - %@"; +"Scene.Compose.Keyboard.ToggleContentWarning" = "Txandakatu edukiaren abisua"; +"Scene.Compose.Keyboard.TogglePoll" = "Txandakatu inkesta"; +"Scene.Compose.MediaSelection.Browse" = "Arakatu"; +"Scene.Compose.MediaSelection.Camera" = "Atera argazkia"; +"Scene.Compose.MediaSelection.PhotoLibrary" = "Argazki-liburutegia"; +"Scene.Compose.Poll.DurationTime" = "Iraupena: %@"; +"Scene.Compose.Poll.OneDay" = "Egun 1"; +"Scene.Compose.Poll.OneHour" = "Ordu 1"; +"Scene.Compose.Poll.OptionNumber" = "%ld aukera"; +"Scene.Compose.Poll.SevenDays" = "7 egun"; +"Scene.Compose.Poll.SixHours" = "6 ordu"; +"Scene.Compose.Poll.ThirtyMinutes" = "30 minutu"; +"Scene.Compose.Poll.ThreeDays" = "3 egun"; +"Scene.Compose.ReplyingToUser" = "%@(r)i erantzuten"; +"Scene.Compose.Title.NewPost" = "Bidalketa berria"; +"Scene.Compose.Title.NewReply" = "Erantzun berria"; +"Scene.Compose.Visibility.Direct" = "Aipatzen dudan jendea soilik"; +"Scene.Compose.Visibility.Private" = "Jarraitzaileak soilik"; +"Scene.Compose.Visibility.Public" = "Publikoa"; +"Scene.Compose.Visibility.Unlisted" = "Zerrendatu gabea"; +"Scene.ConfirmEmail.Button.OpenEmailApp" = "Ireki eposta aplikazioa"; +"Scene.ConfirmEmail.Button.Resend" = "Berbidali"; +"Scene.ConfirmEmail.DontReceiveEmail.Description" = "Egiaztatu zure eposta helbidea zuzena den eta begiratu zaborraren karpeta."; +"Scene.ConfirmEmail.DontReceiveEmail.ResendEmail" = "Birbidali eposta"; +"Scene.ConfirmEmail.DontReceiveEmail.Title" = "Begiratu zure eposta"; +"Scene.ConfirmEmail.OpenEmailApp.Description" = "Eposta bat bidali dizugu. Egiaztatu zure zaborraren karpeta."; +"Scene.ConfirmEmail.OpenEmailApp.Mail" = "Posta"; +"Scene.ConfirmEmail.OpenEmailApp.OpenEmailClient" = "Ireki eposta bezeroa"; +"Scene.ConfirmEmail.OpenEmailApp.Title" = "Egiaztatu zure sarrerako ontzia."; +"Scene.ConfirmEmail.Subtitle" = "Eposta bat bidali dizugu %@ helbidera, +sakatu kontua berresteko esteka."; +"Scene.ConfirmEmail.Title" = "Eta azkenik..."; +"Scene.Favorite.Title" = "Zure gogokoak"; +"Scene.Follower.Footer" = "Beste zerbitzarietako jarraitzaileak ez dira bistaratzen."; +"Scene.Following.Footer" = "Beste zerbitzarietan jarraitutakoak ez dira bistaratzen."; +"Scene.HomeTimeline.NavigationBarState.NewPosts" = "Ikusi bidal. berriak"; +"Scene.HomeTimeline.NavigationBarState.Offline" = "Konexio gabe"; +"Scene.HomeTimeline.NavigationBarState.Published" = "Argitaratua!"; +"Scene.HomeTimeline.NavigationBarState.Publishing" = "Bidalketa argitaratzen..."; +"Scene.HomeTimeline.Title" = "Hasiera"; +"Scene.Notification.Keyobard.ShowEverything" = "Erakutsi guztia"; +"Scene.Notification.Keyobard.ShowMentions" = "Erakutsi aipamenak"; +"Scene.Notification.NotificationDescription.FavoritedYourPost" = "erabiltzaileak zure bidalketa gogoko du"; +"Scene.Notification.NotificationDescription.FollowedYou" = "zu jarraitzen hasi da"; +"Scene.Notification.NotificationDescription.MentionedYou" = "erabiltzaileak aipatu zaitu"; +"Scene.Notification.NotificationDescription.PollHasEnded" = "inkesta amaitu da"; +"Scene.Notification.NotificationDescription.RebloggedYourPost" = "erabiltzaileak bultzada eman dio zure bidalketari"; +"Scene.Notification.NotificationDescription.RequestToFollowYou" = "erabiltzaileak zu jarraitzea eskatu du"; +"Scene.Notification.Title.Everything" = "Dena"; +"Scene.Notification.Title.Mentions" = "Aipamenak"; +"Scene.Preview.Keyboard.ClosePreview" = "Itxi aurrebista"; +"Scene.Preview.Keyboard.ShowNext" = "Erakutsi hurrengoa"; +"Scene.Preview.Keyboard.ShowPrevious" = "Erakutsi aurrekoa"; +"Scene.Profile.Dashboard.Followers" = "jarraitzaile"; +"Scene.Profile.Dashboard.Following" = "jarraitzen"; +"Scene.Profile.Dashboard.Posts" = "bidalketa"; +"Scene.Profile.Fields.AddRow" = "Gehitu errenkada"; +"Scene.Profile.Fields.Placeholder.Content" = "Edukia"; +"Scene.Profile.Fields.Placeholder.Label" = "Etiketa"; +"Scene.Profile.RelationshipActionAlert.ConfirmBlockUser.Message" = "Berretsi %@ blokeatzea"; +"Scene.Profile.RelationshipActionAlert.ConfirmBlockUser.Title" = "Blokeatu kontua"; +"Scene.Profile.RelationshipActionAlert.ConfirmMuteUser.Message" = "Berretsi %@ mututzea"; +"Scene.Profile.RelationshipActionAlert.ConfirmMuteUser.Title" = "Mututu kontua"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUser.Message" = "Berretsi %@ desblokeatzea"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUser.Title" = "Desblokeatu kontua"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Message" = "Berretsi %@ desmututzea"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Title" = "Desmututu kontua"; +"Scene.Profile.SegmentedControl.About" = "Honi buruz"; +"Scene.Profile.SegmentedControl.Media" = "Multimedia"; +"Scene.Profile.SegmentedControl.Posts" = "Bidalketak"; +"Scene.Profile.SegmentedControl.PostsAndReplies" = "Bidalketak eta erantzunak"; +"Scene.Profile.SegmentedControl.Replies" = "Erantzunak"; +"Scene.Register.Error.Item.Agreement" = "Adostasuna"; +"Scene.Register.Error.Item.Email" = "Eposta"; +"Scene.Register.Error.Item.Locale" = "Eskualdeko ezarpenak"; +"Scene.Register.Error.Item.Password" = "Pasahitza"; +"Scene.Register.Error.Item.Reason" = "Arrazoia"; +"Scene.Register.Error.Item.Username" = "Erabiltzaile-izena"; +"Scene.Register.Error.Reason.Accepted" = "%@ onartu behar da"; +"Scene.Register.Error.Reason.Blank" = "%@ beharrezkoa da"; +"Scene.Register.Error.Reason.Blocked" = "%@(e)k onartu gabeko eposta hornitzaile bat erabiltzen du"; +"Scene.Register.Error.Reason.Inclusion" = "%@ ez da onartutako balio bat"; +"Scene.Register.Error.Reason.Invalid" = "%@ baliogabea da"; +"Scene.Register.Error.Reason.Reserved" = "%@ gako-hitz erreserbatu bat da"; +"Scene.Register.Error.Reason.Taken" = "%@ dagoeneko erabiltzen da"; +"Scene.Register.Error.Reason.TooLong" = "%@ luzeegia da"; +"Scene.Register.Error.Reason.TooShort" = "%@ laburregia da"; +"Scene.Register.Error.Reason.Unreachable" = "dirudienez %@ ez da existitzen"; +"Scene.Register.Error.Special.EmailInvalid" = "Hau ez da baliozko eposta helbidea"; +"Scene.Register.Error.Special.PasswordTooShort" = "Pasahitza laburregia da (gutxienez 8 karaktere izan behar ditu)"; +"Scene.Register.Error.Special.UsernameInvalid" = "Erabiltzaile-izenak karaktere alfanumerikoak eta azpimarrak soilik eduki ditzake"; +"Scene.Register.Error.Special.UsernameTooLong" = "Erabiltzaile-izena luzeegia da (ezin ditu 30 karaktere baino gehiago izan)"; +"Scene.Register.Input.Avatar.Delete" = "Ezabatu"; +"Scene.Register.Input.DisplayName.Placeholder" = "pantaila-izena"; +"Scene.Register.Input.Email.Placeholder" = "eposta"; +"Scene.Register.Input.Invite.RegistrationUserInviteRequest" = "Zergatik elkartu nahi duzu?"; +"Scene.Register.Input.Password.Accessibility.Checked" = "hautatuta"; +"Scene.Register.Input.Password.Accessibility.Unchecked" = "hautatu gabe"; +"Scene.Register.Input.Password.CharacterLimit" = "8 karaktere"; +"Scene.Register.Input.Password.Hint" = "Pasahitzak zortzi karaktere izan behar ditu gutxienez"; +"Scene.Register.Input.Password.Placeholder" = "pasahitza"; +"Scene.Register.Input.Password.Require" = "Zure pasahitzak izan behar ditu gutxienez:"; +"Scene.Register.Input.Username.DuplicatePrompt" = "Erabiltzaile-izen hau hartuta dago."; +"Scene.Register.Input.Username.Placeholder" = "erabiltzaile-izena"; +"Scene.Register.Title" = "Hitz egin iezaguzu zuri buruz."; +"Scene.Report.Content1" = "Salaketan beste bidalketarik gehitu nahi duzu?"; +"Scene.Report.Content2" = "Moderatzaileek besterik jakin behar dute salaketa honi buruz?"; +"Scene.Report.ReportSentTitle" = "Mila esker salaketagatik, berrikusiko dugu."; +"Scene.Report.Reported" = "SALATUA"; +"Scene.Report.Send" = "Bidali salaketa"; +"Scene.Report.SkipToSend" = "Bidali iruzkinik gabe"; +"Scene.Report.Step1" = "1. urratsa 2tik"; +"Scene.Report.Step2" = "2. urratsa 2tik"; +"Scene.Report.TextPlaceholder" = "Idatzi edo itsatsi iruzkin gehigarriak"; +"Scene.Report.Title" = "Salatu %@"; +"Scene.Report.TitleReport" = "Salatu"; +"Scene.Search.Recommend.Accounts.Description" = "Kontu hauek jarraitu nahiko dituzu behar bada"; +"Scene.Search.Recommend.Accounts.Follow" = "Jarraitu"; +"Scene.Search.Recommend.Accounts.Title" = "Gustuko izan ditzakezun kontuak"; +"Scene.Search.Recommend.ButtonText" = "Ikusi guztiak"; +"Scene.Search.Recommend.HashTag.Description" = "Deigarri gertatzen ari diren traolak"; +"Scene.Search.Recommend.HashTag.PeopleTalking" = "%@ pertsona hizketan"; +"Scene.Search.Recommend.HashTag.Title" = "Mastodoneko joerak"; +"Scene.Search.SearchBar.Cancel" = "Utzi"; +"Scene.Search.SearchBar.Placeholder" = "Bilatu traolak eta erabiltzaileak"; +"Scene.Search.Searching.Clear" = "Garbitu"; +"Scene.Search.Searching.EmptyState.NoResults" = "Emaitzarik ez"; +"Scene.Search.Searching.RecentSearch" = "Azken bilaketak"; +"Scene.Search.Searching.Segment.All" = "Guztiak"; +"Scene.Search.Searching.Segment.Hashtags" = "Traolak"; +"Scene.Search.Searching.Segment.People" = "Jendea"; +"Scene.Search.Searching.Segment.Posts" = "Bidalketak"; +"Scene.Search.Title" = "Bilatu"; +"Scene.ServerPicker.Button.Category.Academia" = "akademia"; +"Scene.ServerPicker.Button.Category.Activism" = "aktibismoa"; +"Scene.ServerPicker.Button.Category.All" = "Guztiak"; +"Scene.ServerPicker.Button.Category.AllAccessiblityDescription" = "Kategoria: Guztiak"; +"Scene.ServerPicker.Button.Category.Art" = "artea"; +"Scene.ServerPicker.Button.Category.Food" = "janaria"; +"Scene.ServerPicker.Button.Category.Furry" = "furry"; +"Scene.ServerPicker.Button.Category.Games" = "jokoak"; +"Scene.ServerPicker.Button.Category.General" = "orokorra"; +"Scene.ServerPicker.Button.Category.Journalism" = "kazetaritza"; +"Scene.ServerPicker.Button.Category.Lgbt" = "LGBTQ+"; +"Scene.ServerPicker.Button.Category.Music" = "musika"; +"Scene.ServerPicker.Button.Category.Regional" = "herrialdekoa"; +"Scene.ServerPicker.Button.Category.Tech" = "teknologia"; +"Scene.ServerPicker.Button.SeeLess" = "Ikusi gutxiago"; +"Scene.ServerPicker.Button.SeeMore" = "Ikusi gehiago"; +"Scene.ServerPicker.EmptyState.BadNetwork" = "Arazoren bat egon da datuak kargatzean. Egiaztatu zure Interneteko konexioa."; +"Scene.ServerPicker.EmptyState.FindingServers" = "Erabilgarri dauden zerbitzariak bilatzen..."; +"Scene.ServerPicker.EmptyState.NoResults" = "Emaitzarik ez"; +"Scene.ServerPicker.Input.Placeholder" = "Bilatu zerbitzari bat edo sortu zurea..."; +"Scene.ServerPicker.Label.Category" = "KATEGORIA"; +"Scene.ServerPicker.Label.Language" = "HIZKUNTZA"; +"Scene.ServerPicker.Label.Users" = "ERABILTZAILEAK"; +"Scene.ServerPicker.Subtitle" = "Aukeratu komunitate bat zure interes edo lurraldearen arabera, edo erabilera orokorreko bat."; +"Scene.ServerPicker.SubtitleExtend" = "Aukeratu komunitate bat zure interes edo lurraldearen arabera, edo erabilera orokorreko bat. Komunitate bakoitza erakunde edo norbanako independente batek kudeatzen du."; +"Scene.ServerPicker.Title" = "Aukeratu zerbitzari bat, +edozein zerbitzari."; +"Scene.ServerRules.Button.Confirm" = "Ados nago"; +"Scene.ServerRules.PrivacyPolicy" = "pribatutasun-gidalerroak"; +"Scene.ServerRules.Prompt" = "Jarraituz gero, %@ instantziaren zerbitzu-baldintzak eta pribatutasun-gidalerroak onartzen dituzu."; +"Scene.ServerRules.Subtitle" = "Arau hauek %@ instantziako administratzaileek ezarri dituzte."; +"Scene.ServerRules.TermsOfService" = "zerbitzu-baldintzak"; +"Scene.ServerRules.Title" = "Oinarrizko arau batzuk."; +"Scene.Settings.Footer.MastodonDescription" = "Mastodon software librea da. Arazoen berri eman dezakezu GitHub bidez: %@ (%@)"; +"Scene.Settings.Keyboard.CloseSettingsWindow" = "Itxi ezarpenen leihoa"; +"Scene.Settings.Section.Appearance.Automatic" = "Automatikoa"; +"Scene.Settings.Section.Appearance.Dark" = "Beti iluna"; +"Scene.Settings.Section.Appearance.Light" = "Beti argia"; +"Scene.Settings.Section.Appearance.Title" = "Itxura"; +"Scene.Settings.Section.BoringZone.AccountSettings" = "Kontuaren ezarpenak"; +"Scene.Settings.Section.BoringZone.Privacy" = "Pribatutasun-gidalerroak"; +"Scene.Settings.Section.BoringZone.Terms" = "Zerbitzu-baldintzak"; +"Scene.Settings.Section.BoringZone.Title" = "Eremu aspergarria"; +"Scene.Settings.Section.LookAndFeel.Light" = "Argia"; +"Scene.Settings.Section.LookAndFeel.ReallyDark" = "Oso iluna"; +"Scene.Settings.Section.LookAndFeel.SortaDark" = "Ilun antzekoa"; +"Scene.Settings.Section.LookAndFeel.Title" = "Itxura"; +"Scene.Settings.Section.LookAndFeel.UseSystem" = "Erabili sistemakoa"; +"Scene.Settings.Section.Notifications.Boosts" = "Nire bidalketa bultzatu du"; +"Scene.Settings.Section.Notifications.Favorites" = "Nire bidalketa gogoko egitean"; +"Scene.Settings.Section.Notifications.Follows" = "Jarraitzen nau"; +"Scene.Settings.Section.Notifications.Mentions" = "Aipatu nau"; +"Scene.Settings.Section.Notifications.Title" = "Jakinarazpenak"; +"Scene.Settings.Section.Notifications.Trigger.Anyone" = "edozein"; +"Scene.Settings.Section.Notifications.Trigger.Follow" = "jarraitzen dudan edonor"; +"Scene.Settings.Section.Notifications.Trigger.Follower" = "jarraitzaile bat"; +"Scene.Settings.Section.Notifications.Trigger.Noone" = "inor ez"; +"Scene.Settings.Section.Notifications.Trigger.Title" = "Noiz jakinarazi:"; +"Scene.Settings.Section.Preference.DisableAvatarAnimation" = "Desgaitu abatar animatuak"; +"Scene.Settings.Section.Preference.DisableEmojiAnimation" = "Desgaitu emoji animatuak"; +"Scene.Settings.Section.Preference.Title" = "Hobespenak"; +"Scene.Settings.Section.Preference.TrueBlackDarkMode" = "Benetako modu beltz iluna"; +"Scene.Settings.Section.Preference.UsingDefaultBrowser" = "Erabili nabigatzaile lehenetsia estekak irekitzeko"; +"Scene.Settings.Section.SpicyZone.Clear" = "Garbitu multimediaren cachea"; +"Scene.Settings.Section.SpicyZone.Signout" = "Amaitu saioa"; +"Scene.Settings.Section.SpicyZone.Title" = "Eremu beroa"; +"Scene.Settings.Title" = "Ezarpenak"; +"Scene.SuggestionAccount.FollowExplain" = "Norbait jarraitzen duzunean, bere bidalketak zure hasierako denbora-lerroan agertuko zaizkizu."; +"Scene.SuggestionAccount.Title" = "Bilatu jarraitzeko jendea"; +"Scene.Thread.BackTitle" = "Bidalketa"; +"Scene.Thread.Title" = "%@(e)n bidalketa"; +"Scene.Welcome.GetStarted" = "Nola hasi"; +"Scene.Welcome.LogIn" = "Hasi saioa"; +"Scene.Welcome.Slogan" = "Sare sozialak +berriz zure eskuetan."; +"Scene.Wizard.AccessibilityHint" = "Ukitu birritan morroi hau baztertzeko"; +"Scene.Wizard.MultipleAccountSwitchIntroDescription" = "Aldatu hainbat konturen artean profilaren botoia sakatuta edukiz."; +"Scene.Wizard.NewInMastodon" = "Berria Mastodonen"; \ No newline at end of file diff --git a/MastodonSDK/Sources/MastodonLocalization/Resources/eu-ES.lproj/Localizable.stringsdict b/MastodonSDK/Sources/MastodonLocalization/Resources/eu-ES.lproj/Localizable.stringsdict new file mode 100644 index 000000000..817e8372b --- /dev/null +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/eu-ES.lproj/Localizable.stringsdict @@ -0,0 +1,390 @@ +<?xml version="1.0" encoding="utf-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> + <dict> + <key>a11y.plural.count.unread.notification</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@notification_count_unread_notification@</string> + <key>notification_count_unread_notification</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>Irakurri gabeko jakinarazpen bat</string> + <key>other</key> + <string>Irakurri gabeko %ld jakinarazpen</string> + </dict> + </dict> + <key>a11y.plural.count.input_limit_exceeds</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>Sarrerak %#@character_count@ karaktereko muga gainditzen du</string> + <key>character_count</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>karaktere bat</string> + <key>other</key> + <string>%ld karaktere</string> + </dict> + </dict> + <key>a11y.plural.count.input_limit_remains</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>Sarreraren karaktere muga %#@character_count@ da oraindik</string> + <key>character_count</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>karaktere bat</string> + <key>other</key> + <string>%ld karaktere</string> + </dict> + </dict> + <key>plural.count.metric_formatted.post</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%@ %#@post_count@</string> + <key>post_count</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>bidalketa</string> + <key>other</key> + <string>bidalketa</string> + </dict> + </dict> + <key>plural.count.post</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@post_count@</string> + <key>post_count</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>Bidalketa bat</string> + <key>other</key> + <string>%ld bidalketa</string> + </dict> + </dict> + <key>plural.count.favorite</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@favorite_count@</string> + <key>favorite_count</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>Gogoko bat</string> + <key>other</key> + <string>%ld gogoko</string> + </dict> + </dict> + <key>plural.count.reblog</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@reblog_count@</string> + <key>reblog_count</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>Bultzada bat</string> + <key>other</key> + <string>%ld bultzada</string> + </dict> + </dict> + <key>plural.count.vote</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@vote_count@</string> + <key>vote_count</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>Boto bat</string> + <key>other</key> + <string>%ld boto</string> + </dict> + </dict> + <key>plural.count.voter</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@voter_count@</string> + <key>voter_count</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>Boto-emaile bat</string> + <key>other</key> + <string>%ld boto-emaile</string> + </dict> + </dict> + <key>plural.people_talking</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_people_talking@</string> + <key>count_people_talking</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>Pertsona bat hizketan</string> + <key>other</key> + <string>%ld pertsona hizketan</string> + </dict> + </dict> + <key>plural.count.following</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_following@</string> + <key>count_following</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>Bat jarraitzen</string> + <key>other</key> + <string>%ld jarraitzen</string> + </dict> + </dict> + <key>plural.count.follower</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_follower@</string> + <key>count_follower</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>Jarraitzaile bat</string> + <key>other</key> + <string>%ld jarraitzaile</string> + </dict> + </dict> + <key>date.year.left</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_year_left@</string> + <key>count_year_left</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>Urte bat geratzen da</string> + <key>other</key> + <string>%ld urte geratzen dira</string> + </dict> + </dict> + <key>date.month.left</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_month_left@</string> + <key>count_month_left</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>Hilabete bat geratzen da</string> + <key>other</key> + <string>%ld hilabete geratzen dira</string> + </dict> + </dict> + <key>date.day.left</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_day_left@</string> + <key>count_day_left</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>Egun bat geratzen da</string> + <key>other</key> + <string>%ld egun geratzen dira</string> + </dict> + </dict> + <key>date.hour.left</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_hour_left@</string> + <key>count_hour_left</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>Ordu 1 geratzen da</string> + <key>other</key> + <string>%ld ordu geratzen dira</string> + </dict> + </dict> + <key>date.minute.left</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_minute_left@</string> + <key>count_minute_left</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>Minutu 1 geratzen da</string> + <key>other</key> + <string>%ld minutu geratzen dira</string> + </dict> + </dict> + <key>date.second.left</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_second_left@</string> + <key>count_second_left</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>Segundo 1 geratzen da</string> + <key>other</key> + <string>%ld segundo geratzen dira</string> + </dict> + </dict> + <key>date.year.ago.abbr</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_year_ago_abbr@</string> + <key>count_year_ago_abbr</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>Duela urtebete</string> + <key>other</key> + <string>Duela %ld urte</string> + </dict> + </dict> + <key>date.month.ago.abbr</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_month_ago_abbr@</string> + <key>count_month_ago_abbr</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>Duela hilabete</string> + <key>other</key> + <string>Duela %ld hilabete</string> + </dict> + </dict> + <key>date.day.ago.abbr</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_day_ago_abbr@</string> + <key>count_day_ago_abbr</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>Duela egun bat</string> + <key>other</key> + <string>Duela %ld egun</string> + </dict> + </dict> + <key>date.hour.ago.abbr</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_hour_ago_abbr@</string> + <key>count_hour_ago_abbr</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>Duela ordubete</string> + <key>other</key> + <string>Duela %ld ordu</string> + </dict> + </dict> + <key>date.minute.ago.abbr</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_minute_ago_abbr@</string> + <key>count_minute_ago_abbr</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>Duela minutu bat</string> + <key>other</key> + <string>Duela %ld minutu</string> + </dict> + </dict> + <key>date.second.ago.abbr</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_second_ago_abbr@</string> + <key>count_second_ago_abbr</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>Duela segundo bat</string> + <key>other</key> + <string>Duela %ld segundo</string> + </dict> + </dict> + </dict> +</plist> diff --git a/Mastodon/Resources/fr.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/fr.lproj/Localizable.strings similarity index 86% rename from Mastodon/Resources/fr.lproj/Localizable.strings rename to MastodonSDK/Sources/MastodonLocalization/Resources/fr.lproj/Localizable.strings index a4dbfdb6f..ffcd28467 100644 --- a/Mastodon/Resources/fr.lproj/Localizable.strings +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/fr.lproj/Localizable.strings @@ -4,7 +4,7 @@ "Common.Alerts.CleanCache.Title" = "Vider le cache"; "Common.Alerts.Common.PleaseTryAgain" = "Merci de réessayer."; "Common.Alerts.Common.PleaseTryAgainLater" = "Merci de réessayer plus tard."; -"Common.Alerts.DeletePost.Delete" = "Supprimer"; +"Common.Alerts.DeletePost.Message" = "Voulez-vous vraiment supprimer ce message ?"; "Common.Alerts.DeletePost.Title" = "Voulez-vous vraiment supprimer ce message ?"; "Common.Alerts.DiscardPostContent.Message" = "Confirmez pour abandonner le contenu de votre message."; "Common.Alerts.DiscardPostContent.Title" = "Abandonner le brouillon"; @@ -28,7 +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.Compose" = "Rédiger"; "Common.Controls.Actions.Confirm" = "Confirmer"; "Common.Controls.Actions.Continue" = "Continuer"; "Common.Controls.Actions.CopyPhoto" = "Copier la photo"; @@ -41,6 +41,7 @@ Veuillez vérifier votre accès à Internet."; "Common.Controls.Actions.Next" = "Suivant"; "Common.Controls.Actions.Ok" = "OK"; "Common.Controls.Actions.Open" = "Ouvrir"; +"Common.Controls.Actions.OpenInBrowser" = "Ouvrir dans le navigateur"; "Common.Controls.Actions.OpenInSafari" = "Ouvrir dans Safari"; "Common.Controls.Actions.Preview" = "Aperçu"; "Common.Controls.Actions.Previous" = "Précédent"; @@ -93,6 +94,7 @@ Veuillez vérifier votre accès à Internet."; "Common.Controls.Keyboard.Timeline.ToggleFavorite" = "Basculer le favori lors de la publication"; "Common.Controls.Keyboard.Timeline.ToggleReblog" = "Basculer le reblogue lors de la publication"; "Common.Controls.Status.Actions.Favorite" = "Favori"; +"Common.Controls.Status.Actions.Hide" = "Cacher"; "Common.Controls.Status.Actions.Menu" = "Menu"; "Common.Controls.Status.Actions.Reblog" = "Rebloguer"; "Common.Controls.Status.Actions.Reply" = "Répondre"; @@ -112,6 +114,10 @@ Veuillez vérifier votre accès à Internet."; "Common.Controls.Status.Tag.Url" = "URL"; "Common.Controls.Status.UserReblogged" = "%@ a reblogué"; "Common.Controls.Status.UserRepliedTo" = "À répondu à %@"; +"Common.Controls.Status.Visibility.Direct" = "Seul·e l’utilisateur·rice mentionnée peut voir ce message."; +"Common.Controls.Status.Visibility.Private" = "Seul·e·s leurs abonné·e·s peuvent voir ce message."; +"Common.Controls.Status.Visibility.PrivateFromMe" = "Seul·e·s mes abonné·e·s peuvent voir ce message."; +"Common.Controls.Status.Visibility.Unlisted" = "Tout le monde peut voir ce message mais ne sera pas affiché sur le fil public."; "Common.Controls.Tabs.Home" = "Accueil"; "Common.Controls.Tabs.Notification" = "Notification"; "Common.Controls.Tabs.Profile" = "Profil"; @@ -135,8 +141,8 @@ Votre profil ressemble à ça pour lui."; "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.AccountList.DismissAccountSwitcher" = "Rejeter le commutateur de compte"; +"Scene.AccountList.TabBarHint" = "Profil sélectionné actuel: %@. Double appui puis maintenez enfoncé pour afficher le changement de compte"; "Scene.Compose.Accessibility.AppendAttachment" = "Joindre un document"; "Scene.Compose.Accessibility.AppendPoll" = "Ajouter un Sondage"; "Scene.Compose.Accessibility.CustomEmojiPicker" = "Sélecteur d’émojis personnalisés"; @@ -178,8 +184,8 @@ téléversé sur Mastodon."; "Scene.Compose.Visibility.Private" = "Abonnés seulement"; "Scene.Compose.Visibility.Public" = "Public"; "Scene.Compose.Visibility.Unlisted" = "Non listé"; -"Scene.ConfirmEmail.Button.DontReceiveEmail" = "Je n’ai jamais reçu de courriel"; "Scene.ConfirmEmail.Button.OpenEmailApp" = "Ouvrir l’application de courriel"; +"Scene.ConfirmEmail.Button.Resend" = "Renvoyer"; "Scene.ConfirmEmail.DontReceiveEmail.Description" = "Vérifiez que votre adresse courriel est valide ainsi que votre fichier spam si ce n’est pas déjà fait."; "Scene.ConfirmEmail.DontReceiveEmail.ResendEmail" = "Renvoyer le courriel"; "Scene.ConfirmEmail.DontReceiveEmail.Title" = "Vérifier vos courriels"; @@ -192,7 +198,7 @@ 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.Following.Footer" = "Les abonnés issus des autres serveurs ne sont pas affichés."; "Scene.HomeTimeline.NavigationBarState.NewPosts" = "Voir les nouvelles publications"; "Scene.HomeTimeline.NavigationBarState.Offline" = "Hors ligne"; "Scene.HomeTimeline.NavigationBarState.Published" = "Publié!"; @@ -200,14 +206,14 @@ tapotez le lien pour confirmer votre compte."; "Scene.HomeTimeline.Title" = "Accueil"; "Scene.Notification.Keyobard.ShowEverything" = "Tout Afficher"; "Scene.Notification.Keyobard.ShowMentions" = "Afficher les mentions"; +"Scene.Notification.NotificationDescription.FavoritedYourPost" = "a ajouté votre message à ses favoris"; +"Scene.Notification.NotificationDescription.FollowedYou" = "s’est abonné à vous"; +"Scene.Notification.NotificationDescription.MentionedYou" = "vous a mentionné"; +"Scene.Notification.NotificationDescription.PollHasEnded" = "le sondage est terminé"; +"Scene.Notification.NotificationDescription.RebloggedYourPost" = "a partagé votre message"; +"Scene.Notification.NotificationDescription.RequestToFollowYou" = "vous a envoyé une demande d’abonnement"; "Scene.Notification.Title.Everything" = "Tout"; "Scene.Notification.Title.Mentions" = "Mentions"; -"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"; @@ -217,12 +223,18 @@ tapotez le lien pour confirmer votre compte."; "Scene.Profile.Fields.AddRow" = "Ajouter une rangée"; "Scene.Profile.Fields.Placeholder.Content" = "Contenu"; "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.ConfirmBlockUser.Message" = "Confirmer le blocage de %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmBlockUser.Title" = "Bloquer le compte"; +"Scene.Profile.RelationshipActionAlert.ConfirmMuteUser.Message" = "Êtes-vous sûr de vouloir mettre en sourdine %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmMuteUser.Title" = "Masquer le compte"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUser.Message" = "Confirmer le déblocage de %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUser.Title" = "Débloquer le compte"; "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.About" = "À propos"; "Scene.Profile.SegmentedControl.Media" = "Média"; "Scene.Profile.SegmentedControl.Posts" = "Publications"; +"Scene.Profile.SegmentedControl.PostsAndReplies" = "Messages et réponses"; "Scene.Profile.SegmentedControl.Replies" = "Réponses"; "Scene.Register.Error.Item.Agreement" = "Accord"; "Scene.Register.Error.Item.Email" = "Courriel"; @@ -248,19 +260,26 @@ tapotez le lien pour confirmer votre compte."; "Scene.Register.Input.DisplayName.Placeholder" = "nom affiché"; "Scene.Register.Input.Email.Placeholder" = "courriel"; "Scene.Register.Input.Invite.RegistrationUserInviteRequest" = "Pourquoi voulez-vous vous inscrire ?"; +"Scene.Register.Input.Password.Accessibility.Checked" = "vérifié"; +"Scene.Register.Input.Password.Accessibility.Unchecked" = "non vérifié"; +"Scene.Register.Input.Password.CharacterLimit" = "8 caractères"; "Scene.Register.Input.Password.Hint" = "Votre mot de passe doit contenir au moins 8 caractères"; "Scene.Register.Input.Password.Placeholder" = "mot de passe"; +"Scene.Register.Input.Password.Require" = "Votre mot de passe doit être composé d’au moins :"; "Scene.Register.Input.Username.DuplicatePrompt" = "Ce nom d'utilisateur est déjà pris."; "Scene.Register.Input.Username.Placeholder" = "nom d'utilisateur"; "Scene.Register.Title" = "Parlez-nous de vous."; "Scene.Report.Content1" = "Y a-t-il d’autres messages que vous aimeriez ajouter au signalement?"; "Scene.Report.Content2" = "Y a-t-il quelque chose que les modérateurs devraient savoir sur ce rapport ?"; +"Scene.Report.ReportSentTitle" = "Merci de nous l’avoir signalé, nous allons examiner cela."; +"Scene.Report.Reported" = "SIGNALÉ"; "Scene.Report.Send" = "Envoyer le rapport"; "Scene.Report.SkipToSend" = "Envoyer sans commentaire"; "Scene.Report.Step1" = "Étape 1 de 2"; "Scene.Report.Step2" = "Étape 2 de 2"; "Scene.Report.TextPlaceholder" = "Tapez ou collez des informations supplémentaires"; "Scene.Report.Title" = "Signaler %@"; +"Scene.Report.TitleReport" = "Signalement"; "Scene.Search.Recommend.Accounts.Description" = "Vous aimeriez peut-être suivre ces comptes"; "Scene.Search.Recommend.Accounts.Follow" = "Suivre"; "Scene.Search.Recommend.Accounts.Title" = "Comptes que vous pourriez aimer"; @@ -301,6 +320,8 @@ tapotez le lien pour confirmer votre compte."; "Scene.ServerPicker.Label.Category" = "CATÉGORIE"; "Scene.ServerPicker.Label.Language" = "LANGUE"; "Scene.ServerPicker.Label.Users" = "UTILISATEUR·RICE·S"; +"Scene.ServerPicker.Subtitle" = "Choisissez une communauté en fonction de vos intérêts, de votre région ou de votre objectif général."; +"Scene.ServerPicker.SubtitleExtend" = "Choisissez une communauté basée sur vos intérêts, votre région ou un but général. Chaque communauté est gérée par une organisation ou un individu entièrement indépendant."; "Scene.ServerPicker.Title" = "Choisissez un serveur, n'importe quel serveur."; "Scene.ServerRules.Button.Confirm" = "J’accepte"; @@ -319,6 +340,11 @@ n'importe quel serveur."; "Scene.Settings.Section.BoringZone.Privacy" = "Politique de confidentialité"; "Scene.Settings.Section.BoringZone.Terms" = "Entente de service"; "Scene.Settings.Section.BoringZone.Title" = "La zone ennuyante"; +"Scene.Settings.Section.LookAndFeel.Light" = "Clair"; +"Scene.Settings.Section.LookAndFeel.ReallyDark" = "Très sombre"; +"Scene.Settings.Section.LookAndFeel.SortaDark" = "Légèrement sombre"; +"Scene.Settings.Section.LookAndFeel.Title" = "Apparence"; +"Scene.Settings.Section.LookAndFeel.UseSystem" = "Utiliser le thème du système"; "Scene.Settings.Section.Notifications.Boosts" = "Reblogue mon message"; "Scene.Settings.Section.Notifications.Favorites" = "Ajoute l’une de mes publications à ses favoris"; "Scene.Settings.Section.Notifications.Follows" = "Me suit"; @@ -342,6 +368,8 @@ n'importe quel serveur."; "Scene.SuggestionAccount.Title" = "Trouver des personnes à suivre"; "Scene.Thread.BackTitle" = "Publication"; "Scene.Thread.Title" = "Publication de %@"; +"Scene.Welcome.GetStarted" = "Prise en main"; +"Scene.Welcome.LogIn" = "Se connecter"; "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."; diff --git a/Mastodon/Resources/fr.lproj/Localizable.stringsdict b/MastodonSDK/Sources/MastodonLocalization/Resources/fr.lproj/Localizable.stringsdict similarity index 99% rename from Mastodon/Resources/fr.lproj/Localizable.stringsdict rename to MastodonSDK/Sources/MastodonLocalization/Resources/fr.lproj/Localizable.stringsdict index 4a912e4b3..37f07e67a 100644 --- a/Mastodon/Resources/fr.lproj/Localizable.stringsdict +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/fr.lproj/Localizable.stringsdict @@ -37,7 +37,7 @@ <key>a11y.plural.count.input_limit_remains</key> <dict> <key>NSStringLocalizedFormatKey</key> - <string>Input limit remains %#@character_count@</string> + <string>La limite d'entrée reste %#@character_count@</string> <key>character_count</key> <dict> <key>NSStringFormatSpecTypeKey</key> diff --git a/Mastodon/Resources/gd-GB.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/gd-GB.lproj/Localizable.strings similarity index 87% rename from Mastodon/Resources/gd-GB.lproj/Localizable.strings rename to MastodonSDK/Sources/MastodonLocalization/Resources/gd-GB.lproj/Localizable.strings index 3f80f6411..be6ea23a9 100644 --- a/Mastodon/Resources/gd-GB.lproj/Localizable.strings +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/gd-GB.lproj/Localizable.strings @@ -4,7 +4,7 @@ "Common.Alerts.CleanCache.Title" = "Falamhaich an tasgadan"; "Common.Alerts.Common.PleaseTryAgain" = "Feuch ris a-rithist."; "Common.Alerts.Common.PleaseTryAgainLater" = "Feuch ris a-rithist an ceann greis."; -"Common.Alerts.DeletePost.Delete" = "Sguab às"; +"Common.Alerts.DeletePost.Message" = "A bheil thu cinnteach gu bheil thu airson am post seo a sguabadh às?"; "Common.Alerts.DeletePost.Title" = "A bheil thu cinnteach gu bheil thu airson am post seo a sguabadh às?"; "Common.Alerts.DiscardPostContent.Message" = "Dearbh tilgeil air falbh susbaint a’ phuist a sgrìobh thu."; "Common.Alerts.DiscardPostContent.Title" = "Tilg air falbh an dreachd"; @@ -41,6 +41,7 @@ Thoir sùil air a’ cheangal agad ris an eadar-lìon."; "Common.Controls.Actions.Next" = "Air adhart"; "Common.Controls.Actions.Ok" = "Ceart ma-thà"; "Common.Controls.Actions.Open" = "Fosgail"; +"Common.Controls.Actions.OpenInBrowser" = "Fosgail sa bhrabhsair"; "Common.Controls.Actions.OpenInSafari" = "Fosgail ann an Safari"; "Common.Controls.Actions.Preview" = "Ro-sheall"; "Common.Controls.Actions.Previous" = "Air ais"; @@ -93,6 +94,7 @@ Thoir sùil air a’ cheangal agad ris an eadar-lìon."; "Common.Controls.Keyboard.Timeline.ToggleFavorite" = "Toglaich annsachd a’ phuist"; "Common.Controls.Keyboard.Timeline.ToggleReblog" = "Toglaich brosnachadh a’ phuist"; "Common.Controls.Status.Actions.Favorite" = "Cuir ris na h-annsachdan"; +"Common.Controls.Status.Actions.Hide" = "Falaich"; "Common.Controls.Status.Actions.Menu" = "Clàr-taice"; "Common.Controls.Status.Actions.Reblog" = "Brosnaich"; "Common.Controls.Status.Actions.Reply" = "Freagair"; @@ -112,6 +114,10 @@ Thoir sùil air a’ cheangal agad ris an eadar-lìon."; "Common.Controls.Status.Tag.Url" = "URL"; "Common.Controls.Status.UserReblogged" = "Tha %@ ’ga bhrosnachadh"; "Common.Controls.Status.UserRepliedTo" = "Air %@ fhreagairt"; +"Common.Controls.Status.Visibility.Direct" = "Chan fhaic ach an cleachdaiche air an dugadh iomradh am post seo."; +"Common.Controls.Status.Visibility.Private" = "Chan fhaic ach an luchd-leantainn aca am post seo."; +"Common.Controls.Status.Visibility.PrivateFromMe" = "Chan fhaic ach an luchd-leantainn agam am post seo."; +"Common.Controls.Status.Visibility.Unlisted" = "Chì a h-uile duine am post seo ach cha nochd e air an loidhne-ama phoblach."; "Common.Controls.Tabs.Home" = "Dachaigh"; "Common.Controls.Tabs.Notification" = "Brath"; "Common.Controls.Tabs.Profile" = "Pròifil"; @@ -178,8 +184,8 @@ a luchdadh suas gu Mastodon."; "Scene.Compose.Visibility.Private" = "Luchd-leantainn a-mhàin"; "Scene.Compose.Visibility.Public" = "Poblach"; "Scene.Compose.Visibility.Unlisted" = "Falaichte o liostaichean"; -"Scene.ConfirmEmail.Button.DontReceiveEmail" = "Cha d’ fhuair mi post-d a-riamh"; "Scene.ConfirmEmail.Button.OpenEmailApp" = "Fosgail aplacaid a’ phuist-d"; +"Scene.ConfirmEmail.Button.Resend" = "Ath-chuir"; "Scene.ConfirmEmail.DontReceiveEmail.Description" = "Dearbh gu bheil an seòladh puist-d agad mar bu chòir agus nach eil dad ann am pasgan an truilleis."; "Scene.ConfirmEmail.DontReceiveEmail.ResendEmail" = "Cuir am post-d a-rithist"; "Scene.ConfirmEmail.DontReceiveEmail.Title" = "Thoir sùil air a’ phost-d agad"; @@ -200,14 +206,14 @@ thoir gnogag air a’ chunntas a dhearbhadh a’ chunntais agad."; "Scene.HomeTimeline.Title" = "Dachaigh"; "Scene.Notification.Keyobard.ShowEverything" = "Seall a h-uile càil"; "Scene.Notification.Keyobard.ShowMentions" = "Seall na h-iomraidhean"; +"Scene.Notification.NotificationDescription.FavoritedYourPost" = "– ’s iad air am post agad a chur ris na h-annsachdan aca"; +"Scene.Notification.NotificationDescription.FollowedYou" = "– ’s iad ’gad leantainn a-nis"; +"Scene.Notification.NotificationDescription.MentionedYou" = "– ’s iad air iomradh a thoirt ort"; +"Scene.Notification.NotificationDescription.PollHasEnded" = "thàinig cunntas-bheachd gu crìoch"; +"Scene.Notification.NotificationDescription.RebloggedYourPost" = "– ’s iad air am post agad a bhrosnachadh"; +"Scene.Notification.NotificationDescription.RequestToFollowYou" = "iarrtas leantainn ort"; "Scene.Notification.Title.Everything" = "A h-uile rud"; "Scene.Notification.Title.Mentions" = "Iomraidhean"; -"Scene.Notification.UserFavorited Your Post" = "Is annsa le %@ am post agad"; -"Scene.Notification.UserFollowedYou" = "Tha %@ a’ leantainn ort a-nis"; -"Scene.Notification.UserMentionedYou" = "Thug %@ iomradh ort"; -"Scene.Notification.UserRebloggedYourPost" = "Bhrosnaich %@ am post agad"; -"Scene.Notification.UserRequestedToFollowYou" = "Dh’iarr %@ leantainn ort"; -"Scene.Notification.UserYourPollHasEnded" = "Crìoch cunntais-bheachd aig %@"; "Scene.Preview.Keyboard.ClosePreview" = "Dùin an ro-shealladh"; "Scene.Preview.Keyboard.ShowNext" = "Air adhart"; "Scene.Preview.Keyboard.ShowPrevious" = "Air ais"; @@ -217,12 +223,18 @@ thoir gnogag air a’ chunntas a dhearbhadh a’ chunntais agad."; "Scene.Profile.Fields.AddRow" = "Cuir ràgh ris"; "Scene.Profile.Fields.Placeholder.Content" = "Susbaint"; "Scene.Profile.Fields.Placeholder.Label" = "Leubail"; -"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Message" = "Dearbh dì-bhacadh %@"; -"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Title" = "Dì-bhac an cunntas"; +"Scene.Profile.RelationshipActionAlert.ConfirmBlockUser.Message" = "Dearbh bacadh %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmBlockUser.Title" = "Bac an cunntas"; +"Scene.Profile.RelationshipActionAlert.ConfirmMuteUser.Message" = "Dearbh mùchadh %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmMuteUser.Title" = "Mùch an cunntas"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUser.Message" = "Dearbh dì-bhacadh %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUser.Title" = "Dì-bhac an cunntas"; "Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Message" = "Dearbh dì-mhùchadh %@"; "Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Title" = "Dì-mhùch an cunntas"; +"Scene.Profile.SegmentedControl.About" = "Mu dhèidhinn"; "Scene.Profile.SegmentedControl.Media" = "Meadhanan"; "Scene.Profile.SegmentedControl.Posts" = "Postaichean"; +"Scene.Profile.SegmentedControl.PostsAndReplies" = "Postaichean ’s freagairtean"; "Scene.Profile.SegmentedControl.Replies" = "Freagairtean"; "Scene.Register.Error.Item.Agreement" = "Aonta"; "Scene.Register.Error.Item.Email" = "Post-d"; @@ -248,19 +260,26 @@ thoir gnogag air a’ chunntas a dhearbhadh a’ chunntais agad."; "Scene.Register.Input.DisplayName.Placeholder" = "ainm-taisbeanaidh"; "Scene.Register.Input.Email.Placeholder" = "post-d"; "Scene.Register.Input.Invite.RegistrationUserInviteRequest" = "Carson a bu mhiann leat ballrachd fhaighinn?"; +"Scene.Register.Input.Password.Accessibility.Checked" = "le cromag"; +"Scene.Register.Input.Password.Accessibility.Unchecked" = "gun chromag"; +"Scene.Register.Input.Password.CharacterLimit" = "8 caractaran"; "Scene.Register.Input.Password.Hint" = "Feumaidh ochd caractaran a bhith san fhacal-fhaire agad air a char as giorra"; "Scene.Register.Input.Password.Placeholder" = "facal-faire"; +"Scene.Register.Input.Password.Require" = "Feumaidh am facal-faire agad co-dhiù:"; "Scene.Register.Input.Username.DuplicatePrompt" = "Tha an t-ainm-cleachdaiche seo aig cuideigin eile."; "Scene.Register.Input.Username.Placeholder" = "ainm-cleachdaiche"; "Scene.Register.Title" = "Innis dhuinn mu do dhèidhinn."; "Scene.Report.Content1" = "A bheil post sam bith eile ann a bu mhiann leat cur ris a’ ghearan?"; "Scene.Report.Content2" = "A bheil rud sam bith ann a bu mhiann leat innse dha na maoir mun ghearan seo?"; +"Scene.Report.ReportSentTitle" = "Mòran taing airson a’ ghearain, bheir sinn sùil air."; +"Scene.Report.Reported" = "CHAIDH GEARAN A DHÈANAMH"; "Scene.Report.Send" = "Cuir an gearan"; "Scene.Report.SkipToSend" = "Cuir gun bheachd ris"; "Scene.Report.Step1" = "Ceum 1 à 2"; "Scene.Report.Step2" = "Ceum 2 à 2"; "Scene.Report.TextPlaceholder" = "Sgrìobh no cuir ann beachdan a bharrachd"; "Scene.Report.Title" = "Dèan gearan mu %@"; +"Scene.Report.TitleReport" = "Dèan gearan"; "Scene.Search.Recommend.Accounts.Description" = "Saoil am bu toigh leat leantainn air na cunntasan seo?"; "Scene.Search.Recommend.Accounts.Follow" = "Lean air"; "Scene.Search.Recommend.Accounts.Title" = "Cunntasan a chòrdas riut ma dh’fhaoidte"; @@ -301,6 +320,8 @@ thoir gnogag air a’ chunntas a dhearbhadh a’ chunntais agad."; "Scene.ServerPicker.Label.Category" = "ROINN-SEÒRSA"; "Scene.ServerPicker.Label.Language" = "CÀNAN"; "Scene.ServerPicker.Label.Users" = "CLEACHDAICHEAN"; +"Scene.ServerPicker.Subtitle" = "Tagh coimhearsnachd stèidhichte air d’ ùidhean no an roinn-dùthcha agad no tè choitcheann."; +"Scene.ServerPicker.SubtitleExtend" = "Tagh coimhearsnachd stèidhichte air d’ ùidhean no an roinn-dùthcha agad no tè choitcheann. Tha gach coimhearsnachd ’ga stiùireadh le buidheann no neach gu neo-eisimeileach."; "Scene.ServerPicker.Title" = "Tagh frithealaiche sam bith."; "Scene.ServerRules.Button.Confirm" = "Gabhaidh mi ris"; "Scene.ServerRules.PrivacyPolicy" = "poileasaidh prìobhaideachd"; @@ -318,6 +339,11 @@ thoir gnogag air a’ chunntas a dhearbhadh a’ chunntais agad."; "Scene.Settings.Section.BoringZone.Privacy" = "Am poileasaidh prìobhaideachd"; "Scene.Settings.Section.BoringZone.Terms" = "Teirmichean na seirbheise"; "Scene.Settings.Section.BoringZone.Title" = "An earrann ràsanach"; +"Scene.Settings.Section.LookAndFeel.Light" = "Soilleir"; +"Scene.Settings.Section.LookAndFeel.ReallyDark" = "Glè dhorcha"; +"Scene.Settings.Section.LookAndFeel.SortaDark" = "Caran dorcha"; +"Scene.Settings.Section.LookAndFeel.Title" = "Coltas"; +"Scene.Settings.Section.LookAndFeel.UseSystem" = "Cleachd coltas an t-siostaim"; "Scene.Settings.Section.Notifications.Boosts" = "Nuair a bhrosnaicheas iad post uam"; "Scene.Settings.Section.Notifications.Favorites" = "Nuair as annsa leotha am post agam"; "Scene.Settings.Section.Notifications.Follows" = "Nuair a leanas iad orm"; @@ -341,6 +367,8 @@ thoir gnogag air a’ chunntas a dhearbhadh a’ chunntais agad."; "Scene.SuggestionAccount.Title" = "Lorg daoine a leanas tu"; "Scene.Thread.BackTitle" = "Post"; "Scene.Thread.Title" = "Post le %@"; +"Scene.Welcome.GetStarted" = "Dèan toiseach-tòiseachaidh"; +"Scene.Welcome.LogIn" = "Clàraich a-steach"; "Scene.Welcome.Slogan" = "A’ cur nan lìonraidhean sòisealta ’nad làmhan fhèin."; "Scene.Wizard.AccessibilityHint" = "Thoir gnogag dhùbailte a’ leigeil seachad an draoidh seo"; diff --git a/Mastodon/Resources/gd-GB.lproj/Localizable.stringsdict b/MastodonSDK/Sources/MastodonLocalization/Resources/gd-GB.lproj/Localizable.stringsdict similarity index 100% rename from Mastodon/Resources/gd-GB.lproj/Localizable.stringsdict rename to MastodonSDK/Sources/MastodonLocalization/Resources/gd-GB.lproj/Localizable.stringsdict diff --git a/Mastodon/Resources/ja.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/ja.lproj/Localizable.strings similarity index 83% rename from Mastodon/Resources/ja.lproj/Localizable.strings rename to MastodonSDK/Sources/MastodonLocalization/Resources/ja.lproj/Localizable.strings index 98bf71639..cb037936b 100644 --- a/Mastodon/Resources/ja.lproj/Localizable.strings +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/ja.lproj/Localizable.strings @@ -4,14 +4,14 @@ "Common.Alerts.CleanCache.Title" = "キャッシュを消去"; "Common.Alerts.Common.PleaseTryAgain" = "もう一度お試しください。"; "Common.Alerts.Common.PleaseTryAgainLater" = "後でもう一度お試しください。"; -"Common.Alerts.DeletePost.Delete" = "消去"; +"Common.Alerts.DeletePost.Message" = "本当に削除しますか?"; "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.AttachmentsMessage.VideoAttachWithPhoto" = "すでに画像が含まれている投稿に、動画を添付することはできません。"; "Common.Alerts.PublishPostFailure.Message" = "投稿に失敗しました。 インターネットに接続されているか確認してください。"; "Common.Alerts.PublishPostFailure.Title" = "失敗"; @@ -28,7 +28,7 @@ "Common.Controls.Actions.Back" = "戻る"; "Common.Controls.Actions.BlockDomain" = "%@をブロック"; "Common.Controls.Actions.Cancel" = "キャンセル"; -"Common.Controls.Actions.Compose" = "Compose"; +"Common.Controls.Actions.Compose" = "新規作成"; "Common.Controls.Actions.Confirm" = "確認"; "Common.Controls.Actions.Continue" = "続ける"; "Common.Controls.Actions.CopyPhoto" = "写真をコピー"; @@ -41,12 +41,13 @@ "Common.Controls.Actions.Next" = "次"; "Common.Controls.Actions.Ok" = "OK"; "Common.Controls.Actions.Open" = "開く"; +"Common.Controls.Actions.OpenInBrowser" = "ブラウザで開く"; "Common.Controls.Actions.OpenInSafari" = "Safariで開く"; "Common.Controls.Actions.Preview" = "プレビュー"; "Common.Controls.Actions.Previous" = "前"; "Common.Controls.Actions.Remove" = "消去"; "Common.Controls.Actions.Reply" = "リプライ"; -"Common.Controls.Actions.ReportUser" = "%@を報告"; +"Common.Controls.Actions.ReportUser" = "%@を通報"; "Common.Controls.Actions.Save" = "保存"; "Common.Controls.Actions.SavePhoto" = "写真を撮る"; "Common.Controls.Actions.SeeMore" = "もっと見る"; @@ -93,6 +94,7 @@ "Common.Controls.Keyboard.Timeline.ToggleFavorite" = "お気に入り登録を切り替える"; "Common.Controls.Keyboard.Timeline.ToggleReblog" = "ブーストを切り替える"; "Common.Controls.Status.Actions.Favorite" = "お気に入り"; +"Common.Controls.Status.Actions.Hide" = "非表示"; "Common.Controls.Status.Actions.Menu" = "メニュー"; "Common.Controls.Status.Actions.Reblog" = "ブースト"; "Common.Controls.Status.Actions.Reply" = "リプライ"; @@ -112,6 +114,10 @@ "Common.Controls.Status.Tag.Url" = "URL"; "Common.Controls.Status.UserReblogged" = "%@がブースト"; "Common.Controls.Status.UserRepliedTo" = "%@がリプライ"; +"Common.Controls.Status.Visibility.Direct" = "この投稿はメンションされたユーザーに限り見ることができます。"; +"Common.Controls.Status.Visibility.Private" = "この投稿はフォロワーに限り見ることができます。"; +"Common.Controls.Status.Visibility.PrivateFromMe" = "この投稿はフォロワーに限り見ることができます。"; +"Common.Controls.Status.Visibility.Unlisted" = "この投稿は誰でも見ることができますが、公開タイムラインには表示されません。"; "Common.Controls.Tabs.Home" = "ホーム"; "Common.Controls.Tabs.Notification" = "通知"; "Common.Controls.Tabs.Profile" = "プロフィール"; @@ -131,8 +137,8 @@ "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.AccountList.DismissAccountSwitcher" = "アカウント切替画面を閉じます"; +"Scene.AccountList.TabBarHint" = "現在のアカウント: %@. ダブルタップしてアカウント切替画面を表示します"; "Scene.Compose.Accessibility.AppendAttachment" = "アタッチメントの追加"; "Scene.Compose.Accessibility.AppendPoll" = "投票を追加"; "Scene.Compose.Accessibility.CustomEmojiPicker" = "カスタム絵文字ピッカー"; @@ -141,8 +147,8 @@ "Scene.Compose.Accessibility.PostVisibilityMenu" = "投稿の表示メニュー"; "Scene.Compose.Accessibility.RemovePoll" = "投票を消去"; "Scene.Compose.Attachment.AttachmentBroken" = "%@は壊れていてMastodonにアップロードできません。"; -"Scene.Compose.Attachment.DescriptionPhoto" = "視覚障がい者のために写真を説明"; -"Scene.Compose.Attachment.DescriptionVideo" = "視覚障がい者のための映像の説明"; +"Scene.Compose.Attachment.DescriptionPhoto" = "閲覧が難しいユーザーへの画像説明"; +"Scene.Compose.Attachment.DescriptionVideo" = "閲覧が難しいユーザーへの映像説明"; "Scene.Compose.Attachment.Photo" = "写真"; "Scene.Compose.Attachment.Video" = "動画"; "Scene.Compose.AutoComplete.SpaceToAdd" = "スペースを追加"; @@ -173,8 +179,8 @@ "Scene.Compose.Visibility.Private" = "フォロワーのみ"; "Scene.Compose.Visibility.Public" = "パブリック"; "Scene.Compose.Visibility.Unlisted" = "非表示"; -"Scene.ConfirmEmail.Button.DontReceiveEmail" = "メールがこない"; "Scene.ConfirmEmail.Button.OpenEmailApp" = "メールアプリを開く"; +"Scene.ConfirmEmail.Button.Resend" = "Resend"; "Scene.ConfirmEmail.DontReceiveEmail.Description" = "メールアドレスが正しいかどうか、また、迷惑メールフォルダに入っていないかどうかも確認してください。"; "Scene.ConfirmEmail.DontReceiveEmail.ResendEmail" = "もう一度メールを送信"; "Scene.ConfirmEmail.DontReceiveEmail.Title" = "メールをチェックしてください"; @@ -185,8 +191,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.Follower.Footer" = "他のサーバーからのフォロワーは表示されません。"; +"Scene.Following.Footer" = "他のサーバーにいるフォローは表示されません。"; "Scene.HomeTimeline.NavigationBarState.NewPosts" = "新しい投稿を見る"; "Scene.HomeTimeline.NavigationBarState.Offline" = "オフライン"; "Scene.HomeTimeline.NavigationBarState.Published" = "投稿しました!"; @@ -194,14 +200,14 @@ "Scene.HomeTimeline.Title" = "ホーム"; "Scene.Notification.Keyobard.ShowEverything" = "すべて見る"; "Scene.Notification.Keyobard.ShowMentions" = "メンションを見る"; +"Scene.Notification.NotificationDescription.FavoritedYourPost" = "favorited your post"; +"Scene.Notification.NotificationDescription.FollowedYou" = "followed you"; +"Scene.Notification.NotificationDescription.MentionedYou" = "mentioned you"; +"Scene.Notification.NotificationDescription.PollHasEnded" = "poll has ended"; +"Scene.Notification.NotificationDescription.RebloggedYourPost" = "reblogged your post"; +"Scene.Notification.NotificationDescription.RequestToFollowYou" = "request to follow you"; "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" = "前を見る"; @@ -211,12 +217,18 @@ "Scene.Profile.Fields.AddRow" = "行追加"; "Scene.Profile.Fields.Placeholder.Content" = "コンテンツ"; "Scene.Profile.Fields.Placeholder.Label" = "ラベル"; -"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Message" = "%@のブロックを解除しますか?"; -"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Title" = "ブロックを解除"; +"Scene.Profile.RelationshipActionAlert.ConfirmBlockUser.Message" = "Confirm to block %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmBlockUser.Title" = "Block Account"; +"Scene.Profile.RelationshipActionAlert.ConfirmMuteUser.Message" = "Confirm to mute %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmMuteUser.Title" = "Mute Account"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUser.Message" = "Confirm to unblock %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUser.Title" = "Unblock Account"; "Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Message" = "%@をミュートしますか?"; "Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Title" = "ミュートを解除"; +"Scene.Profile.SegmentedControl.About" = "About"; "Scene.Profile.SegmentedControl.Media" = "メディア"; "Scene.Profile.SegmentedControl.Posts" = "投稿"; +"Scene.Profile.SegmentedControl.PostsAndReplies" = "Posts and Replies"; "Scene.Profile.SegmentedControl.Replies" = "リプライ"; "Scene.Register.Error.Item.Agreement" = "契約"; "Scene.Register.Error.Item.Email" = "メール"; @@ -242,19 +254,26 @@ "Scene.Register.Input.DisplayName.Placeholder" = "表示名"; "Scene.Register.Input.Email.Placeholder" = "メール"; "Scene.Register.Input.Invite.RegistrationUserInviteRequest" = "なぜ参加したいと思ったのですか?"; +"Scene.Register.Input.Password.Accessibility.Checked" = "checked"; +"Scene.Register.Input.Password.Accessibility.Unchecked" = "unchecked"; +"Scene.Register.Input.Password.CharacterLimit" = "8 characters"; "Scene.Register.Input.Password.Hint" = "パスワードは最低でも8文字必要です。"; "Scene.Register.Input.Password.Placeholder" = "パスワード"; +"Scene.Register.Input.Password.Require" = "Your password needs at least:"; "Scene.Register.Input.Username.DuplicatePrompt" = "このユーザー名は使用されています"; "Scene.Register.Input.Username.Placeholder" = "ユーザー名"; "Scene.Register.Title" = "あなたのことを教えてください"; -"Scene.Report.Content1" = "他に報告したい投稿はありますか?"; -"Scene.Report.Content2" = "この報告についてモデレーターに言いたいことはありますか?"; -"Scene.Report.Send" = "報告を送信"; +"Scene.Report.Content1" = "他に通報したい投稿はありますか?"; +"Scene.Report.Content2" = "この通報についてモデレーターに伝達しておきたい事項はありますか?"; +"Scene.Report.ReportSentTitle" = "Thanks for reporting, we’ll look into this."; +"Scene.Report.Reported" = "REPORTED"; +"Scene.Report.Send" = "通報を送信"; "Scene.Report.SkipToSend" = "コメントなしで送信"; "Scene.Report.Step1" = "ステップ 1/2"; "Scene.Report.Step2" = "ステップ 2/2"; "Scene.Report.TextPlaceholder" = "追加コメントを入力"; -"Scene.Report.Title" = "%@を報告"; +"Scene.Report.Title" = "%@を通報"; +"Scene.Report.TitleReport" = "Report"; "Scene.Search.Recommend.Accounts.Description" = "以下のアカウントをフォローしてみてはいかがでしょうか?"; "Scene.Search.Recommend.Accounts.Follow" = "フォロー"; "Scene.Search.Recommend.Accounts.Title" = "おすすめのアカウント"; @@ -278,7 +297,7 @@ "Scene.ServerPicker.Button.Category.AllAccessiblityDescription" = "カテゴリ: すべて"; "Scene.ServerPicker.Button.Category.Art" = "アート"; "Scene.ServerPicker.Button.Category.Food" = "食べ物"; -"Scene.ServerPicker.Button.Category.Furry" = "furry"; +"Scene.ServerPicker.Button.Category.Furry" = "ケモノ"; "Scene.ServerPicker.Button.Category.Games" = "ゲーム"; "Scene.ServerPicker.Button.Category.General" = "全般"; "Scene.ServerPicker.Button.Category.Journalism" = "言論"; @@ -295,6 +314,8 @@ "Scene.ServerPicker.Label.Category" = "カテゴリー"; "Scene.ServerPicker.Label.Language" = "言語"; "Scene.ServerPicker.Label.Users" = "ユーザー"; +"Scene.ServerPicker.Subtitle" = "あなたの興味分野・地域に合ったコミュニティや、汎用のものを選択してください。"; +"Scene.ServerPicker.SubtitleExtend" = "あなたの興味分野・地域に合ったコミュニティや、汎用のものを選択してください。各コミュニティはそれぞれ完全に独立した組織や個人によって運営されています。"; "Scene.ServerPicker.Title" = "サーバーを選択"; "Scene.ServerRules.Button.Confirm" = "同意する"; "Scene.ServerRules.PrivacyPolicy" = "プライバシーポリシー"; @@ -312,6 +333,11 @@ "Scene.Settings.Section.BoringZone.Privacy" = "プライバシーポリシー"; "Scene.Settings.Section.BoringZone.Terms" = "利用規約"; "Scene.Settings.Section.BoringZone.Title" = "アプリについて"; +"Scene.Settings.Section.LookAndFeel.Light" = "Light"; +"Scene.Settings.Section.LookAndFeel.ReallyDark" = "Really Dark"; +"Scene.Settings.Section.LookAndFeel.SortaDark" = "Sorta Dark"; +"Scene.Settings.Section.LookAndFeel.Title" = "Look and Feel"; +"Scene.Settings.Section.LookAndFeel.UseSystem" = "Use System"; "Scene.Settings.Section.Notifications.Boosts" = "ブースト"; "Scene.Settings.Section.Notifications.Favorites" = "お気に入り登録"; "Scene.Settings.Section.Notifications.Follows" = "フォロー"; @@ -322,7 +348,7 @@ "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.DisableAvatarAnimation" = "アバターのアニメーションを無効化する"; "Scene.Settings.Section.Preference.DisableEmojiAnimation" = "絵文字のアニメーションを無効化する"; "Scene.Settings.Section.Preference.Title" = "環境設定"; "Scene.Settings.Section.Preference.TrueBlackDarkMode" = "真っ黒なダークテーマを使用する"; @@ -335,7 +361,9 @@ "Scene.SuggestionAccount.Title" = "フォローする人を探す"; "Scene.Thread.BackTitle" = "投稿"; "Scene.Thread.Title" = "%@の投稿"; +"Scene.Welcome.GetStarted" = "Get Started"; +"Scene.Welcome.LogIn" = "ログイン"; "Scene.Welcome.Slogan" = "ソーシャルネットワーキングを、あなたの手の中に."; -"Scene.Wizard.AccessibilityHint" = "Double tap to dismiss this wizard"; +"Scene.Wizard.AccessibilityHint" = "チュートリアルを閉じるには、ダブルタップしてください"; "Scene.Wizard.MultipleAccountSwitchIntroDescription" = "プロフィールボタンを押して複数のアカウントを切り替えます。"; "Scene.Wizard.NewInMastodon" = "Mastodon の新機能"; \ No newline at end of file diff --git a/Mastodon/Resources/ja.lproj/Localizable.stringsdict b/MastodonSDK/Sources/MastodonLocalization/Resources/ja.lproj/Localizable.stringsdict similarity index 99% rename from Mastodon/Resources/ja.lproj/Localizable.stringsdict rename to MastodonSDK/Sources/MastodonLocalization/Resources/ja.lproj/Localizable.stringsdict index c51a9a29d..f1c5e6e25 100644 --- a/Mastodon/Resources/ja.lproj/Localizable.stringsdict +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/ja.lproj/Localizable.stringsdict @@ -279,7 +279,7 @@ <key>NSStringFormatValueTypeKey</key> <string>ld</string> <key>other</key> - <string>%ld分前</string> + <string>%ldか月前</string> </dict> </dict> <key>date.day.ago.abbr</key> diff --git a/Mastodon/Resources/ku-TR.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/ku-TR.lproj/Localizable.strings similarity index 86% rename from Mastodon/Resources/ku-TR.lproj/Localizable.strings rename to MastodonSDK/Sources/MastodonLocalization/Resources/ku-TR.lproj/Localizable.strings index d0d0f294e..6e629cb0b 100644 --- a/Mastodon/Resources/ku-TR.lproj/Localizable.strings +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/ku-TR.lproj/Localizable.strings @@ -4,8 +4,8 @@ "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.DeletePost.Message" = "Ma tu dixwazî vê şandiyê jê bibî?"; +"Common.Alerts.DeletePost.Title" = "Şandiyê jê bibe"; "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."; @@ -41,6 +41,7 @@ Jkx girêdana înternetê xwe kontrol bike."; "Common.Controls.Actions.Next" = "Pêş"; "Common.Controls.Actions.Ok" = "BAŞ E"; "Common.Controls.Actions.Open" = "Veke"; +"Common.Controls.Actions.OpenInBrowser" = "Di gerokê de veke"; "Common.Controls.Actions.OpenInSafari" = "Di Safariyê de veke"; "Common.Controls.Actions.Preview" = "Pêşdîtin"; "Common.Controls.Actions.Previous" = "Paş"; @@ -91,8 +92,9 @@ Jkx girêdana înternetê xwe kontrol bike."; "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.Keyboard.Timeline.ToggleReblog" = "Ji nû ve nivîsandin di şandiyê de biguherîne"; "Common.Controls.Status.Actions.Favorite" = "Bijarte"; +"Common.Controls.Status.Actions.Hide" = "Veşêre"; "Common.Controls.Status.Actions.Menu" = "Kulîn"; "Common.Controls.Status.Actions.Reblog" = "Ji nû ve nivîsandin"; "Common.Controls.Status.Actions.Reply" = "Bersivê bide"; @@ -110,8 +112,12 @@ Jkx girêdana înternetê xwe kontrol bike."; "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.UserReblogged" = "%@ ji nû ve nivîsand"; "Common.Controls.Status.UserRepliedTo" = "Bersiv da %@"; +"Common.Controls.Status.Visibility.Direct" = "Tenê bikarhênerê qalkirî dikare vê şandiyê bibîne."; +"Common.Controls.Status.Visibility.Private" = "Tenê şopînerên wan dikarin vê şandiyê bibînin."; +"Common.Controls.Status.Visibility.PrivateFromMe" = "Tenê şopînerên min dikarin vê şandiyê bibînin."; +"Common.Controls.Status.Visibility.Unlisted" = "Her kes dikare vê şandiyê bibîne lê nayê nîşandan di demnameya gelemperî de."; "Common.Controls.Tabs.Home" = "Serrûpel"; "Common.Controls.Tabs.Notification" = "Agahdarî"; "Common.Controls.Tabs.Profile" = "Profîl"; @@ -178,8 +184,8 @@ Profîla te ji wan ra wiha xuya dike."; "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.Button.Resend" = "Ji nû ve bişîne"; "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"; @@ -200,14 +206,14 @@ girêdanê bitikne da ku ajimêra xwe bidî piştrastkirin."; "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.NotificationDescription.FavoritedYourPost" = "şandiya te hez kir"; +"Scene.Notification.NotificationDescription.FollowedYou" = "te şopand"; +"Scene.Notification.NotificationDescription.MentionedYou" = "qale te kir"; +"Scene.Notification.NotificationDescription.PollHasEnded" = "rapirsî qediya"; +"Scene.Notification.NotificationDescription.RebloggedYourPost" = "şandiya te ji nû ve nivisand"; +"Scene.Notification.NotificationDescription.RequestToFollowYou" = "dixwazê te bişopîne"; "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"; @@ -217,12 +223,18 @@ girêdanê bitikne da ku ajimêra xwe bidî piştrastkirin."; "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.ConfirmBlockUser.Message" = "Ji bo rakirina astengkirinê %@ bipejirîne"; +"Scene.Profile.RelationshipActionAlert.ConfirmBlockUser.Title" = "Ajimêr asteng bike"; +"Scene.Profile.RelationshipActionAlert.ConfirmMuteUser.Message" = "Ji bo bêdengkirina %@ bipejirîne"; +"Scene.Profile.RelationshipActionAlert.ConfirmMuteUser.Title" = "Ajimêrê bêdeng bike"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUser.Message" = "Ji bo rakirina astengkirinê %@ bipejirîne"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUser.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.About" = "Derbar"; "Scene.Profile.SegmentedControl.Media" = "Medya"; "Scene.Profile.SegmentedControl.Posts" = "Şandî"; +"Scene.Profile.SegmentedControl.PostsAndReplies" = "Şandî û bersiv"; "Scene.Profile.SegmentedControl.Replies" = "Bersiv"; "Scene.Register.Error.Item.Agreement" = "Peyman"; "Scene.Register.Error.Item.Email" = "E-name"; @@ -248,19 +260,26 @@ girêdanê bitikne da ku ajimêra xwe bidî piştrastkirin."; "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.Accessibility.Checked" = "hate kontrolkirin"; +"Scene.Register.Input.Password.Accessibility.Unchecked" = "nehate kontrolkirin"; +"Scene.Register.Input.Password.CharacterLimit" = "8 tîp"; "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.Password.Require" = "Pêdiviya pêborîna te ya herî kêm:"; "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.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.ReportSentTitle" = "Spas ji bo ragihandina te, em ê binirxînin."; +"Scene.Report.Reported" = "HATE RAGIHANDIN"; "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.Report.TitleReport" = "Ragihandin"; "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î"; @@ -301,12 +320,14 @@ girêdanê bitikne da ku ajimêra xwe bidî piştrastkirin."; "Scene.ServerPicker.Label.Category" = "BEŞ"; "Scene.ServerPicker.Label.Language" = "ZIMAN"; "Scene.ServerPicker.Label.Users" = "BIKARHÊNER"; +"Scene.ServerPicker.Subtitle" = "Li gorî berjewendî, herêm, an jî armancek gelemperî civakekê hilbijêre."; +"Scene.ServerPicker.SubtitleExtend" = "Li gorî berjewendî, herêm, an jî armancek gelemperî civakekê hilbijêre. Her civakek ji hêla rêxistinek an kesek bi tevahî serbixwe ve tê xebitandin."; "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.Subtitle" = "Ev rêzik ji aliyê çavdêrê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"; @@ -319,6 +340,11 @@ Her kîjan rajekar be."; "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.LookAndFeel.Light" = "Ron"; +"Scene.Settings.Section.LookAndFeel.ReallyDark" = "Tarî"; +"Scene.Settings.Section.LookAndFeel.SortaDark" = "Hinekî tarî"; +"Scene.Settings.Section.LookAndFeel.Title" = "Xuyang"; +"Scene.Settings.Section.LookAndFeel.UseSystem" = "Pergalê bi kar bîne"; "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"; @@ -342,6 +368,8 @@ Her kîjan rajekar be."; "Scene.SuggestionAccount.Title" = "Kesên bo ku bişopînî bibîne"; "Scene.Thread.BackTitle" = "Şandî"; "Scene.Thread.Title" = "Şandî ji %@"; +"Scene.Welcome.GetStarted" = "Dest pê bike"; +"Scene.Welcome.LogIn" = "Têkeve"; "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î"; diff --git a/Mastodon/Resources/ku-TR.lproj/Localizable.stringsdict b/MastodonSDK/Sources/MastodonLocalization/Resources/ku-TR.lproj/Localizable.stringsdict similarity index 100% rename from Mastodon/Resources/ku-TR.lproj/Localizable.stringsdict rename to MastodonSDK/Sources/MastodonLocalization/Resources/ku-TR.lproj/Localizable.stringsdict diff --git a/MastodonSDK/Sources/MastodonLocalization/Resources/ku.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/ku.lproj/Localizable.strings new file mode 100644 index 000000000..02c8be667 --- /dev/null +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/ku.lproj/Localizable.strings @@ -0,0 +1,377 @@ +"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.Message" = "Ma tu dixwazî vê şandiyê jê bibî?"; +"Common.Alerts.DeletePost.Title" = "Şandiyê jê bibe"; +"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.OpenInBrowser" = "Di gerokê de 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 nû ve nivîsandin di şandiyê de biguherîne"; +"Common.Controls.Status.Actions.Favorite" = "Bijarte"; +"Common.Controls.Status.Actions.Hide" = "Veşêre"; +"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 nivîsand"; +"Common.Controls.Status.UserRepliedTo" = "Bersiv da %@"; +"Common.Controls.Status.Visibility.Direct" = "Tenê bikarhênerê qalkirî dikare vê şandiyê bibîne."; +"Common.Controls.Status.Visibility.Private" = "Tenê şopînerên wan dikarin vê şandiyê bibînin."; +"Common.Controls.Status.Visibility.PrivateFromMe" = "Tenê şopînerên min dikarin vê şandiyê bibînin."; +"Common.Controls.Status.Visibility.Unlisted" = "Her kes dikare vê şandiyê bibîne lê nayê nîşandan di demnameya gelemperî de."; +"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.OpenEmailApp" = "Sepana e-nameyê veke"; +"Scene.ConfirmEmail.Button.Resend" = "Ji nû ve bişîne"; +"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.NotificationDescription.FavoritedYourPost" = "şandiya te hez kir"; +"Scene.Notification.NotificationDescription.FollowedYou" = "te şopand"; +"Scene.Notification.NotificationDescription.MentionedYou" = "qale te kir"; +"Scene.Notification.NotificationDescription.PollHasEnded" = "rapirsî qediya"; +"Scene.Notification.NotificationDescription.RebloggedYourPost" = "şandiya te ji nû ve nivisand"; +"Scene.Notification.NotificationDescription.RequestToFollowYou" = "dixwazê te bişopîne"; +"Scene.Notification.Title.Everything" = "Her tişt"; +"Scene.Notification.Title.Mentions" = "Qalkirin"; +"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.ConfirmBlockUser.Message" = "Ji bo rakirina astengkirinê %@ bipejirîne"; +"Scene.Profile.RelationshipActionAlert.ConfirmBlockUser.Title" = "Ajimêr asteng bike"; +"Scene.Profile.RelationshipActionAlert.ConfirmMuteUser.Message" = "Ji bo bêdengkirina %@ bipejirîne"; +"Scene.Profile.RelationshipActionAlert.ConfirmMuteUser.Title" = "Ajimêrê bêdeng bike"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUser.Message" = "Ji bo rakirina astengkirinê %@ bipejirîne"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUser.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.About" = "Derbar"; +"Scene.Profile.SegmentedControl.Media" = "Medya"; +"Scene.Profile.SegmentedControl.Posts" = "Şandî"; +"Scene.Profile.SegmentedControl.PostsAndReplies" = "Şandî û bersiv"; +"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.Accessibility.Checked" = "hate kontrolkirin"; +"Scene.Register.Input.Password.Accessibility.Unchecked" = "nehate kontrolkirin"; +"Scene.Register.Input.Password.CharacterLimit" = "8 tîp"; +"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.Password.Require" = "Pêdiviya pêborîna te ya herî kêm:"; +"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.ReportSentTitle" = "Spas ji bo ragihandina te, em ê binirxînin."; +"Scene.Report.Reported" = "HATE RAGIHANDIN"; +"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.Report.TitleReport" = "Ragihandin"; +"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.Subtitle" = "Li gorî berjewendî, herêm, an jî armancek gelemperî civakekê hilbijêre."; +"Scene.ServerPicker.SubtitleExtend" = "Li gorî berjewendî, herêm, an jî armancek gelemperî civakekê hilbijêre. Her civakek ji hêla rêxistinek an kesek bi tevahî serbixwe ve tê xebitandin."; +"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ê çavdêrê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 ronahî"; +"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.LookAndFeel.Light" = "Ronahî"; +"Scene.Settings.Section.LookAndFeel.ReallyDark" = "Tarî"; +"Scene.Settings.Section.LookAndFeel.SortaDark" = "Hinekî tarî"; +"Scene.Settings.Section.LookAndFeel.Title" = "Xuyang"; +"Scene.Settings.Section.LookAndFeel.UseSystem" = "Pergalê bi kar bîne"; +"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" = "Sazkarî"; +"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.GetStarted" = "Dest pê bike"; +"Scene.Welcome.LogIn" = "Têkeve"; +"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/MastodonSDK/Sources/MastodonLocalization/Resources/ku.lproj/Localizable.stringsdict b/MastodonSDK/Sources/MastodonLocalization/Resources/ku.lproj/Localizable.stringsdict new file mode 100644 index 000000000..8ae1b812a --- /dev/null +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/ku.lproj/Localizable.stringsdict @@ -0,0 +1,390 @@ +<?xml version="1.0" encoding="utf-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> + <dict> + <key>a11y.plural.count.unread.notification</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@notification_count_unread_notification@</string> + <key>notification_count_unread_notification</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 agahdariya nexwendî</string> + <key>other</key> + <string>%ld agahdariyên nexwendî</string> + </dict> + </dict> + <key>a11y.plural.count.input_limit_exceeds</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>Sînorê têketinê derbas kir %#@character_count@</string> + <key>character_count</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 tîp</string> + <key>other</key> + <string>%ld tîp</string> + </dict> + </dict> + <key>a11y.plural.count.input_limit_remains</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>Sînorê têketinê %#@character_count@ maye</string> + <key>character_count</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 tîp</string> + <key>other</key> + <string>%ld tîp</string> + </dict> + </dict> + <key>plural.count.metric_formatted.post</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%@ %#@post_count@</string> + <key>post_count</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>şandî</string> + <key>other</key> + <string>şandî</string> + </dict> + </dict> + <key>plural.count.post</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@post_count@</string> + <key>post_count</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 şandî</string> + <key>other</key> + <string>%ld şandî</string> + </dict> + </dict> + <key>plural.count.favorite</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@favorite_count@</string> + <key>favorite_count</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 hezkirin</string> + <key>other</key> + <string>%ld hezkirin</string> + </dict> + </dict> + <key>plural.count.reblog</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@reblog_count@</string> + <key>reblog_count</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 ji nû ve nivîsandin</string> + <key>other</key> + <string>%ld ji nû ve nivîsandin</string> + </dict> + </dict> + <key>plural.count.vote</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@vote_count@</string> + <key>vote_count</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 deng</string> + <key>other</key> + <string>%ld deng</string> + </dict> + </dict> + <key>plural.count.voter</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@voter_count@</string> + <key>voter_count</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 hilbijêr</string> + <key>other</key> + <string>%ld hilbijêr</string> + </dict> + </dict> + <key>plural.people_talking</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_people_talking@</string> + <key>count_people_talking</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 mirov diaxive</string> + <key>other</key> + <string>%ld mirov diaxive</string> + </dict> + </dict> + <key>plural.count.following</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_following@</string> + <key>count_following</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 dişopîne</string> + <key>other</key> + <string>%ld dişopîne</string> + </dict> + </dict> + <key>plural.count.follower</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_follower@</string> + <key>count_follower</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 şopîner</string> + <key>other</key> + <string>%ld şopîner</string> + </dict> + </dict> + <key>date.year.left</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_year_left@</string> + <key>count_year_left</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 sal berê</string> + <key>other</key> + <string>%ld sal berê</string> + </dict> + </dict> + <key>date.month.left</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_month_left@</string> + <key>count_month_left</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 meh berê</string> + <key>other</key> + <string>%ld meh berê</string> + </dict> + </dict> + <key>date.day.left</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_day_left@</string> + <key>count_day_left</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 roj berê</string> + <key>other</key> + <string>%ld roj berê</string> + </dict> + </dict> + <key>date.hour.left</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_hour_left@</string> + <key>count_hour_left</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 demjimêr berê</string> + <key>other</key> + <string>%ld demjimêr berê</string> + </dict> + </dict> + <key>date.minute.left</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_minute_left@</string> + <key>count_minute_left</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 xulek berê</string> + <key>other</key> + <string>%ld xulek berê</string> + </dict> + </dict> + <key>date.second.left</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_second_left@</string> + <key>count_second_left</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 çirke berê</string> + <key>other</key> + <string>%ld çirke berê</string> + </dict> + </dict> + <key>date.year.ago.abbr</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_year_ago_abbr@</string> + <key>count_year_ago_abbr</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 sal berê</string> + <key>other</key> + <string>%ld sal berê</string> + </dict> + </dict> + <key>date.month.ago.abbr</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_month_ago_abbr@</string> + <key>count_month_ago_abbr</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 xulek berê</string> + <key>other</key> + <string>%ld xulek berê</string> + </dict> + </dict> + <key>date.day.ago.abbr</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_day_ago_abbr@</string> + <key>count_day_ago_abbr</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 roj berê</string> + <key>other</key> + <string>%ld roj berê</string> + </dict> + </dict> + <key>date.hour.ago.abbr</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_hour_ago_abbr@</string> + <key>count_hour_ago_abbr</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 demjimêr berê</string> + <key>other</key> + <string>%ld demjimêr berê</string> + </dict> + </dict> + <key>date.minute.ago.abbr</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_minute_ago_abbr@</string> + <key>count_minute_ago_abbr</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 xulek berê</string> + <key>other</key> + <string>%ld xulek berê</string> + </dict> + </dict> + <key>date.second.ago.abbr</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_second_ago_abbr@</string> + <key>count_second_ago_abbr</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 çirke berê</string> + <key>other</key> + <string>%ld çirke berê</string> + </dict> + </dict> + </dict> +</plist> diff --git a/Mastodon/Resources/nl.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/nl.lproj/Localizable.strings similarity index 88% rename from Mastodon/Resources/nl.lproj/Localizable.strings rename to MastodonSDK/Sources/MastodonLocalization/Resources/nl.lproj/Localizable.strings index 9c84e138f..f9e04622d 100644 --- a/Mastodon/Resources/nl.lproj/Localizable.strings +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/nl.lproj/Localizable.strings @@ -4,7 +4,7 @@ "Common.Alerts.CleanCache.Title" = "Cache-geheugen Wissen"; "Common.Alerts.Common.PleaseTryAgain" = "Probeer het opnieuw."; "Common.Alerts.Common.PleaseTryAgainLater" = "Probeer het later nog eens."; -"Common.Alerts.DeletePost.Delete" = "Verwijderen"; +"Common.Alerts.DeletePost.Message" = "Are you sure you want to delete this post?"; "Common.Alerts.DeletePost.Title" = "Weet u zeker dat u dit bericht wilt verwijderen?"; "Common.Alerts.DiscardPostContent.Message" = "Bevestig het verwijderen van het concept bericht."; "Common.Alerts.DiscardPostContent.Title" = "Concept Verwijderen"; @@ -40,6 +40,7 @@ "Common.Controls.Actions.Next" = "Volgende"; "Common.Controls.Actions.Ok" = "Oké"; "Common.Controls.Actions.Open" = "Open"; +"Common.Controls.Actions.OpenInBrowser" = "Open in Browser"; "Common.Controls.Actions.OpenInSafari" = "Open in Safari"; "Common.Controls.Actions.Preview" = "Voorvertoning"; "Common.Controls.Actions.Previous" = "Vorige"; @@ -92,6 +93,7 @@ "Common.Controls.Keyboard.Timeline.ToggleFavorite" = "Favoriet Omschakelen bij Bericht"; "Common.Controls.Keyboard.Timeline.ToggleReblog" = "Delen bij berichten omschakelen"; "Common.Controls.Status.Actions.Favorite" = "Toevoegen aan Favorieten"; +"Common.Controls.Status.Actions.Hide" = "Hide"; "Common.Controls.Status.Actions.Menu" = "Menu"; "Common.Controls.Status.Actions.Reblog" = "Delen"; "Common.Controls.Status.Actions.Reply" = "Reageren"; @@ -111,6 +113,10 @@ "Common.Controls.Status.Tag.Url" = "URL"; "Common.Controls.Status.UserReblogged" = "%@ gedeeld"; "Common.Controls.Status.UserRepliedTo" = "Reactie op %@"; +"Common.Controls.Status.Visibility.Direct" = "Only mentioned user can see this post."; +"Common.Controls.Status.Visibility.Private" = "Only their followers can see this post."; +"Common.Controls.Status.Visibility.PrivateFromMe" = "Only my followers can see this post."; +"Common.Controls.Status.Visibility.Unlisted" = "Everyone can see this post but not display in the public timeline."; "Common.Controls.Tabs.Home" = "Start"; "Common.Controls.Tabs.Notification" = "Melding"; "Common.Controls.Tabs.Profile" = "Profiel"; @@ -172,8 +178,8 @@ Uw profiel ziet er zo uit voor hen."; "Scene.Compose.Visibility.Private" = "Alleen volgers"; "Scene.Compose.Visibility.Public" = "Openbaar"; "Scene.Compose.Visibility.Unlisted" = "Niet-vermeld"; -"Scene.ConfirmEmail.Button.DontReceiveEmail" = "Ik heb geen email ontvangen"; "Scene.ConfirmEmail.Button.OpenEmailApp" = "Email Openen"; +"Scene.ConfirmEmail.Button.Resend" = "Resend"; "Scene.ConfirmEmail.DontReceiveEmail.Description" = "Controleer of uw emailadres correct is en of the email in de ongewenste email filter terecht is gekomen."; "Scene.ConfirmEmail.DontReceiveEmail.ResendEmail" = "Email Opnieuw Versturen"; "Scene.ConfirmEmail.DontReceiveEmail.Title" = "Controleer uw emailadres"; @@ -194,14 +200,14 @@ klik op de link om uw account te bevestigen."; "Scene.HomeTimeline.Title" = "Start"; "Scene.Notification.Keyobard.ShowEverything" = "Alles weergeven"; "Scene.Notification.Keyobard.ShowMentions" = "Vermeldingen weergeven"; +"Scene.Notification.NotificationDescription.FavoritedYourPost" = "favorited your post"; +"Scene.Notification.NotificationDescription.FollowedYou" = "followed you"; +"Scene.Notification.NotificationDescription.MentionedYou" = "mentioned you"; +"Scene.Notification.NotificationDescription.PollHasEnded" = "poll has ended"; +"Scene.Notification.NotificationDescription.RebloggedYourPost" = "reblogged your post"; +"Scene.Notification.NotificationDescription.RequestToFollowYou" = "request to follow you"; "Scene.Notification.Title.Everything" = "Alles"; "Scene.Notification.Title.Mentions" = "Vermeldingen"; -"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" = "Voorbeeldweergave Sluiten"; "Scene.Preview.Keyboard.ShowNext" = "Volgende"; "Scene.Preview.Keyboard.ShowPrevious" = "Vorige"; @@ -211,12 +217,18 @@ klik op de link om uw account te bevestigen."; "Scene.Profile.Fields.AddRow" = "Rij Toevoegen"; "Scene.Profile.Fields.Placeholder.Content" = "Inhoud"; "Scene.Profile.Fields.Placeholder.Label" = "Etiket"; -"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Message" = "Bevestig om %@ te deblokkeren"; -"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Title" = "Account niet langer negeren"; +"Scene.Profile.RelationshipActionAlert.ConfirmBlockUser.Message" = "Confirm to block %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmBlockUser.Title" = "Block Account"; +"Scene.Profile.RelationshipActionAlert.ConfirmMuteUser.Message" = "Confirm to mute %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmMuteUser.Title" = "Mute Account"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUser.Message" = "Confirm to unblock %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUser.Title" = "Unblock Account"; "Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Message" = "Bevestig om %@ te negeren"; "Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Title" = "Account Negeren"; +"Scene.Profile.SegmentedControl.About" = "About"; "Scene.Profile.SegmentedControl.Media" = "Media"; "Scene.Profile.SegmentedControl.Posts" = "Berichten"; +"Scene.Profile.SegmentedControl.PostsAndReplies" = "Posts and Replies"; "Scene.Profile.SegmentedControl.Replies" = "Reacties"; "Scene.Register.Error.Item.Agreement" = "Overeenkomst"; "Scene.Register.Error.Item.Email" = "Email"; @@ -242,19 +254,26 @@ klik op de link om uw account te bevestigen."; "Scene.Register.Input.DisplayName.Placeholder" = "weergavenaam"; "Scene.Register.Input.Email.Placeholder" = "email"; "Scene.Register.Input.Invite.RegistrationUserInviteRequest" = "Waarom wil u zich hier registreren?"; +"Scene.Register.Input.Password.Accessibility.Checked" = "checked"; +"Scene.Register.Input.Password.Accessibility.Unchecked" = "unchecked"; +"Scene.Register.Input.Password.CharacterLimit" = "8 characters"; "Scene.Register.Input.Password.Hint" = "Uw wachtwoord moet ten minste acht tekens bevatten"; "Scene.Register.Input.Password.Placeholder" = "wachtwoord"; +"Scene.Register.Input.Password.Require" = "Your password needs at least:"; "Scene.Register.Input.Username.DuplicatePrompt" = "Deze gebruikersnaam is al in gebruik."; "Scene.Register.Input.Username.Placeholder" = "gebruikersnaam"; "Scene.Register.Title" = "Vertel ons over uzelf."; "Scene.Report.Content1" = "Zijn er nog meer berichten die u aan het rapport wilt toevoegen?"; "Scene.Report.Content2" = "Is er iets anders over dit rapport dat de moderators zouden moeten weten?"; +"Scene.Report.ReportSentTitle" = "Thanks for reporting, we’ll look into this."; +"Scene.Report.Reported" = "REPORTED"; "Scene.Report.Send" = "Stuur rapport"; "Scene.Report.SkipToSend" = "Verstuur zonder opmerkingen"; "Scene.Report.Step1" = "Stap 1 van 2"; "Scene.Report.Step2" = "Stap 2 van 2"; "Scene.Report.TextPlaceholder" = "Schrijf of plak aanvullende opmerkingen"; "Scene.Report.Title" = "Rapporteer %@"; +"Scene.Report.TitleReport" = "Report"; "Scene.Search.Recommend.Accounts.Description" = "Misschien dat u geïnteresseerd bent in deze accounts"; "Scene.Search.Recommend.Accounts.Follow" = "Volgen"; "Scene.Search.Recommend.Accounts.Title" = "Interessante accounts voor u"; @@ -295,6 +314,8 @@ klik op de link om uw account te bevestigen."; "Scene.ServerPicker.Label.Category" = "CATEGORIE"; "Scene.ServerPicker.Label.Language" = "TAAL"; "Scene.ServerPicker.Label.Users" = "GEBRUIKERS"; +"Scene.ServerPicker.Subtitle" = "Pick a community based on your interests, region, or a general purpose one."; +"Scene.ServerPicker.SubtitleExtend" = "Pick a community based on your interests, region, or a general purpose one. Each community is operated by an entirely independent organization or individual."; "Scene.ServerPicker.Title" = "Kies een server, welke dan ook."; "Scene.ServerRules.Button.Confirm" = "Ik Ga Akkoord"; "Scene.ServerRules.PrivacyPolicy" = "privacybeleid"; @@ -312,6 +333,11 @@ klik op de link om uw account te bevestigen."; "Scene.Settings.Section.BoringZone.Privacy" = "Privacybeleid"; "Scene.Settings.Section.BoringZone.Terms" = "Servicevoorwaarden"; "Scene.Settings.Section.BoringZone.Title" = "De Saaie Instellingen"; +"Scene.Settings.Section.LookAndFeel.Light" = "Light"; +"Scene.Settings.Section.LookAndFeel.ReallyDark" = "Really Dark"; +"Scene.Settings.Section.LookAndFeel.SortaDark" = "Sorta Dark"; +"Scene.Settings.Section.LookAndFeel.Title" = "Look and Feel"; +"Scene.Settings.Section.LookAndFeel.UseSystem" = "Use System"; "Scene.Settings.Section.Notifications.Boosts" = "Mijn bericht deelt"; "Scene.Settings.Section.Notifications.Favorites" = "Mijn bericht als favoriet toevoegt"; "Scene.Settings.Section.Notifications.Follows" = "Mij volgt"; @@ -335,6 +361,8 @@ 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.GetStarted" = "Get Started"; +"Scene.Welcome.LogIn" = "Log In"; "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."; diff --git a/Mastodon/Resources/nl.lproj/Localizable.stringsdict b/MastodonSDK/Sources/MastodonLocalization/Resources/nl.lproj/Localizable.stringsdict similarity index 100% rename from Mastodon/Resources/nl.lproj/Localizable.stringsdict rename to MastodonSDK/Sources/MastodonLocalization/Resources/nl.lproj/Localizable.stringsdict diff --git a/Mastodon/Resources/ru.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/ru.lproj/Localizable.strings similarity index 90% rename from Mastodon/Resources/ru.lproj/Localizable.strings rename to MastodonSDK/Sources/MastodonLocalization/Resources/ru.lproj/Localizable.strings index 1a4f92fc6..aa05910c0 100644 --- a/Mastodon/Resources/ru.lproj/Localizable.strings +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/ru.lproj/Localizable.strings @@ -4,7 +4,7 @@ "Common.Alerts.CleanCache.Title" = "Очистка кэша"; "Common.Alerts.Common.PleaseTryAgain" = "Пожалуйста, попробуйте ещё раз."; "Common.Alerts.Common.PleaseTryAgainLater" = "Пожалуйста, попробуйте позже."; -"Common.Alerts.DeletePost.Delete" = "Удалить"; +"Common.Alerts.DeletePost.Message" = "Are you sure you want to delete this post?"; "Common.Alerts.DeletePost.Title" = "Вы уверены, что хотите удалить этот пост?"; "Common.Alerts.DiscardPostContent.Message" = "Вы действительно хотите удалить набранное содержимое поста?"; "Common.Alerts.DiscardPostContent.Title" = "Удалить черновик"; @@ -28,7 +28,7 @@ "Common.Controls.Actions.Back" = "Назад"; "Common.Controls.Actions.BlockDomain" = "Заблокировать %@"; "Common.Controls.Actions.Cancel" = "Отмена"; -"Common.Controls.Actions.Compose" = "Compose"; +"Common.Controls.Actions.Compose" = "Написать"; "Common.Controls.Actions.Confirm" = "Подтвердить"; "Common.Controls.Actions.Continue" = "Продолжить"; "Common.Controls.Actions.CopyPhoto" = "Скопировать изображение"; @@ -41,6 +41,7 @@ "Common.Controls.Actions.Next" = "Далее"; "Common.Controls.Actions.Ok" = "ОК"; "Common.Controls.Actions.Open" = "Открыть"; +"Common.Controls.Actions.OpenInBrowser" = "Открыть в браузере"; "Common.Controls.Actions.OpenInSafari" = "Открыть в Safari"; "Common.Controls.Actions.Preview" = "Предпросмотр"; "Common.Controls.Actions.Previous" = "Прошлые"; @@ -93,6 +94,7 @@ "Common.Controls.Keyboard.Timeline.ToggleFavorite" = "Добавить или убрать из избранного"; "Common.Controls.Keyboard.Timeline.ToggleReblog" = "Продвинуть или убрать продвижение"; "Common.Controls.Status.Actions.Favorite" = "Добавить в избранное"; +"Common.Controls.Status.Actions.Hide" = "Hide"; "Common.Controls.Status.Actions.Menu" = "Меню"; "Common.Controls.Status.Actions.Reblog" = "Продвинуть"; "Common.Controls.Status.Actions.Reply" = "Ответить"; @@ -112,6 +114,10 @@ "Common.Controls.Status.Tag.Url" = "Ссылка"; "Common.Controls.Status.UserReblogged" = "%@ продвинул(а)"; "Common.Controls.Status.UserRepliedTo" = "Ответил(а) %@"; +"Common.Controls.Status.Visibility.Direct" = "Only mentioned user can see this post."; +"Common.Controls.Status.Visibility.Private" = "Only their followers can see this post."; +"Common.Controls.Status.Visibility.PrivateFromMe" = "Only my followers can see this post."; +"Common.Controls.Status.Visibility.Unlisted" = "Everyone can see this post but not display in the public timeline."; "Common.Controls.Tabs.Home" = "Главная"; "Common.Controls.Tabs.Notification" = "Уведомление"; "Common.Controls.Tabs.Profile" = "Профиль"; @@ -186,8 +192,8 @@ "Scene.Compose.Visibility.Private" = "Для подписчиков"; "Scene.Compose.Visibility.Public" = "Публичный"; "Scene.Compose.Visibility.Unlisted" = "Скрытый"; -"Scene.ConfirmEmail.Button.DontReceiveEmail" = "Я не получил письма"; "Scene.ConfirmEmail.Button.OpenEmailApp" = "Открыть приложение почты"; +"Scene.ConfirmEmail.Button.Resend" = "Resend"; "Scene.ConfirmEmail.DontReceiveEmail.Description" = "Проверьте, правильно ли указан ваш e-mail адрес, а также папку «спам», если ещё не сделали этого."; "Scene.ConfirmEmail.DontReceiveEmail.ResendEmail" = "Отправить ещё раз"; "Scene.ConfirmEmail.DontReceiveEmail.Title" = "Проверьте свой e-mail адрес"; @@ -210,14 +216,14 @@ "Scene.HomeTimeline.Title" = "Главная"; "Scene.Notification.Keyobard.ShowEverything" = "Показать все"; "Scene.Notification.Keyobard.ShowMentions" = "Показать упоминания"; +"Scene.Notification.NotificationDescription.FavoritedYourPost" = "favorited your post"; +"Scene.Notification.NotificationDescription.FollowedYou" = "followed you"; +"Scene.Notification.NotificationDescription.MentionedYou" = "mentioned you"; +"Scene.Notification.NotificationDescription.PollHasEnded" = "poll has ended"; +"Scene.Notification.NotificationDescription.RebloggedYourPost" = "reblogged your post"; +"Scene.Notification.NotificationDescription.RequestToFollowYou" = "request to follow you"; "Scene.Notification.Title.Everything" = "Все"; "Scene.Notification.Title.Mentions" = "Упоминания"; -"Scene.Notification.UserFavorited Your Post" = "%@ favorited your post"; -"Scene.Notification.UserFollowedYou" = "%@ подписался (-ась)"; -"Scene.Notification.UserMentionedYou" = "%@ упомянул вас"; -"Scene.Notification.UserRebloggedYourPost" = "%@ reblogged your post"; -"Scene.Notification.UserRequestedToFollowYou" = "%@ запрашивает подписку"; -"Scene.Notification.UserYourPollHasEnded" = "%@ Your poll has ended"; "Scene.Preview.Keyboard.ClosePreview" = "Закрыть предпросмотр"; "Scene.Preview.Keyboard.ShowNext" = "Следующее изображение"; "Scene.Preview.Keyboard.ShowPrevious" = "Предыдущее изображение"; @@ -227,12 +233,18 @@ "Scene.Profile.Fields.AddRow" = "Добавить строку"; "Scene.Profile.Fields.Placeholder.Content" = "Содержимое"; "Scene.Profile.Fields.Placeholder.Label" = "Ярлык"; -"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Message" = "Убрать %@ из списка блокировки?"; -"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Title" = "Разблокировать"; +"Scene.Profile.RelationshipActionAlert.ConfirmBlockUser.Message" = "Confirm to block %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmBlockUser.Title" = "Block Account"; +"Scene.Profile.RelationshipActionAlert.ConfirmMuteUser.Message" = "Confirm to mute %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmMuteUser.Title" = "Mute Account"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUser.Message" = "Confirm to unblock %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUser.Title" = "Unblock Account"; "Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Message" = "Убрать %@ из игнорируемых?"; "Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Title" = "Убрать из игнорируемых"; +"Scene.Profile.SegmentedControl.About" = "About"; "Scene.Profile.SegmentedControl.Media" = "Медиа"; "Scene.Profile.SegmentedControl.Posts" = "Посты"; +"Scene.Profile.SegmentedControl.PostsAndReplies" = "Posts and Replies"; "Scene.Profile.SegmentedControl.Replies" = "Ответы"; "Scene.Register.Error.Item.Agreement" = "Соглашение"; "Scene.Register.Error.Item.Email" = "E-mail"; @@ -258,19 +270,26 @@ "Scene.Register.Input.DisplayName.Placeholder" = "отображаемое имя"; "Scene.Register.Input.Email.Placeholder" = "e-mail"; "Scene.Register.Input.Invite.RegistrationUserInviteRequest" = "Почему вы хотите присоединиться?"; +"Scene.Register.Input.Password.Accessibility.Checked" = "checked"; +"Scene.Register.Input.Password.Accessibility.Unchecked" = "unchecked"; +"Scene.Register.Input.Password.CharacterLimit" = "8 characters"; "Scene.Register.Input.Password.Hint" = "Пароль должен содержать не менее восьми символов"; "Scene.Register.Input.Password.Placeholder" = "пароль"; +"Scene.Register.Input.Password.Require" = "Your password needs at least:"; "Scene.Register.Input.Username.DuplicatePrompt" = "Это имя пользователя занято."; "Scene.Register.Input.Username.Placeholder" = "имя пользователя"; "Scene.Register.Title" = "Расскажите нам о себе."; "Scene.Report.Content1" = "Есть ли другие сообщения, которые вы хотите добавить в отчёт?"; "Scene.Report.Content2" = "Есть ли что-то, что модераторы должны знать об этом сообщении?"; +"Scene.Report.ReportSentTitle" = "Thanks for reporting, we’ll look into this."; +"Scene.Report.Reported" = "REPORTED"; "Scene.Report.Send" = "Пожаловаться"; "Scene.Report.SkipToSend" = "Отправить без комментария"; "Scene.Report.Step1" = "Шаг 1 из 2"; "Scene.Report.Step2" = "Шаг 2 из 2"; "Scene.Report.TextPlaceholder" = "Дополнительные комментарии"; "Scene.Report.Title" = "Пожаловаться на %@"; +"Scene.Report.TitleReport" = "Report"; "Scene.Search.Recommend.Accounts.Description" = "Возможно, вы захотите подписаться на эти профили"; "Scene.Search.Recommend.Accounts.Follow" = "Подписаться"; "Scene.Search.Recommend.Accounts.Title" = "Вам может понравится"; @@ -311,6 +330,8 @@ "Scene.ServerPicker.Label.Category" = "КАТЕГОРИЯ"; "Scene.ServerPicker.Label.Language" = "ЯЗЫК"; "Scene.ServerPicker.Label.Users" = "ПОЛЬЗОВАТЕЛИ"; +"Scene.ServerPicker.Subtitle" = "Выберите сообщество на основе своих интересов, региона или общей тематики."; +"Scene.ServerPicker.SubtitleExtend" = "Pick a community based on your interests, region, or a general purpose one. Each community is operated by an entirely independent organization or individual."; "Scene.ServerPicker.Title" = "Выберите сервер, любой сервер."; "Scene.ServerRules.Button.Confirm" = "Принимаю"; @@ -329,6 +350,11 @@ "Scene.Settings.Section.BoringZone.Privacy" = "Политика конфиденциальности"; "Scene.Settings.Section.BoringZone.Terms" = "Условия использования"; "Scene.Settings.Section.BoringZone.Title" = "Зона скукотищи"; +"Scene.Settings.Section.LookAndFeel.Light" = "Light"; +"Scene.Settings.Section.LookAndFeel.ReallyDark" = "Really Dark"; +"Scene.Settings.Section.LookAndFeel.SortaDark" = "Sorta Dark"; +"Scene.Settings.Section.LookAndFeel.Title" = "Look and Feel"; +"Scene.Settings.Section.LookAndFeel.UseSystem" = "Use System"; "Scene.Settings.Section.Notifications.Boosts" = "Продвигает мой пост"; "Scene.Settings.Section.Notifications.Favorites" = "Добавляет мой пост в избранное"; "Scene.Settings.Section.Notifications.Follows" = "Подписался на меня"; @@ -352,6 +378,8 @@ "Scene.SuggestionAccount.Title" = "Подпишитесь на людей"; "Scene.Thread.BackTitle" = "Пост"; "Scene.Thread.Title" = "Пост %@"; +"Scene.Welcome.GetStarted" = "Get Started"; +"Scene.Welcome.LogIn" = "Вход"; "Scene.Welcome.Slogan" = "Социальная сеть под вашим контролем."; "Scene.Wizard.AccessibilityHint" = "Double tap to dismiss this wizard"; diff --git a/Mastodon/Resources/ru.lproj/Localizable.stringsdict b/MastodonSDK/Sources/MastodonLocalization/Resources/ru.lproj/Localizable.stringsdict similarity index 100% rename from Mastodon/Resources/ru.lproj/Localizable.stringsdict rename to MastodonSDK/Sources/MastodonLocalization/Resources/ru.lproj/Localizable.stringsdict diff --git a/MastodonSDK/Sources/MastodonLocalization/Resources/sv_FI.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/sv_FI.lproj/Localizable.strings new file mode 100644 index 000000000..738a9ac00 --- /dev/null +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/sv_FI.lproj/Localizable.strings @@ -0,0 +1,376 @@ +"Common.Alerts.BlockDomain.BlockEntireDomain" = "Estä verkkotunnus"; +"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" = "%@ välimuisti tyhjennetty onnistuneesti."; +"Common.Alerts.CleanCache.Title" = "Puhdista välimuisti"; +"Common.Alerts.Common.PleaseTryAgain" = "Yritä uudelleen."; +"Common.Alerts.Common.PleaseTryAgainLater" = "Yritä uudelleen myöhemmin."; +"Common.Alerts.DeletePost.Message" = "Are you sure you want to delete this post?"; +"Common.Alerts.DeletePost.Title" = "Haluatko varmasti poistaa tämän julkaisun?"; +"Common.Alerts.DiscardPostContent.Message" = "Confirm to discard composed post content."; +"Common.Alerts.DiscardPostContent.Title" = "Hylkää luonnos"; +"Common.Alerts.EditProfileFailure.Message" = "Profiilia ei voida muoka. Yritä uudelleen."; +"Common.Alerts.EditProfileFailure.Title" = "Virhe profiilin muokkauksessa"; +"Common.Alerts.PublishPostFailure.AttachmentsMessage.MoreThanOneVideo" = "Ei voi liittä yhtä videota enempää."; +"Common.Alerts.PublishPostFailure.AttachmentsMessage.VideoAttachWithPhoto" = "Cannot attach a video to a post that already contains images."; +"Common.Alerts.PublishPostFailure.Message" = "Julkaisun julkaiseminen epäonnistui. +Tarkista internet-yhteytesi."; +"Common.Alerts.PublishPostFailure.Title" = "Julkaiseminen epäonnistui"; +"Common.Alerts.SavePhotoFailure.Message" = "Please enable the photo library access permission to save the photo."; +"Common.Alerts.SavePhotoFailure.Title" = "Kuvan tallentaminen epäonnistui"; +"Common.Alerts.ServerError.Title" = "Palvelinvirhe"; +"Common.Alerts.SignOut.Confirm" = "Kirjaudu ulos"; +"Common.Alerts.SignOut.Message" = "Haluatko varmasti kirjautua ulos?"; +"Common.Alerts.SignOut.Title" = "Kirjaudu ulos"; +"Common.Alerts.SignUpFailure.Title" = "Rekisteröinti epäonnistui"; +"Common.Alerts.VoteFailure.PollEnded" = "Kysely on päättynyt"; +"Common.Alerts.VoteFailure.Title" = "Vote Failure"; +"Common.Controls.Actions.Add" = "Lisää"; +"Common.Controls.Actions.Back" = "Takaisin"; +"Common.Controls.Actions.BlockDomain" = "Estä %@"; +"Common.Controls.Actions.Cancel" = "Kumoa"; +"Common.Controls.Actions.Compose" = "Koosta"; +"Common.Controls.Actions.Confirm" = "Vahvista"; +"Common.Controls.Actions.Continue" = "Jatka"; +"Common.Controls.Actions.CopyPhoto" = "Kopioi kuva"; +"Common.Controls.Actions.Delete" = "Poista"; +"Common.Controls.Actions.Discard" = "Hylkää"; +"Common.Controls.Actions.Done" = "Valmis"; +"Common.Controls.Actions.Edit" = "Muokkaa"; +"Common.Controls.Actions.FindPeople" = "Löydä tilejä seurattavaksi"; +"Common.Controls.Actions.ManuallySearch" = "Manually search instead"; +"Common.Controls.Actions.Next" = "Seuraava"; +"Common.Controls.Actions.Ok" = "OK"; +"Common.Controls.Actions.Open" = "Avaa"; +"Common.Controls.Actions.OpenInBrowser" = "Open in Browser"; +"Common.Controls.Actions.OpenInSafari" = "Avaa Safarissa"; +"Common.Controls.Actions.Preview" = "Esikatselu"; +"Common.Controls.Actions.Previous" = "Edellinen"; +"Common.Controls.Actions.Remove" = "Poista"; +"Common.Controls.Actions.Reply" = "Vastaa"; +"Common.Controls.Actions.ReportUser" = "Ilmianna %@"; +"Common.Controls.Actions.Save" = "Tallenna"; +"Common.Controls.Actions.SavePhoto" = "Tallenna kuva"; +"Common.Controls.Actions.SeeMore" = "Näytä lisää"; +"Common.Controls.Actions.Settings" = "Asetukset"; +"Common.Controls.Actions.Share" = "Jaa"; +"Common.Controls.Actions.SharePost" = "Jaa julkaisu"; +"Common.Controls.Actions.ShareUser" = "Jaa %@"; +"Common.Controls.Actions.SignIn" = "Kirjaudu sisään"; +"Common.Controls.Actions.SignUp" = "Rekisteröidy"; +"Common.Controls.Actions.Skip" = "Ohita"; +"Common.Controls.Actions.TakePhoto" = "Ota kuva"; +"Common.Controls.Actions.TryAgain" = "Yritä uudelleen"; +"Common.Controls.Actions.UnblockDomain" = "Poista esto %@"; +"Common.Controls.Friendship.Block" = "Estä"; +"Common.Controls.Friendship.BlockDomain" = "Estä %@"; +"Common.Controls.Friendship.BlockUser" = "Estä %@"; +"Common.Controls.Friendship.Blocked" = "Estetty"; +"Common.Controls.Friendship.EditInfo" = "Muokkaa profiilia"; +"Common.Controls.Friendship.Follow" = "Seuraa"; +"Common.Controls.Friendship.Following" = "Seurataan"; +"Common.Controls.Friendship.Mute" = "Mykistä"; +"Common.Controls.Friendship.MuteUser" = "Mykistä %@"; +"Common.Controls.Friendship.Muted" = "Mykistetty"; +"Common.Controls.Friendship.Pending" = "Pyydetty"; +"Common.Controls.Friendship.Request" = "Pyydä"; +"Common.Controls.Friendship.Unblock" = "Poista esto"; +"Common.Controls.Friendship.UnblockUser" = "Unblock %@"; +"Common.Controls.Friendship.Unmute" = "Poista mykistys"; +"Common.Controls.Friendship.UnmuteUser" = "Poista mykistys tililtä %@"; +"Common.Controls.Keyboard.Common.ComposeNewPost" = "Koosta uusi julkaisu"; +"Common.Controls.Keyboard.Common.OpenSettings" = "Avaa asetukset"; +"Common.Controls.Keyboard.Common.ShowFavorites" = "Näytä suosikit"; +"Common.Controls.Keyboard.Common.SwitchToTab" = "Vaihda %@"; +"Common.Controls.Keyboard.SegmentedControl.NextSection" = "Seuraava lohko"; +"Common.Controls.Keyboard.SegmentedControl.PreviousSection" = "Previous Section"; +"Common.Controls.Keyboard.Timeline.NextStatus" = "Seuraava julkaisu"; +"Common.Controls.Keyboard.Timeline.OpenAuthorProfile" = "Avaa tekijän profiili"; +"Common.Controls.Keyboard.Timeline.OpenRebloggerProfile" = "Avaa edelleen jakajan profiili"; +"Common.Controls.Keyboard.Timeline.OpenStatus" = "Avaa julkaisu"; +"Common.Controls.Keyboard.Timeline.PreviewImage" = "Preview Image"; +"Common.Controls.Keyboard.Timeline.PreviousStatus" = "Edellinen julkaisu"; +"Common.Controls.Keyboard.Timeline.ReplyStatus" = "Vastaa julkaisuun"; +"Common.Controls.Keyboard.Timeline.ToggleContentWarning" = "Vaihda sisältövaroitus"; +"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.Hide" = "Dölj"; +"Common.Controls.Status.Actions.Menu" = "Valikko"; +"Common.Controls.Status.Actions.Reblog" = "Jaa edelleen"; +"Common.Controls.Status.Actions.Reply" = "Vastaa"; +"Common.Controls.Status.Actions.Unfavorite" = "Unfavorite"; +"Common.Controls.Status.Actions.Unreblog" = "Peru edelleen jako"; +"Common.Controls.Status.ContentWarning" = "Sisältövaroitus"; +"Common.Controls.Status.MediaContentWarning" = "Napauta mistä tahansa paljastaaksesi"; +"Common.Controls.Status.Poll.Closed" = "Suljettu"; +"Common.Controls.Status.Poll.Vote" = "Vote"; +"Common.Controls.Status.ShowPost" = "Näytä julkaisu"; +"Common.Controls.Status.ShowUserProfile" = "Näytä tili"; +"Common.Controls.Status.Tag.Email" = "Sähköposti"; +"Common.Controls.Status.Tag.Emoji" = "Emoji"; +"Common.Controls.Status.Tag.Hashtag" = "Hashtagi"; +"Common.Controls.Status.Tag.Link" = "Linkki"; +"Common.Controls.Status.Tag.Mention" = "Mention"; +"Common.Controls.Status.Tag.Url" = "URL"; +"Common.Controls.Status.UserReblogged" = "%@ jakoi edelleen"; +"Common.Controls.Status.UserRepliedTo" = "Vastasi %@:lle"; +"Common.Controls.Status.Visibility.Direct" = "Only mentioned user can see this post."; +"Common.Controls.Status.Visibility.Private" = "Only their followers can see this post."; +"Common.Controls.Status.Visibility.PrivateFromMe" = "Only my followers can see this post."; +"Common.Controls.Status.Visibility.Unlisted" = "Everyone can see this post but not display in the public timeline."; +"Common.Controls.Tabs.Home" = "Koti"; +"Common.Controls.Tabs.Notification" = "Ilmoitus"; +"Common.Controls.Tabs.Profile" = "Profiili"; +"Common.Controls.Tabs.Search" = "Haku"; +"Common.Controls.Timeline.Filtered" = "Suodatettu"; +"Common.Controls.Timeline.Header.BlockedWarning" = "Et voi tarkastella tämän tilin profiilia +ennen kuin hän poistaa eston."; +"Common.Controls.Timeline.Header.BlockingWarning" = "Et voi tarkastella tämän tilin profiilia +ennen kuin poistat sen esto. +Profiilisi näyttää tältä hänelle."; +"Common.Controls.Timeline.Header.NoStatusFound" = "Julkaisua ei löytynyt"; +"Common.Controls.Timeline.Header.SuspendedWarning" = "Tämä tili on lakkautettu."; +"Common.Controls.Timeline.Header.UserBlockedWarning" = "Et voi tarkastella tilin %@ profiilia +ennen kuin hän poistaa eston."; +"Common.Controls.Timeline.Header.UserBlockingWarning" = "Et voi tarkastella tilin %@ profiilia +ennen kuin poistat sen esto. +Profiilisi näyttää tältä hänelle."; +"Common.Controls.Timeline.Header.UserSuspendedWarning" = "Tili %@ on lakkautettu."; +"Common.Controls.Timeline.Loader.LoadMissingPosts" = "Lataa puuttuvat julkaisut"; +"Common.Controls.Timeline.Loader.LoadingMissingPosts" = "Ladataan puuttuvia julkaisuja..."; +"Common.Controls.Timeline.Loader.ShowMoreReplies" = "Näytä lisää vastauksia"; +"Common.Controls.Timeline.Timestamp.Now" = "Nyt"; +"Scene.AccountList.AddAccount" = "Lisää tili"; +"Scene.AccountList.DismissAccountSwitcher" = "Sulje tilin vaihtaja"; +"Scene.AccountList.TabBarHint" = "Nykyinen valittu profiili: %@. Kaksoisnapauta ja pidä sitten painettuna näytääksesi tilin vaihtajan"; +"Scene.Compose.Accessibility.AppendAttachment" = "Lisää liite"; +"Scene.Compose.Accessibility.AppendPoll" = "Lisää kysely"; +"Scene.Compose.Accessibility.CustomEmojiPicker" = "Mukautettu emojivalitsin"; +"Scene.Compose.Accessibility.DisableContentWarning" = "Poista sisältövaroitus käytöstä"; +"Scene.Compose.Accessibility.EnableContentWarning" = "Ota sisältövaroitus käyttöön"; +"Scene.Compose.Accessibility.PostVisibilityMenu" = "Julkaisun näkyvyysvalikko"; +"Scene.Compose.Accessibility.RemovePoll" = "Poista kysely"; +"Scene.Compose.Attachment.AttachmentBroken" = "This %@ is broken and can’t be +uploaded to Mastodon."; +"Scene.Compose.Attachment.DescriptionPhoto" = "Kuvaile kuva näkövammaisille..."; +"Scene.Compose.Attachment.DescriptionVideo" = "Kuvaile video näkövammaisille..."; +"Scene.Compose.Attachment.Photo" = "kuva"; +"Scene.Compose.Attachment.Video" = "video"; +"Scene.Compose.AutoComplete.SpaceToAdd" = "Space to add"; +"Scene.Compose.ComposeAction" = "Julkaise"; +"Scene.Compose.ContentInputPlaceholder" = "Kirjoita tai liitä, siitä mitä ajattelet"; +"Scene.Compose.ContentWarning.Placeholder" = "Kirjoita tarkka varoitus tähän..."; +"Scene.Compose.Keyboard.AppendAttachmentEntry" = "Lisää liite - %@"; +"Scene.Compose.Keyboard.DiscardPost" = "Hylkää julkaisu"; +"Scene.Compose.Keyboard.PublishPost" = "Julkaise julkaisu"; +"Scene.Compose.Keyboard.SelectVisibilityEntry" = "Valitse näkyvyys - %@"; +"Scene.Compose.Keyboard.ToggleContentWarning" = "Vaihda sisältövaroitus"; +"Scene.Compose.Keyboard.TogglePoll" = "Vaihda kysely"; +"Scene.Compose.MediaSelection.Browse" = "Selaa"; +"Scene.Compose.MediaSelection.Camera" = "Ota kuva"; +"Scene.Compose.MediaSelection.PhotoLibrary" = "Kuvakirjasto"; +"Scene.Compose.Poll.DurationTime" = "Kesto: %@"; +"Scene.Compose.Poll.OneDay" = "1 päivä"; +"Scene.Compose.Poll.OneHour" = "1 tunti"; +"Scene.Compose.Poll.OptionNumber" = "Vaihtoehto %ld"; +"Scene.Compose.Poll.SevenDays" = "7 päivää"; +"Scene.Compose.Poll.SixHours" = "6 tuntia"; +"Scene.Compose.Poll.ThirtyMinutes" = "30 minuuttia"; +"Scene.Compose.Poll.ThreeDays" = "3 päivää"; +"Scene.Compose.ReplyingToUser" = "vastaamassa tilille %@"; +"Scene.Compose.Title.NewPost" = "Uusi julkaisu"; +"Scene.Compose.Title.NewReply" = "Uusi vastaus"; +"Scene.Compose.Visibility.Direct" = "Vain mainitsemani tilit"; +"Scene.Compose.Visibility.Private" = "Vain seuraajat"; +"Scene.Compose.Visibility.Public" = "Julkinen"; +"Scene.Compose.Visibility.Unlisted" = "Listaamaton"; +"Scene.ConfirmEmail.Button.OpenEmailApp" = "Avaa sähköpostisovellus"; +"Scene.ConfirmEmail.Button.Resend" = "Resend"; +"Scene.ConfirmEmail.DontReceiveEmail.Description" = "Tarkista, että sähköpostiosoitteesi on oikea, sekä roskapostikansiosi, jos et vielä ole."; +"Scene.ConfirmEmail.DontReceiveEmail.ResendEmail" = "Lähetä sähköposti uudelleen"; +"Scene.ConfirmEmail.DontReceiveEmail.Title" = "Tarkista sähköpostisi"; +"Scene.ConfirmEmail.OpenEmailApp.Description" = "Lähetimme sinulle juuri sähköpostin. Tarkista myös roskapostikansiosi, jos et vielä ole."; +"Scene.ConfirmEmail.OpenEmailApp.Mail" = "Sähköposti"; +"Scene.ConfirmEmail.OpenEmailApp.OpenEmailClient" = "Avaa sähköpostisovellus"; +"Scene.ConfirmEmail.OpenEmailApp.Title" = "Tarkasta postilaatikkosi."; +"Scene.ConfirmEmail.Subtitle" = "Lähetimme juuri sähköpostin osoitteeseen %@, napauta siinä olevaa linkkiä vahvistaaksesi tilisi."; +"Scene.ConfirmEmail.Title" = "Viimeinen asia."; +"Scene.Favorite.Title" = "Omat suosikit"; +"Scene.Follower.Footer" = "Seuraajia muilta palvelimilta ei näytetä."; +"Scene.Following.Footer" = "Seurauksia muilta palvelimilta ei näytetä."; +"Scene.HomeTimeline.NavigationBarState.NewPosts" = "Uusia julkaisuja"; +"Scene.HomeTimeline.NavigationBarState.Offline" = "Yhteydetön"; +"Scene.HomeTimeline.NavigationBarState.Published" = "Julkaistu!"; +"Scene.HomeTimeline.NavigationBarState.Publishing" = "Julkaistaan julkaisua..."; +"Scene.HomeTimeline.Title" = "Koti"; +"Scene.Notification.Keyobard.ShowEverything" = "Näytä kaikki"; +"Scene.Notification.Keyobard.ShowMentions" = "Näytä maininnat"; +"Scene.Notification.NotificationDescription.FavoritedYourPost" = "favorited your post"; +"Scene.Notification.NotificationDescription.FollowedYou" = "followed you"; +"Scene.Notification.NotificationDescription.MentionedYou" = "nämnde dig"; +"Scene.Notification.NotificationDescription.PollHasEnded" = "poll has ended"; +"Scene.Notification.NotificationDescription.RebloggedYourPost" = "reblogged your post"; +"Scene.Notification.NotificationDescription.RequestToFollowYou" = "request to follow you"; +"Scene.Notification.Title.Everything" = "Kaikki"; +"Scene.Notification.Title.Mentions" = "Maininnat"; +"Scene.Preview.Keyboard.ClosePreview" = "Sulje esikatselu"; +"Scene.Preview.Keyboard.ShowNext" = "Näytä seuraava"; +"Scene.Preview.Keyboard.ShowPrevious" = "Näytä edellinen"; +"Scene.Profile.Dashboard.Followers" = "seuraajat"; +"Scene.Profile.Dashboard.Following" = "seurataan"; +"Scene.Profile.Dashboard.Posts" = "julkaisut"; +"Scene.Profile.Fields.AddRow" = "Lisää rivi"; +"Scene.Profile.Fields.Placeholder.Content" = "Sisältö"; +"Scene.Profile.Fields.Placeholder.Label" = "Nimi"; +"Scene.Profile.RelationshipActionAlert.ConfirmBlockUser.Message" = "Confirm to block %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmBlockUser.Title" = "Block Account"; +"Scene.Profile.RelationshipActionAlert.ConfirmMuteUser.Message" = "Confirm to mute %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmMuteUser.Title" = "Mute Account"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUser.Message" = "Confirm to unblock %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUser.Title" = "Unblock Account"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Message" = "Vahvista, että haluat poistaa mykistyksen tililtä %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Title" = "Poista tilin mykistys"; +"Scene.Profile.SegmentedControl.About" = "Om"; +"Scene.Profile.SegmentedControl.Media" = "Media"; +"Scene.Profile.SegmentedControl.Posts" = "Julkaisut"; +"Scene.Profile.SegmentedControl.PostsAndReplies" = "Posts and Replies"; +"Scene.Profile.SegmentedControl.Replies" = "Vastaukset"; +"Scene.Register.Error.Item.Agreement" = "Hyväksy"; +"Scene.Register.Error.Item.Email" = "Sähköposti"; +"Scene.Register.Error.Item.Locale" = "Locale"; +"Scene.Register.Error.Item.Password" = "Salasana"; +"Scene.Register.Error.Item.Reason" = "Syy"; +"Scene.Register.Error.Item.Username" = "Käyttäjänimi"; +"Scene.Register.Error.Reason.Accepted" = "%@ täytyy hyväksyä"; +"Scene.Register.Error.Reason.Blank" = "%@ vaaditaan"; +"Scene.Register.Error.Reason.Blocked" = "%@ sisältää estetyn sähköpostipalveluntarjoajan"; +"Scene.Register.Error.Reason.Inclusion" = "%@ ei ole tuettu arvo"; +"Scene.Register.Error.Reason.Invalid" = "%@ on virheellinen"; +"Scene.Register.Error.Reason.Reserved" = "%@ is a reserved keyword"; +"Scene.Register.Error.Reason.Taken" = "%@ on jo käytössä"; +"Scene.Register.Error.Reason.TooLong" = "%@ on liian pitkä"; +"Scene.Register.Error.Reason.TooShort" = "%@ on liian lyhyt"; +"Scene.Register.Error.Reason.Unreachable" = "%@ ei näytä olevan olemassa"; +"Scene.Register.Error.Special.EmailInvalid" = "Tämä ei ole kelvollinen sähköpostiosoite"; +"Scene.Register.Error.Special.PasswordTooShort" = "Salasana on liian lyhyt (täytyy olla vähintään 8 merkkiä)"; +"Scene.Register.Error.Special.UsernameInvalid" = "Käyttäjänimi voi sisältää ainoastaan aakkosnumerrisia merkkejä ja alaviivoja"; +"Scene.Register.Error.Special.UsernameTooLong" = "Käyttäjänimi on liian pitkä (ei voi olla pidempi kuin 30 merkkiä)"; +"Scene.Register.Input.Avatar.Delete" = "Poista"; +"Scene.Register.Input.DisplayName.Placeholder" = "näyttönimi"; +"Scene.Register.Input.Email.Placeholder" = "sähköposti"; +"Scene.Register.Input.Invite.RegistrationUserInviteRequest" = "Miksi haluat liittyä?"; +"Scene.Register.Input.Password.Accessibility.Checked" = "checked"; +"Scene.Register.Input.Password.Accessibility.Unchecked" = "unchecked"; +"Scene.Register.Input.Password.CharacterLimit" = "8 characters"; +"Scene.Register.Input.Password.Hint" = "Salasanassasi on oltava vähintään kahdeksan merkkiä"; +"Scene.Register.Input.Password.Placeholder" = "salasana"; +"Scene.Register.Input.Password.Require" = "Your password needs at least:"; +"Scene.Register.Input.Username.DuplicatePrompt" = "Tämä käyttäjänimi on varattu."; +"Scene.Register.Input.Username.Placeholder" = "käyttäjänimi"; +"Scene.Register.Title" = "Kerro meille sinusta."; +"Scene.Report.Content1" = "Onko julkaisuja, joita haluaisit lisätä ilmiantoon?"; +"Scene.Report.Content2" = "Onko valvojien syytä tietää tästä ilmiannosta?"; +"Scene.Report.ReportSentTitle" = "Thanks for reporting, we’ll look into this."; +"Scene.Report.Reported" = "REPORTED"; +"Scene.Report.Send" = "Lähetä ilmianto"; +"Scene.Report.SkipToSend" = "Lähetä ilman kommentteja"; +"Scene.Report.Step1" = "Vaihe 1/2"; +"Scene.Report.Step2" = "Vaihe 2/2"; +"Scene.Report.TextPlaceholder" = "Kirjoita tai liitä lisäkommentteja"; +"Scene.Report.Title" = "Ilmianna %@"; +"Scene.Report.TitleReport" = "Report"; +"Scene.Search.Recommend.Accounts.Description" = "Haluta ehkä seurata näitä tilejä"; +"Scene.Search.Recommend.Accounts.Follow" = "Seuraa"; +"Scene.Search.Recommend.Accounts.Title" = "Saatat pitää näistä tileistä"; +"Scene.Search.Recommend.ButtonText" = "Katso kaikki"; +"Scene.Search.Recommend.HashTag.Description" = "Hashtagit, jotka saavat melkoisesti huomiota"; +"Scene.Search.Recommend.HashTag.PeopleTalking" = "%@ ihmistä puhuu"; +"Scene.Search.Recommend.HashTag.Title" = "Trendaavat Mastodonissa"; +"Scene.Search.SearchBar.Cancel" = "Kumoa"; +"Scene.Search.SearchBar.Placeholder" = "Haku"; +"Scene.Search.Searching.Clear" = "Tyhjennä"; +"Scene.Search.Searching.EmptyState.NoResults" = "Ei hakutuloksia"; +"Scene.Search.Searching.RecentSearch" = "Viimeaikaiset"; +"Scene.Search.Searching.Segment.All" = "Kaikki"; +"Scene.Search.Searching.Segment.Hashtags" = "Hashtagit"; +"Scene.Search.Searching.Segment.People" = "Tilit"; +"Scene.Search.Searching.Segment.Posts" = "Julkaisut"; +"Scene.Search.Title" = "Haku"; +"Scene.ServerPicker.Button.Category.Academia" = "akateeminen"; +"Scene.ServerPicker.Button.Category.Activism" = "aktivismi"; +"Scene.ServerPicker.Button.Category.All" = "Kaikki"; +"Scene.ServerPicker.Button.Category.AllAccessiblityDescription" = "Kategoria: Kaikki"; +"Scene.ServerPicker.Button.Category.Art" = "taide"; +"Scene.ServerPicker.Button.Category.Food" = "ruoka"; +"Scene.ServerPicker.Button.Category.Furry" = "turri"; +"Scene.ServerPicker.Button.Category.Games" = "pelit"; +"Scene.ServerPicker.Button.Category.General" = "yleinen"; +"Scene.ServerPicker.Button.Category.Journalism" = "journalismi"; +"Scene.ServerPicker.Button.Category.Lgbt" = "hlbt"; +"Scene.ServerPicker.Button.Category.Music" = "musiikki"; +"Scene.ServerPicker.Button.Category.Regional" = "alueellinen"; +"Scene.ServerPicker.Button.Category.Tech" = "tekniikka"; +"Scene.ServerPicker.Button.SeeLess" = "Näytä vähemmän"; +"Scene.ServerPicker.Button.SeeMore" = "Näytä lisää"; +"Scene.ServerPicker.EmptyState.BadNetwork" = "Jokin meni pieleen dataa ladatessa. Tarkista internet-yhteytesi."; +"Scene.ServerPicker.EmptyState.FindingServers" = "Etsistään saatavilla olevia palvelimia..."; +"Scene.ServerPicker.EmptyState.NoResults" = "Ei hakutuloksia"; +"Scene.ServerPicker.Input.Placeholder" = "Etsi palvelin tai liity omaan..."; +"Scene.ServerPicker.Label.Category" = "KATEGORIA"; +"Scene.ServerPicker.Label.Language" = "KIELI"; +"Scene.ServerPicker.Label.Users" = "TILIÄ"; +"Scene.ServerPicker.Subtitle" = "Pick a community based on your interests, region, or a general purpose one."; +"Scene.ServerPicker.SubtitleExtend" = "Pick a community based on your interests, region, or a general purpose one. Each community is operated by an entirely independent organization or individual."; +"Scene.ServerPicker.Title" = "Valitse palvelin, +mikä tahansa palvelin."; +"Scene.ServerRules.Button.Confirm" = "Hyväksyn"; +"Scene.ServerRules.PrivacyPolicy" = "tietosuojakäytäntö"; +"Scene.ServerRules.Prompt" = "Jatkamalla, hyväksyt palvelun %@ palveluehdot ja tietosuojakäytönnön."; +"Scene.ServerRules.Subtitle" = "Nämä säännöt ovat %@ -palvelun asettamia."; +"Scene.ServerRules.TermsOfService" = "käyttöehdot"; +"Scene.ServerRules.Title" = "Joitakin perussääntöjä."; +"Scene.Settings.Footer.MastodonDescription" = "Mastodon on avoimen lähdekoodin ohjelmisto. Voit raportoida ongelmasta GitHubissa osoitteessa %@ (%@)"; +"Scene.Settings.Keyboard.CloseSettingsWindow" = "Sulje asetukset"; +"Scene.Settings.Section.Appearance.Automatic" = "Seuraa järjestelmää"; +"Scene.Settings.Section.Appearance.Dark" = "Tumma"; +"Scene.Settings.Section.Appearance.Light" = "Vaalea"; +"Scene.Settings.Section.Appearance.Title" = "Ulkoasu"; +"Scene.Settings.Section.BoringZone.AccountSettings" = "Tiliasetukset"; +"Scene.Settings.Section.BoringZone.Privacy" = "Tietosuojakäytäntö"; +"Scene.Settings.Section.BoringZone.Terms" = "Palveluehdot"; +"Scene.Settings.Section.BoringZone.Title" = "Tylsä alue"; +"Scene.Settings.Section.LookAndFeel.Light" = "Ljust"; +"Scene.Settings.Section.LookAndFeel.ReallyDark" = "Really Dark"; +"Scene.Settings.Section.LookAndFeel.SortaDark" = "Sorta Dark"; +"Scene.Settings.Section.LookAndFeel.Title" = "Look and Feel"; +"Scene.Settings.Section.LookAndFeel.UseSystem" = "Use System"; +"Scene.Settings.Section.Notifications.Boosts" = "Omien julkaisujen edelleen jaot"; +"Scene.Settings.Section.Notifications.Favorites" = "Favorites my post"; +"Scene.Settings.Section.Notifications.Follows" = "Seuraa minua"; +"Scene.Settings.Section.Notifications.Mentions" = "Mainitsee minut"; +"Scene.Settings.Section.Notifications.Title" = "Ilmoitukset"; +"Scene.Settings.Section.Notifications.Trigger.Anyone" = "kuka tahansa"; +"Scene.Settings.Section.Notifications.Trigger.Follow" = "kuka tahansa, jota seuraan"; +"Scene.Settings.Section.Notifications.Trigger.Follower" = "seuraaja"; +"Scene.Settings.Section.Notifications.Trigger.Noone" = "ei kukaan"; +"Scene.Settings.Section.Notifications.Trigger.Title" = "Ilmoita minulle, kun"; +"Scene.Settings.Section.Preference.DisableAvatarAnimation" = "Poista käytöstä animoidut avatarit"; +"Scene.Settings.Section.Preference.DisableEmojiAnimation" = "Poista käytöstä animoidut emojit"; +"Scene.Settings.Section.Preference.Title" = "Lisäasetukset"; +"Scene.Settings.Section.Preference.TrueBlackDarkMode" = "Todellinen mustan tumma tila"; +"Scene.Settings.Section.Preference.UsingDefaultBrowser" = "Käytä oletusselainta linkkien avaamiseen"; +"Scene.Settings.Section.SpicyZone.Clear" = "Tyhjennä median välimuisti"; +"Scene.Settings.Section.SpicyZone.Signout" = "Kirjaudu ulos"; +"Scene.Settings.Section.SpicyZone.Title" = "Varovainen alue"; +"Scene.Settings.Title" = "Asetukset"; +"Scene.SuggestionAccount.FollowExplain" = "Kun seuraat jotakuta, näet hänen julkaisunsa kotisyötteessäsi."; +"Scene.SuggestionAccount.Title" = "Löydä tilejä seurattavaksi"; +"Scene.Thread.BackTitle" = "Julkaisu"; +"Scene.Thread.Title" = "Julkaisu tililtä %@"; +"Scene.Welcome.GetStarted" = "Kom igång"; +"Scene.Welcome.LogIn" = "Logga in"; +"Scene.Welcome.Slogan" = "Sosiaalinen verkostoituminen +takaisin käsissäsi."; +"Scene.Wizard.AccessibilityHint" = "Hylkää tämä ohjattu toiminto kaksoisnapauttamalla"; +"Scene.Wizard.MultipleAccountSwitchIntroDescription" = "Vaihda useiden tilien välillä pitämällä profiilipainiketta painettuna."; +"Scene.Wizard.NewInMastodon" = "Uutta Mastodonissa"; \ No newline at end of file diff --git a/MastodonSDK/Sources/MastodonLocalization/Resources/sv_FI.lproj/Localizable.stringsdict b/MastodonSDK/Sources/MastodonLocalization/Resources/sv_FI.lproj/Localizable.stringsdict new file mode 100644 index 000000000..eec977a68 --- /dev/null +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/sv_FI.lproj/Localizable.stringsdict @@ -0,0 +1,390 @@ +<?xml version="1.0" encoding="utf-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> + <dict> + <key>a11y.plural.count.unread.notification</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@notification_count_unread_notification@</string> + <key>notification_count_unread_notification</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 lukematon ilmoitus</string> + <key>other</key> + <string>%ld lukematonta ilmoitusta</string> + </dict> + </dict> + <key>a11y.plural.count.input_limit_exceeds</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>Syöterajoitus ylittyy %#@character_count@</string> + <key>character_count</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 merkki</string> + <key>other</key> + <string>%ld merkkiä</string> + </dict> + </dict> + <key>a11y.plural.count.input_limit_remains</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>Syöterajoitus ylittyy %#@character_count@ päästä</string> + <key>character_count</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 merkki</string> + <key>other</key> + <string>%ld merkkiä</string> + </dict> + </dict> + <key>plural.count.metric_formatted.post</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%@ %#@post_count@</string> + <key>post_count</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>julkaisu</string> + <key>other</key> + <string>julkaisut</string> + </dict> + </dict> + <key>plural.count.post</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@post_count@</string> + <key>post_count</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 julkaisu</string> + <key>other</key> + <string>%ld julkaisua</string> + </dict> + </dict> + <key>plural.count.favorite</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@favorite_count@</string> + <key>favorite_count</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 suosikki</string> + <key>other</key> + <string>%ld suosikkia</string> + </dict> + </dict> + <key>plural.count.reblog</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@reblog_count@</string> + <key>reblog_count</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 edelleen jako</string> + <key>other</key> + <string>%ld edelleen jakoa</string> + </dict> + </dict> + <key>plural.count.vote</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@vote_count@</string> + <key>vote_count</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 ääni</string> + <key>other</key> + <string>%ld ääntä</string> + </dict> + </dict> + <key>plural.count.voter</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@voter_count@</string> + <key>voter_count</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 vastaaja</string> + <key>other</key> + <string>%ld vastaajaa</string> + </dict> + </dict> + <key>plural.people_talking</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_people_talking@</string> + <key>count_people_talking</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 ihminen puhuu</string> + <key>other</key> + <string>%ld ihmistä puhuu</string> + </dict> + </dict> + <key>plural.count.following</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_following@</string> + <key>count_following</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 seurataan</string> + <key>other</key> + <string>%ld seurataan</string> + </dict> + </dict> + <key>plural.count.follower</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_follower@</string> + <key>count_follower</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 seuraaja</string> + <key>other</key> + <string>%ld seuraajaa</string> + </dict> + </dict> + <key>date.year.left</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_year_left@</string> + <key>count_year_left</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 vuosi jäljellä</string> + <key>other</key> + <string>%ld vuotta jäljellä</string> + </dict> + </dict> + <key>date.month.left</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_month_left@</string> + <key>count_month_left</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 kuukausi jäljellä</string> + <key>other</key> + <string>%ld kuukautta jäljellä</string> + </dict> + </dict> + <key>date.day.left</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_day_left@</string> + <key>count_day_left</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 päivä jäljellä</string> + <key>other</key> + <string>%ld päivää jäljellä</string> + </dict> + </dict> + <key>date.hour.left</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_hour_left@</string> + <key>count_hour_left</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 tunti jäljellä</string> + <key>other</key> + <string>%ld tuntia jäljellä</string> + </dict> + </dict> + <key>date.minute.left</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_minute_left@</string> + <key>count_minute_left</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 minuutti jäljellä</string> + <key>other</key> + <string>%ld minuuttia jäljellä</string> + </dict> + </dict> + <key>date.second.left</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_second_left@</string> + <key>count_second_left</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1 sekuntti</string> + <key>other</key> + <string>%ld sekunttia jäljellä</string> + </dict> + </dict> + <key>date.year.ago.abbr</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_year_ago_abbr@</string> + <key>count_year_ago_abbr</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1v sitten</string> + <key>other</key> + <string>%ldv sitten</string> + </dict> + </dict> + <key>date.month.ago.abbr</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_month_ago_abbr@</string> + <key>count_month_ago_abbr</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1kk sitten</string> + <key>other</key> + <string>%ldkk sitten</string> + </dict> + </dict> + <key>date.day.ago.abbr</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_day_ago_abbr@</string> + <key>count_day_ago_abbr</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1pv sitten</string> + <key>other</key> + <string>%ldpv sitten</string> + </dict> + </dict> + <key>date.hour.ago.abbr</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_hour_ago_abbr@</string> + <key>count_hour_ago_abbr</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1t sitten</string> + <key>other</key> + <string>%ldt sitten</string> + </dict> + </dict> + <key>date.minute.ago.abbr</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_minute_ago_abbr@</string> + <key>count_minute_ago_abbr</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1min sitten</string> + <key>other</key> + <string>%ldmin sitten</string> + </dict> + </dict> + <key>date.second.ago.abbr</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@count_second_ago_abbr@</string> + <key>count_second_ago_abbr</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>ld</string> + <key>one</key> + <string>1s sitten</string> + <key>other</key> + <string>%lds sitten</string> + </dict> + </dict> + </dict> +</plist> diff --git a/Mastodon/Resources/th.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/th.lproj/Localizable.strings similarity index 84% rename from Mastodon/Resources/th.lproj/Localizable.strings rename to MastodonSDK/Sources/MastodonLocalization/Resources/th.lproj/Localizable.strings index 1bd954fe5..c79bb681d 100644 --- a/Mastodon/Resources/th.lproj/Localizable.strings +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/th.lproj/Localizable.strings @@ -4,8 +4,8 @@ "Common.Alerts.CleanCache.Title" = "ล้างแคช"; "Common.Alerts.Common.PleaseTryAgain" = "โปรดลองอีกครั้ง"; "Common.Alerts.Common.PleaseTryAgainLater" = "โปรดลองอีกครั้งในภายหลัง"; -"Common.Alerts.DeletePost.Delete" = "ลบ"; -"Common.Alerts.DeletePost.Title" = "คุณแน่ใจหรือไม่ว่าต้องการลบโพสต์นี้?"; +"Common.Alerts.DeletePost.Message" = "คุณแน่ใจหรือไม่ว่าต้องการลบโพสต์นี้?"; +"Common.Alerts.DeletePost.Title" = "ลบโพสต์"; "Common.Alerts.DiscardPostContent.Message" = "ยืนยันที่จะละทิ้งเนื้อหาโพสต์ที่เขียน"; "Common.Alerts.DiscardPostContent.Title" = "ละทิ้งแบบร่าง"; "Common.Alerts.EditProfileFailure.Message" = "ไม่สามารถแก้ไขโปรไฟล์ โปรดลองอีกครั้ง"; @@ -41,6 +41,7 @@ "Common.Controls.Actions.Next" = "ถัดไป"; "Common.Controls.Actions.Ok" = "ตกลง"; "Common.Controls.Actions.Open" = "เปิด"; +"Common.Controls.Actions.OpenInBrowser" = "เปิดในเบราว์เซอร์"; "Common.Controls.Actions.OpenInSafari" = "เปิดใน Safari"; "Common.Controls.Actions.Preview" = "แสดงตัวอย่าง"; "Common.Controls.Actions.Previous" = "ก่อนหน้า"; @@ -93,6 +94,7 @@ "Common.Controls.Keyboard.Timeline.ToggleFavorite" = "เปิด/ปิดรายการโปรดในโพสต์"; "Common.Controls.Keyboard.Timeline.ToggleReblog" = "เปิด/ปิดการดันในโพสต์"; "Common.Controls.Status.Actions.Favorite" = "ชื่นชอบ"; +"Common.Controls.Status.Actions.Hide" = "ซ่อน"; "Common.Controls.Status.Actions.Menu" = "เมนู"; "Common.Controls.Status.Actions.Reblog" = "ดัน"; "Common.Controls.Status.Actions.Reply" = "ตอบกลับ"; @@ -112,23 +114,27 @@ "Common.Controls.Status.Tag.Url" = "URL"; "Common.Controls.Status.UserReblogged" = "%@ ได้ดัน"; "Common.Controls.Status.UserRepliedTo" = "ตอบกลับ %@"; +"Common.Controls.Status.Visibility.Direct" = "เฉพาะผู้ใช้ที่กล่าวถึงเท่านั้นที่สามารถเห็นโพสต์นี้"; +"Common.Controls.Status.Visibility.Private" = "เฉพาะผู้ติดตามของเขาเท่านั้นที่สามารถเห็นโพสต์นี้"; +"Common.Controls.Status.Visibility.PrivateFromMe" = "เฉพาะผู้ติดตามของฉันเท่านั้นที่สามารถเห็นโพสต์นี้"; +"Common.Controls.Status.Visibility.Unlisted" = "ทุกคนสามารถเห็นโพสต์นี้แต่ไม่แสดงในเส้นเวลาสาธารณะ"; "Common.Controls.Tabs.Home" = "หน้าแรก"; "Common.Controls.Tabs.Notification" = "การแจ้งเตือน"; "Common.Controls.Tabs.Profile" = "โปรไฟล์"; "Common.Controls.Tabs.Search" = "ค้นหา"; "Common.Controls.Timeline.Filtered" = "กรองอยู่"; "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" = "คุณไม่สามารถดูโปรไฟล์ของ %@ -จนกว่าผู้ใช้นี้จะเลิกปิดกั้นคุณ"; +จนกว่าเขาจะเลิกปิดกั้นคุณ"; "Common.Controls.Timeline.Header.UserBlockingWarning" = "คุณไม่สามารถดูโปรไฟล์ของ %@ -จนกว่าคุณจะเลิกปิดกั้นผู้ใช้นี้ -ผู้ใช้นี้เห็นโปรไฟล์ของคุณเหมือนกับที่คุณเห็น"; +จนกว่าคุณจะเลิกปิดกั้นเขา +โปรไฟล์ของคุณมีลักษณะเช่นนี้สำหรับเขา"; "Common.Controls.Timeline.Header.UserSuspendedWarning" = "บัญชีของ %@ ถูกระงับการใช้งาน"; "Common.Controls.Timeline.Loader.LoadMissingPosts" = "โหลดโพสต์ที่ขาดหายไป"; "Common.Controls.Timeline.Loader.LoadingMissingPosts" = "กำลังโหลดโพสต์ที่ขาดหายไป..."; @@ -178,8 +184,8 @@ "Scene.Compose.Visibility.Private" = "ผู้ติดตามเท่านั้น"; "Scene.Compose.Visibility.Public" = "สาธารณะ"; "Scene.Compose.Visibility.Unlisted" = "ไม่อยู่ในรายการ"; -"Scene.ConfirmEmail.Button.DontReceiveEmail" = "ฉันไม่เคยได้รับอีเมล"; "Scene.ConfirmEmail.Button.OpenEmailApp" = "เปิดแอปอีเมล"; +"Scene.ConfirmEmail.Button.Resend" = "ส่งใหม่"; "Scene.ConfirmEmail.DontReceiveEmail.Description" = "หากคุณยังไม่ได้รับอีเมล ตรวจสอบว่าที่อยู่อีเมลของคุณถูกต้อง รวมถึงโฟลเดอร์อีเมลขยะของคุณ"; "Scene.ConfirmEmail.DontReceiveEmail.ResendEmail" = "ส่งอีเมลใหม่"; "Scene.ConfirmEmail.DontReceiveEmail.Title" = "ตรวจสอบอีเมลของคุณ"; @@ -187,8 +193,7 @@ "Scene.ConfirmEmail.OpenEmailApp.Mail" = "จดหมาย"; "Scene.ConfirmEmail.OpenEmailApp.OpenEmailClient" = "เปิดไคลเอ็นต์อีเมล"; "Scene.ConfirmEmail.OpenEmailApp.Title" = "ตรวจสอบกล่องขาเข้าของคุณ"; -"Scene.ConfirmEmail.Subtitle" = "เราเพิ่งส่งอีเมลไปยัง %@ -แตะที่ลิงก์เพื่อยืนยันบัญชีของคุณ"; +"Scene.ConfirmEmail.Subtitle" = "แตะลิงก์ที่เราส่งอีเมลถึงคุณเพื่อยืนยันบัญชีของคุณ"; "Scene.ConfirmEmail.Title" = "หนึ่งสิ่งสุดท้าย"; "Scene.Favorite.Title" = "รายการโปรดของคุณ"; "Scene.Follower.Footer" = "ไม่ได้แสดงผู้ติดตามจากเซิร์ฟเวอร์อื่น ๆ"; @@ -200,14 +205,14 @@ "Scene.HomeTimeline.Title" = "หน้าแรก"; "Scene.Notification.Keyobard.ShowEverything" = "แสดงทุกอย่าง"; "Scene.Notification.Keyobard.ShowMentions" = "แสดงการกล่าวถึง"; +"Scene.Notification.NotificationDescription.FavoritedYourPost" = "ได้ชื่นชอบโพสต์ของคุณ"; +"Scene.Notification.NotificationDescription.FollowedYou" = "ได้ติดตามคุณ"; +"Scene.Notification.NotificationDescription.MentionedYou" = "ได้กล่าวถึงคุณ"; +"Scene.Notification.NotificationDescription.PollHasEnded" = "การสำรวจความคิดเห็นได้สิ้นสุดแล้ว"; +"Scene.Notification.NotificationDescription.RebloggedYourPost" = "ได้ดันโพสต์ของคุณ"; +"Scene.Notification.NotificationDescription.RequestToFollowYou" = "ขอติดตามคุณ"; "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" = "แสดงก่อนหน้า"; @@ -217,12 +222,18 @@ "Scene.Profile.Fields.AddRow" = "เพิ่มแถว"; "Scene.Profile.Fields.Placeholder.Content" = "เนื้อหา"; "Scene.Profile.Fields.Placeholder.Label" = "ป้ายชื่อ"; -"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Message" = "ยืนยันเพื่อเลิกปิดกั้น %@"; -"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Title" = "เลิกปิดกั้นบัญชี"; +"Scene.Profile.RelationshipActionAlert.ConfirmBlockUser.Message" = "ยืนยันเพื่อปิดกั้น %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmBlockUser.Title" = "ปิดกั้นบัญชี"; +"Scene.Profile.RelationshipActionAlert.ConfirmMuteUser.Message" = "ยืนยันเพื่อซ่อน %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmMuteUser.Title" = "ซ่อนบัญชี"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUser.Message" = "ยืนยันเพื่อเลิกปิดกั้น %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUser.Title" = "เลิกปิดกั้นบัญชี"; "Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Message" = "ยืนยันเพื่อเลิกซ่อน %@"; "Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Title" = "เลิกซ่อนบัญชี"; +"Scene.Profile.SegmentedControl.About" = "เกี่ยวกับ"; "Scene.Profile.SegmentedControl.Media" = "สื่อ"; "Scene.Profile.SegmentedControl.Posts" = "โพสต์"; +"Scene.Profile.SegmentedControl.PostsAndReplies" = "โพสต์และการตอบกลับ"; "Scene.Profile.SegmentedControl.Replies" = "การตอบกลับ"; "Scene.Register.Error.Item.Agreement" = "ข้อตกลง"; "Scene.Register.Error.Item.Email" = "อีเมล"; @@ -248,19 +259,26 @@ "Scene.Register.Input.DisplayName.Placeholder" = "ชื่อที่แสดง"; "Scene.Register.Input.Email.Placeholder" = "อีเมล"; "Scene.Register.Input.Invite.RegistrationUserInviteRequest" = "ทำไมคุณจึงต้องการเข้าร่วม?"; +"Scene.Register.Input.Password.Accessibility.Checked" = "กาเครื่องหมายแล้ว"; +"Scene.Register.Input.Password.Accessibility.Unchecked" = "ไม่ได้กาเครื่องหมาย"; +"Scene.Register.Input.Password.CharacterLimit" = "8 ตัวอักษร"; "Scene.Register.Input.Password.Hint" = "รหัสผ่านของคุณต้องมีอย่างน้อยแปดตัวอักษร"; "Scene.Register.Input.Password.Placeholder" = "รหัสผ่าน"; +"Scene.Register.Input.Password.Require" = "รหัสผ่านของคุณต้องมีอย่างน้อย:"; "Scene.Register.Input.Username.DuplicatePrompt" = "ชื่อผู้ใช้นี้ถูกใช้ไปแล้ว"; "Scene.Register.Input.Username.Placeholder" = "ชื่อผู้ใช้"; -"Scene.Register.Title" = "บอกเราเกี่ยวกับคุณ"; +"Scene.Register.Title" = "มาตั้งค่าของคุณใน %@ กันเลย"; "Scene.Report.Content1" = "มีโพสต์อื่นใดที่คุณต้องการเพิ่มไปยังรายงานหรือไม่?"; "Scene.Report.Content2" = "มีสิ่งใดที่ผู้ควบคุมควรทราบเกี่ยวกับรายงานนี้หรือไม่?"; +"Scene.Report.ReportSentTitle" = "ขอบคุณสำหรับการรายงาน เราจะตรวจสอบสิ่งนี้"; +"Scene.Report.Reported" = "รายงานแล้ว"; "Scene.Report.Send" = "ส่งรายงาน"; "Scene.Report.SkipToSend" = "ส่งโดยไม่มีความคิดเห็น"; "Scene.Report.Step1" = "ขั้นตอนที่ 1 จาก 2"; "Scene.Report.Step2" = "ขั้นตอนที่ 2 จาก 2"; "Scene.Report.TextPlaceholder" = "พิมพ์หรือวางความคิดเห็นเพิ่มเติม"; "Scene.Report.Title" = "รายงาน %@"; +"Scene.Report.TitleReport" = "รายงาน"; "Scene.Search.Recommend.Accounts.Description" = "คุณอาจต้องการติดตามบัญชีเหล่านี้"; "Scene.Search.Recommend.Accounts.Follow" = "ติดตาม"; "Scene.Search.Recommend.Accounts.Title" = "บัญชีที่คุณอาจชอบ"; @@ -297,16 +315,17 @@ "Scene.ServerPicker.EmptyState.BadNetwork" = "มีบางอย่างผิดพลาดขณะโหลดข้อมูล ตรวจสอบการเชื่อมต่ออินเทอร์เน็ตของคุณ"; "Scene.ServerPicker.EmptyState.FindingServers" = "กำลังค้นหาเซิร์ฟเวอร์ที่พร้อมใช้งาน..."; "Scene.ServerPicker.EmptyState.NoResults" = "ไม่มีผลลัพธ์"; -"Scene.ServerPicker.Input.Placeholder" = "ค้นหาเซิร์ฟเวอร์หรือเข้าร่วมของคุณเอง..."; +"Scene.ServerPicker.Input.Placeholder" = "ค้นหาชุมชน"; "Scene.ServerPicker.Label.Category" = "หมวดหมู่"; "Scene.ServerPicker.Label.Language" = "ภาษา"; "Scene.ServerPicker.Label.Users" = "ผู้ใช้"; -"Scene.ServerPicker.Title" = "เลือกเซิร์ฟเวอร์ -อันไหนก็ได้"; +"Scene.ServerPicker.Subtitle" = "เลือกชุมชนตามความสนใจ, ภูมิภาค หรือวัตถุประสงค์ทั่วไปของคุณ"; +"Scene.ServerPicker.SubtitleExtend" = "เลือกชุมชนตามความสนใจ, ภูมิภาค หรือวัตถุประสงค์ทั่วไปของคุณ แต่ละชุมชนดำเนินการโดยองค์กรหรือบุคคลที่เป็นอิสระโดยสิ้นเชิง"; +"Scene.ServerPicker.Title" = "Mastodon ประกอบด้วยผู้ใช้ในชุมชนต่าง ๆ"; "Scene.ServerRules.Button.Confirm" = "ฉันเห็นด้วย"; "Scene.ServerRules.PrivacyPolicy" = "นโยบายความเป็นส่วนตัว"; "Scene.ServerRules.Prompt" = "เมื่อคุณดำเนินการต่อ คุณอยู่ภายใต้เงื่อนไขการให้บริการและนโยบายความเป็นส่วนตัวสำหรับ %@"; -"Scene.ServerRules.Subtitle" = "กฎเหล่านี้ถูกตั้งโดยผู้ดูแลของ %@"; +"Scene.ServerRules.Subtitle" = "มีการตั้งและบังคับใช้กฎเหล่านี้โดยผู้ควบคุมของ %@"; "Scene.ServerRules.TermsOfService" = "เงื่อนไขการให้บริการ"; "Scene.ServerRules.Title" = "กฎพื้นฐานบางประการ"; "Scene.Settings.Footer.MastodonDescription" = "Mastodon เป็นซอฟต์แวร์โอเพนซอร์ส คุณสามารถรายงานปัญหาได้ใน GitHub ที่ %@ (%@)"; @@ -319,6 +338,11 @@ "Scene.Settings.Section.BoringZone.Privacy" = "นโยบายความเป็นส่วนตัว"; "Scene.Settings.Section.BoringZone.Terms" = "เงื่อนไขการให้บริการ"; "Scene.Settings.Section.BoringZone.Title" = "โซนน่าเบื่อ"; +"Scene.Settings.Section.LookAndFeel.Light" = "สว่าง"; +"Scene.Settings.Section.LookAndFeel.ReallyDark" = "มืดมาก"; +"Scene.Settings.Section.LookAndFeel.SortaDark" = "ค่อนข้างมืด"; +"Scene.Settings.Section.LookAndFeel.Title" = "ลักษณะที่แสดง"; +"Scene.Settings.Section.LookAndFeel.UseSystem" = "ใช้ของระบบ"; "Scene.Settings.Section.Notifications.Boosts" = "ดันโพสต์ของฉัน"; "Scene.Settings.Section.Notifications.Favorites" = "ชื่นชอบโพสต์ของฉัน"; "Scene.Settings.Section.Notifications.Follows" = "ติดตามฉัน"; @@ -342,6 +366,8 @@ "Scene.SuggestionAccount.Title" = "ค้นหาผู้คนที่จะติดตาม"; "Scene.Thread.BackTitle" = "โพสต์"; "Scene.Thread.Title" = "โพสต์จาก %@"; +"Scene.Welcome.GetStarted" = "เริ่มต้นใช้งาน"; +"Scene.Welcome.LogIn" = "เข้าสู่ระบบ"; "Scene.Welcome.Slogan" = "ให้เครือข่ายสังคม กลับมาอยู่ในมือของคุณ"; "Scene.Wizard.AccessibilityHint" = "แตะสองครั้งเพื่อปิดตัวช่วยสร้างนี้"; diff --git a/Mastodon/Resources/th.lproj/Localizable.stringsdict b/MastodonSDK/Sources/MastodonLocalization/Resources/th.lproj/Localizable.stringsdict similarity index 100% rename from Mastodon/Resources/th.lproj/Localizable.stringsdict rename to MastodonSDK/Sources/MastodonLocalization/Resources/th.lproj/Localizable.stringsdict diff --git a/Mastodon/Resources/zh-Hans.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/zh-Hans.lproj/Localizable.strings similarity index 87% rename from Mastodon/Resources/zh-Hans.lproj/Localizable.strings rename to MastodonSDK/Sources/MastodonLocalization/Resources/zh-Hans.lproj/Localizable.strings index 7a6b02032..7ad984f19 100644 --- a/Mastodon/Resources/zh-Hans.lproj/Localizable.strings +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/zh-Hans.lproj/Localizable.strings @@ -4,7 +4,7 @@ "Common.Alerts.CleanCache.Title" = "清除缓存"; "Common.Alerts.Common.PleaseTryAgain" = "请重试。"; "Common.Alerts.Common.PleaseTryAgainLater" = "请稍后重试。"; -"Common.Alerts.DeletePost.Delete" = "删除"; +"Common.Alerts.DeletePost.Message" = "确定要删除这个帖子吗?"; "Common.Alerts.DeletePost.Title" = "确定要删除这条消息吗?"; "Common.Alerts.DiscardPostContent.Message" = "确认要丢弃正在编辑的内容"; "Common.Alerts.DiscardPostContent.Title" = "丢弃草案"; @@ -41,6 +41,7 @@ "Common.Controls.Actions.Next" = "下一个"; "Common.Controls.Actions.Ok" = "好的"; "Common.Controls.Actions.Open" = "打开"; +"Common.Controls.Actions.OpenInBrowser" = "在浏览器中打开"; "Common.Controls.Actions.OpenInSafari" = "在 Safari 中打开"; "Common.Controls.Actions.Preview" = "预览"; "Common.Controls.Actions.Previous" = "上一个"; @@ -93,6 +94,7 @@ "Common.Controls.Keyboard.Timeline.ToggleFavorite" = "喜欢此帖子"; "Common.Controls.Keyboard.Timeline.ToggleReblog" = "转发此帖子"; "Common.Controls.Status.Actions.Favorite" = "喜欢"; +"Common.Controls.Status.Actions.Hide" = "隐藏"; "Common.Controls.Status.Actions.Menu" = "菜单"; "Common.Controls.Status.Actions.Reblog" = "转发"; "Common.Controls.Status.Actions.Reply" = "回复"; @@ -112,6 +114,10 @@ "Common.Controls.Status.Tag.Url" = "URL"; "Common.Controls.Status.UserReblogged" = "%@ 转发"; "Common.Controls.Status.UserRepliedTo" = "回复给 %@"; +"Common.Controls.Status.Visibility.Direct" = "只有提到的用户才能看到此帖子。"; +"Common.Controls.Status.Visibility.Private" = "只有作者的关注者才能看到此帖子。"; +"Common.Controls.Status.Visibility.PrivateFromMe" = "只有我的关注者才能看到此帖子。"; +"Common.Controls.Status.Visibility.Unlisted" = "任何人都可以看到这个帖子,但不会在公开的时间线中显示。"; "Common.Controls.Tabs.Home" = "主页"; "Common.Controls.Tabs.Notification" = "通知"; "Common.Controls.Tabs.Profile" = "个人资料"; @@ -178,8 +184,8 @@ "Scene.Compose.Visibility.Private" = "仅关注者"; "Scene.Compose.Visibility.Public" = "公开"; "Scene.Compose.Visibility.Unlisted" = "不公开"; -"Scene.ConfirmEmail.Button.DontReceiveEmail" = "我还没有收到电子邮件"; "Scene.ConfirmEmail.Button.OpenEmailApp" = "打开电子邮件应用"; +"Scene.ConfirmEmail.Button.Resend" = "重新发送"; "Scene.ConfirmEmail.DontReceiveEmail.Description" = "检查您的电子邮件地址是否正确,同时请检查你的垃圾箱。"; "Scene.ConfirmEmail.DontReceiveEmail.ResendEmail" = "重新发送邮件"; "Scene.ConfirmEmail.DontReceiveEmail.Title" = "请检查你的邮箱。"; @@ -200,14 +206,14 @@ "Scene.HomeTimeline.Title" = "主页"; "Scene.Notification.Keyobard.ShowEverything" = "显示全部"; "Scene.Notification.Keyobard.ShowMentions" = "显示提及"; +"Scene.Notification.NotificationDescription.FavoritedYourPost" = "喜欢了你的帖子"; +"Scene.Notification.NotificationDescription.FollowedYou" = "关注了你"; +"Scene.Notification.NotificationDescription.MentionedYou" = "提及了你"; +"Scene.Notification.NotificationDescription.PollHasEnded" = "投票已结束"; +"Scene.Notification.NotificationDescription.RebloggedYourPost" = "转发了你的帖子"; +"Scene.Notification.NotificationDescription.RequestToFollowYou" = "关注请求"; "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" = "显示前一个"; @@ -217,12 +223,18 @@ "Scene.Profile.Fields.AddRow" = "添加"; "Scene.Profile.Fields.Placeholder.Content" = "内容"; "Scene.Profile.Fields.Placeholder.Label" = "标签"; -"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Message" = "确认取消屏蔽 %@"; -"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Title" = "解除屏蔽帐户"; +"Scene.Profile.RelationshipActionAlert.ConfirmBlockUser.Message" = "确认屏蔽 %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmBlockUser.Title" = "屏蔽帐户"; +"Scene.Profile.RelationshipActionAlert.ConfirmMuteUser.Message" = "确认静音 %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmMuteUser.Title" = "静音账户"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUser.Message" = "确认取消屏蔽 %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUser.Title" = "解除屏蔽帐户"; "Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Message" = "确认取消静音 %@"; "Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Title" = "取消静音账户"; +"Scene.Profile.SegmentedControl.About" = "关于"; "Scene.Profile.SegmentedControl.Media" = "媒体"; "Scene.Profile.SegmentedControl.Posts" = "帖子"; +"Scene.Profile.SegmentedControl.PostsAndReplies" = "帖子与回复"; "Scene.Profile.SegmentedControl.Replies" = "回复"; "Scene.Register.Error.Item.Agreement" = "协议"; "Scene.Register.Error.Item.Email" = "电子邮箱"; @@ -248,19 +260,26 @@ "Scene.Register.Input.DisplayName.Placeholder" = "昵称"; "Scene.Register.Input.Email.Placeholder" = "电子邮箱"; "Scene.Register.Input.Invite.RegistrationUserInviteRequest" = "加入的理由是?"; +"Scene.Register.Input.Password.Accessibility.Checked" = "已选中"; +"Scene.Register.Input.Password.Accessibility.Unchecked" = "未选中"; +"Scene.Register.Input.Password.CharacterLimit" = "8 个字符"; "Scene.Register.Input.Password.Hint" = "密码长度至少为 8 个字符"; "Scene.Register.Input.Password.Placeholder" = "密码"; +"Scene.Register.Input.Password.Require" = "您的密码至少需要:"; "Scene.Register.Input.Username.DuplicatePrompt" = "此用户名已被使用"; "Scene.Register.Input.Username.Placeholder" = "用户名"; "Scene.Register.Title" = "介绍一下你自己吧"; "Scene.Report.Content1" = "是否有帖子需要举报?"; "Scene.Report.Content2" = "是否有关于此举报的详细描述信息?"; +"Scene.Report.ReportSentTitle" = "感谢提交举报,我们将会进行处理。"; +"Scene.Report.Reported" = "已报告"; "Scene.Report.Send" = "发送举报"; "Scene.Report.SkipToSend" = "直接发送"; "Scene.Report.Step1" = "步骤 1 / 2"; "Scene.Report.Step2" = "步骤 2 / 2"; "Scene.Report.TextPlaceholder" = "输入或粘贴额外的注释"; "Scene.Report.Title" = "举报 %@"; +"Scene.Report.TitleReport" = "举报"; "Scene.Search.Recommend.Accounts.Description" = "你可能会喜欢关注这些用户"; "Scene.Search.Recommend.Accounts.Follow" = "关注"; "Scene.Search.Recommend.Accounts.Title" = "你可能感兴趣的用户"; @@ -301,6 +320,8 @@ "Scene.ServerPicker.Label.Category" = "类别"; "Scene.ServerPicker.Label.Language" = "语言"; "Scene.ServerPicker.Label.Users" = "用户"; +"Scene.ServerPicker.Subtitle" = "根据你的兴趣、区域或一般目的选择一个社区。"; +"Scene.ServerPicker.SubtitleExtend" = "根据你的兴趣、区域或一般目的选择一个社区。每个社区都由完全独立的组织或个人管理。"; "Scene.ServerPicker.Title" = "挑选一个服务器, 任意服务器。"; "Scene.ServerRules.Button.Confirm" = "我同意"; @@ -319,6 +340,11 @@ "Scene.Settings.Section.BoringZone.Privacy" = "隐私政策"; "Scene.Settings.Section.BoringZone.Terms" = "服务条款"; "Scene.Settings.Section.BoringZone.Title" = "The Boring Zone"; +"Scene.Settings.Section.LookAndFeel.Light" = "浅色"; +"Scene.Settings.Section.LookAndFeel.ReallyDark" = "暗色"; +"Scene.Settings.Section.LookAndFeel.SortaDark" = "深色"; +"Scene.Settings.Section.LookAndFeel.Title" = "外观和风格"; +"Scene.Settings.Section.LookAndFeel.UseSystem" = "跟随系统"; "Scene.Settings.Section.Notifications.Boosts" = "转发我的帖子"; "Scene.Settings.Section.Notifications.Favorites" = "喜欢我的帖子"; "Scene.Settings.Section.Notifications.Follows" = "关注我"; @@ -342,6 +368,8 @@ "Scene.SuggestionAccount.Title" = "查看推荐关注的用户"; "Scene.Thread.BackTitle" = "帖子"; "Scene.Thread.Title" = "来自 %@ 的帖子"; +"Scene.Welcome.GetStarted" = "开始使用"; +"Scene.Welcome.LogIn" = "登录"; "Scene.Welcome.Slogan" = "社交网络 回到你的手中。"; "Scene.Wizard.AccessibilityHint" = "双击关闭此向导"; diff --git a/Mastodon/Resources/zh-Hans.lproj/Localizable.stringsdict b/MastodonSDK/Sources/MastodonLocalization/Resources/zh-Hans.lproj/Localizable.stringsdict similarity index 100% rename from Mastodon/Resources/zh-Hans.lproj/Localizable.stringsdict rename to MastodonSDK/Sources/MastodonLocalization/Resources/zh-Hans.lproj/Localizable.stringsdict diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Instance.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Instance.swift index e91aaa7b7..e90ee27c1 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Instance.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Instance.swift @@ -37,7 +37,18 @@ extension Mastodon.API.Instance { ) return session.dataTaskPublisher(for: request) .tryMap { data, response in - let value = try Mastodon.API.decode(type: Mastodon.Entity.Instance.self, from: data, response: response) + let value: Mastodon.Entity.Instance + + do { + value = try Mastodon.API.decode(type: Mastodon.Entity.Instance.self, from: data, response: response) + } catch { + if let response = response as? HTTPURLResponse, 400 ..< 500 ~= response.statusCode { + // For example, AUTHORIZED_FETCH may result in authentication errors + value = Mastodon.Entity.Instance(domain: domain) + } else { + throw error + } + } return Mastodon.Response.Content(value: value, response: response) } .eraseToAnyPublisher() diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index f72fd3aff..66c822b32 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -74,27 +74,27 @@ extension Mastodon.API { }() static func oauthEndpointURL(domain: String) -> URL { - return URL(string: "https://" + domain + "/oauth/")! + return URL(string: "\(URL.httpScheme(domain: domain))://" + domain + "/oauth/")! } static func endpointURL(domain: String) -> URL { - return URL(string: "https://" + domain + "/api/v1/")! + return URL(string: "\(URL.httpScheme(domain: domain))://" + domain + "/api/v1/")! } static func endpointV2URL(domain: String) -> URL { - return URL(string: "https://" + domain + "/api/v2/")! + return URL(string: "\(URL.httpScheme(domain: domain))://" + domain + "/api/v2/")! } static let joinMastodonEndpointURL = URL(string: "https://api.joinmastodon.org/")! public static func resendEmailURL(domain: String) -> URL { - return URL(string: "https://" + domain + "/auth/confirmation/new")! + return URL(string: "\(URL.httpScheme(domain: domain))://" + domain + "/auth/confirmation/new")! } public static func serverRulesURL(domain: String) -> URL { - return URL(string: "https://" + domain + "/about/more")! + return URL(string: "\(URL.httpScheme(domain: domain))://" + domain + "/about/more")! } public static func privacyURL(domain: String) -> URL { - return URL(string: "https://" + domain + "/terms")! + return URL(string: "\(URL.httpScheme(domain: domain))://" + domain + "/terms")! } } diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Filter.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Filter.swift index e52dd36b0..00a06ccf0 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Filter.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Filter.swift @@ -22,7 +22,7 @@ extension Mastodon.Entity { public let id: ID public let phrase: String public let context: [Context] - public let expiresAt: Date + public let expiresAt: Date? public let irreversible: Bool public let wholeWord: Bool @@ -38,7 +38,7 @@ extension Mastodon.Entity { } extension Mastodon.Entity.Filter { - public enum Context: RawRepresentable, Codable { + public enum Context: RawRepresentable, Codable, Hashable { case home case notifications case `public` diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Instance.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Instance.swift index d0d16ee4a..7cf4890bc 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Instance.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Instance.swift @@ -38,6 +38,25 @@ extension Mastodon.Entity { // https://github.com/mastodon/mastodon/pull/16485 public let configuration: Configuration? + public init(domain: String) { + self.uri = domain + self.title = domain + self.description = "" + self.shortDescription = nil + self.email = "" + self.version = nil + self.languages = nil + self.registrations = nil + self.approvalRequired = nil + self.invitesEnabled = nil + self.urls = nil + self.statistics = nil + self.thumbnail = nil + self.contactAccount = nil + self.rules = nil + self.configuration = nil + } + enum CodingKeys: String, CodingKey { case uri case title @@ -86,7 +105,7 @@ extension Mastodon.Entity.Instance { } extension Mastodon.Entity.Instance { - public struct Rule: Codable { + public struct Rule: Codable, Hashable { public let id: String public let text: String } diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Tag.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Tag.swift index 740001572..b017d1551 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Tag.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Tag.swift @@ -16,7 +16,8 @@ extension Mastodon.Entity { /// 2021/1/28 /// # Reference /// [Document](https://docs.joinmastodon.org/entities/tag/) - public struct Tag: Codable { + public struct Tag: Hashable, Codable { + // Base public let name: String public let url: String @@ -28,5 +29,14 @@ extension Mastodon.Entity { case url case history } + + public static func == (lhs: Mastodon.Entity.Tag, rhs: Mastodon.Entity.Tag) -> Bool { + return lhs.name == rhs.name + && lhs.url == rhs.url + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(name) + } } } diff --git a/MastodonSDK/Sources/MastodonSDK/Extension/URL.swift b/MastodonSDK/Sources/MastodonSDK/Extension/URL.swift new file mode 100644 index 000000000..a9345c7b3 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Extension/URL.swift @@ -0,0 +1,14 @@ +// +// URL.swift +// +// +// Created by MainasuK on 2022-3-16. +// + +import Foundation + +extension URL { + public static func httpScheme(domain: String) -> String { + return domain.hasSuffix(".onion") ? "http" : "https" + } +} diff --git a/MastodonSDK/Sources/MastodonUI/DateTimeProvider.swift b/MastodonSDK/Sources/MastodonUI/DateTimeProvider.swift new file mode 100644 index 000000000..0bd5b695f --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/DateTimeProvider.swift @@ -0,0 +1,12 @@ +// +// DateTimeProvider.swift +// +// +// Created by MainasuK on 2022-1-29. +// + +import Foundation + +public protocol DateTimeProvider { + func shortTimeAgoSinceNow(to date: Date?) -> String? +} diff --git a/Mastodon/Extension/Date.swift b/MastodonSDK/Sources/MastodonUI/Extension/Date.swift similarity index 86% rename from Mastodon/Extension/Date.swift rename to MastodonSDK/Sources/MastodonUI/Extension/Date.swift index 51d70cc0d..89d31dc91 100644 --- a/Mastodon/Extension/Date.swift +++ b/MastodonSDK/Sources/MastodonUI/Extension/Date.swift @@ -6,26 +6,27 @@ // import Foundation -import DateToolsSwift +import MastodonAsset +import MastodonLocalization extension Date { - static let relativeTimestampFormatter: RelativeDateTimeFormatter = { + public static let relativeTimestampFormatter: RelativeDateTimeFormatter = { let formatter = RelativeDateTimeFormatter() formatter.dateTimeStyle = .numeric formatter.unitsStyle = .full return formatter }() - var localizedSlowedTimeAgoSinceNow: String { + public var localizedSlowedTimeAgoSinceNow: String { return self.localizedTimeAgo(since: Date(), isSlowed: true, isAbbreviated: true) } - var localizedTimeAgoSinceNow: String { + public var localizedTimeAgoSinceNow: String { return self.localizedTimeAgo(since: Date(), isSlowed: false, isAbbreviated: false) } - func localizedTimeAgo(since date: Date, isSlowed: Bool, isAbbreviated: Bool) -> String { + public func localizedTimeAgo(since date: Date, isSlowed: Bool, isAbbreviated: Bool) -> String { let earlierDate = date < self ? date : self let latestDate = earlierDate == date ? self : date @@ -44,7 +45,7 @@ extension Date { extension Date { - func localizedShortTimeAgo(since date: Date) -> String { + public func localizedShortTimeAgo(since date: Date) -> String { let earlierDate = date < self ? date : self let latestDate = earlierDate == date ? self : date @@ -67,7 +68,7 @@ extension Date { } } - func localizedTimeLeft() -> String { + public func localizedTimeLeft() -> String { let date = Date() let earlierDate = date < self ? date : self let latestDate = earlierDate == date ? self : date diff --git a/MastodonSDK/Sources/MastodonUI/Extension/FLAnimatedImageView.swift b/MastodonSDK/Sources/MastodonUI/Extension/FLAnimatedImageView.swift new file mode 100644 index 000000000..3fc92a4b5 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/Extension/FLAnimatedImageView.swift @@ -0,0 +1,90 @@ +// +// FLAnimatedImageView.swift +// FLAnimatedImageView +// +// Created by Cirno MainasuK on 2021-8-20. +// Copyright © 2021 Twidere. All rights reserved. +// + +import Foundation +import Combine +import Alamofire +import AlamofireImage +import FLAnimatedImage + +private enum FLAnimatedImageViewAssociatedKeys { + static var activeAvatarRequestURL = "FLAnimatedImageViewAssociatedKeys.activeAvatarRequestURL" + static var avatarRequestCancellable = "FLAnimatedImageViewAssociatedKeys.avatarRequestCancellable" +} + +extension FLAnimatedImageView { + + public var activeAvatarRequestURL: URL? { + get { + objc_getAssociatedObject(self, &FLAnimatedImageViewAssociatedKeys.activeAvatarRequestURL) as? URL + } + set { + objc_setAssociatedObject(self, &FLAnimatedImageViewAssociatedKeys.activeAvatarRequestURL, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + public var avatarRequestCancellable: AnyCancellable? { + get { + objc_getAssociatedObject(self, &FLAnimatedImageViewAssociatedKeys.avatarRequestCancellable) as? AnyCancellable + } + set { + objc_setAssociatedObject(self, &FLAnimatedImageViewAssociatedKeys.avatarRequestCancellable, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + public func setImage( + url: URL?, + placeholder: UIImage?, + scaleToSize: CGSize? + ) { + // cancel task + cancelTask() + + // set placeholder + image = placeholder + + // set image + guard let url = url else { return } + activeAvatarRequestURL = url + let avatarRequest = AF.request(url).publishData() + avatarRequestCancellable = avatarRequest + .sink { response in + switch response.result { + case .success(let data): + DispatchQueue.global().async { + let image: UIImage? = { + if let scaleToSize = scaleToSize { + return UIImage(data: data)?.af.imageScaled(to: scaleToSize, scale: UIScreen.main.scale) + } else { + return UIImage(data: data) + } + }() + let animatedImage = FLAnimatedImage(animatedGIFData: data) + + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + if self.activeAvatarRequestURL == url { + if let animatedImage = animatedImage { + self.animatedImage = animatedImage + } else { + self.image = image + } + } + } + } + case .failure: + break + } + } + } + + public func cancelTask() { + activeAvatarRequestURL = nil + avatarRequestCancellable?.cancel() + } +} diff --git a/Mastodon/Extension/MetaLabel.swift b/MastodonSDK/Sources/MastodonUI/Extension/MetaLabel.swift similarity index 74% rename from Mastodon/Extension/MetaLabel.swift rename to MastodonSDK/Sources/MastodonUI/Extension/MetaLabel.swift index cf7d27cc0..119e9e031 100644 --- a/Mastodon/Extension/MetaLabel.swift +++ b/MastodonSDK/Sources/MastodonUI/Extension/MetaLabel.swift @@ -8,11 +8,15 @@ import UIKit import Meta import MetaTextKit +import MastodonAsset extension MetaLabel { - enum Style { + public enum Style { case statusHeader case statusName + case statusUsername + case statusSpoilerOverlay + case statusSpoilerBanner case notificationTitle case profileFieldName case profileFieldValue @@ -26,7 +30,7 @@ extension MetaLabel { case sidebarSubheadline(isSelected: Bool) } - convenience init(style: Style) { + public convenience init(style: Style) { self.init() layer.masksToBounds = true @@ -37,31 +41,44 @@ extension MetaLabel { setup(style: style) } - func setup(style: Style) { + public 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) + font = UIFontMetrics(forTextStyle: .footnote).scaledFont(for: .systemFont(ofSize: 13, weight: .bold)) textColor = Asset.Colors.Label.secondary.color case .statusName: - font = .systemFont(ofSize: 17, weight: .semibold) + font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .bold)) + textColor = Asset.Colors.Label.primary.color + + case .statusUsername: + font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .regular)) + textColor = Asset.Colors.Label.secondary.color + + case .statusSpoilerOverlay: + font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 20, weight: .semibold)) + textColor = Asset.Colors.Label.primary.color + textAlignment = .center + paragraphStyle.alignment = .center + + case .statusSpoilerBanner: + font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .regular)) textColor = Asset.Colors.Label.primary.color case .notificationTitle: - font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .regular), maximumPointSize: 20) + font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 14, weight: .regular)) 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 + font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 13, weight: .semibold)) + textColor = Asset.Colors.Label.secondary.color case .profileFieldValue: - font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .regular), maximumPointSize: 20) + font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .regular)) textColor = Asset.Colors.Label.primary.color - textAlignment = .right case .titleView: font = .systemFont(ofSize: 17, weight: .semibold) @@ -110,34 +127,3 @@ 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] = [] - - init(string: String) { - self.string = string - } - - func metaAttachment(for entity: Meta.Entity) -> MetaAttachment? { - return nil - } -} diff --git a/MastodonSDK/Sources/MastodonUI/Extension/UIContentSizeCategory.swift b/MastodonSDK/Sources/MastodonUI/Extension/UIContentSizeCategory.swift new file mode 100644 index 000000000..76fb3e216 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/Extension/UIContentSizeCategory.swift @@ -0,0 +1,27 @@ +// +// UIContentSizeCategory.swift +// UIContentSizeCategory +// +// Created by Cirno MainasuK on 2021-9-10. +// Copyright © 2021 Twidere. All rights reserved. +// + +import UIKit +import Combine + +extension UIContentSizeCategory { + // for Dynamic Type + public static var publisher: AnyPublisher<UIContentSizeCategory, Never> { + return NotificationCenter.default.publisher(for: UIContentSizeCategory.didChangeNotification) + .map { notification in + let key = UIContentSizeCategory.newValueUserInfoKey + guard let category = notification.userInfo?[key] as? UIContentSizeCategory else { + assertionFailure() + return UIApplication.shared.preferredContentSizeCategory + } + return category + } + .prepend(UIApplication.shared.preferredContentSizeCategory) + .eraseToAnyPublisher() + } +} diff --git a/MastodonSDK/Sources/MastodonUI/Extension/UITapGestureRecognizer.swift b/MastodonSDK/Sources/MastodonUI/Extension/UITapGestureRecognizer.swift new file mode 100644 index 000000000..2f79fdfc8 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/Extension/UITapGestureRecognizer.swift @@ -0,0 +1,27 @@ +// +// UITapGestureRecognizer.swift +// TwidereX +// +// Created by Cirno MainasuK on 2020-11-5. +// Copyright © 2020 Twidere. All rights reserved. +// + +import UIKit + +extension UITapGestureRecognizer { + + public static var singleTapGestureRecognizer: UITapGestureRecognizer { + let tapGestureRecognizer = UITapGestureRecognizer() + tapGestureRecognizer.numberOfTapsRequired = 1 + tapGestureRecognizer.numberOfTouchesRequired = 1 + return tapGestureRecognizer + } + + public static var doubleTapGestureRecognizer: UITapGestureRecognizer { + let tapGestureRecognizer = UITapGestureRecognizer() + tapGestureRecognizer.numberOfTapsRequired = 2 + tapGestureRecognizer.numberOfTouchesRequired = 1 + return tapGestureRecognizer + } + +} diff --git a/MastodonSDK/Sources/MastodonUI/Model/PlaintextMetaContent.swift b/MastodonSDK/Sources/MastodonUI/Model/PlaintextMetaContent.swift new file mode 100644 index 000000000..8a2ef91c2 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/Model/PlaintextMetaContent.swift @@ -0,0 +1,22 @@ +// +// PlaintextMetaContent.swift +// +// +// Created by MainasuK on 2022-1-10. +// + +import Foundation +import Meta + +public struct PlaintextMetaContent: MetaContent { + public let string: String + public let entities: [Meta.Entity] = [] + + public init(string: String) { + self.string = string + } + + public func metaAttachment(for entity: Meta.Entity) -> MetaAttachment? { + return nil + } +} diff --git a/MastodonSDK/Sources/MastodonUI/Model/Poll/PollItem.swift b/MastodonSDK/Sources/MastodonUI/Model/Poll/PollItem.swift new file mode 100644 index 000000000..b21a45b2d --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/Model/Poll/PollItem.swift @@ -0,0 +1,14 @@ +// +// PollItem.swift +// +// +// Created by MainasuK on 2022-1-12. +// + +import Foundation +import CoreData +import CoreDataStack + +public enum PollItem: Hashable { + case option(record: ManagedObjectRecord<PollOption>) +} diff --git a/MastodonSDK/Sources/MastodonUI/Model/Poll/PollSection.swift b/MastodonSDK/Sources/MastodonUI/Model/Poll/PollSection.swift new file mode 100644 index 000000000..10dd023f4 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/Model/Poll/PollSection.swift @@ -0,0 +1,12 @@ +// +// PollSection.swift +// +// +// Created by MainasuK on 2022-1-12. +// + +import Foundation + +public enum PollSection: Hashable { + case main +} diff --git a/MastodonSDK/Sources/MastodonUI/Protocol/AdaptiveMarginStatusTableViewCell.swift b/MastodonSDK/Sources/MastodonUI/Protocol/AdaptiveMarginStatusTableViewCell.swift new file mode 100644 index 000000000..0ac9342af --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/Protocol/AdaptiveMarginStatusTableViewCell.swift @@ -0,0 +1,56 @@ +// +// AdaptiveMarginStatusTableViewCell.swift +// +// +// Created by MainasuK on 2022-2-18. +// + +import UIKit + +public protocol AdaptiveContainerView: UIView { + func updateContainerViewComponentsLayoutMarginsRelativeArrangementBehavior(isEnabled: Bool) +} + +public protocol AdaptiveContainerMarginTableViewCell: UITableViewCell { + associatedtype ContainerView: AdaptiveContainerView + static var containerViewMarginForRegularHorizontalSizeClass: CGFloat { get } + var containerView: ContainerView { get } + var containerViewLeadingLayoutConstraint: NSLayoutConstraint! { get set } + var containerViewTrailingLayoutConstraint: NSLayoutConstraint! { get set } +} + +extension AdaptiveContainerMarginTableViewCell { + + public static var containerViewMarginForRegularHorizontalSizeClass: CGFloat { 64 } + + public func setupContainerViewMarginConstraints() { + containerViewLeadingLayoutConstraint = containerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor) + containerViewTrailingLayoutConstraint = contentView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor) + } + + public func updateContainerViewMarginConstraints() { + func setupContainerForPhone() { + containerView.updateContainerViewComponentsLayoutMarginsRelativeArrangementBehavior(isEnabled: true) // add inner margin for phone + containerViewLeadingLayoutConstraint.constant = 0 // remove outer margin for phone + containerViewTrailingLayoutConstraint.constant = 0 + } + + switch traitCollection.userInterfaceIdiom { + case .phone: + setupContainerForPhone() + default: + guard traitCollection.horizontalSizeClass == .regular else { + setupContainerForPhone() + return + } + containerView.updateContainerViewComponentsLayoutMarginsRelativeArrangementBehavior(isEnabled: false) // remove margin for iPad + containerViewLeadingLayoutConstraint.constant = Self.containerViewMarginForRegularHorizontalSizeClass // add outer margin for iPad + containerViewTrailingLayoutConstraint.constant = Self.containerViewMarginForRegularHorizontalSizeClass + } + } + + public var containerViewHorizontalMargin: CGFloat { + containerViewLeadingLayoutConstraint.constant + containerViewTrailingLayoutConstraint.constant + } + +} diff --git a/MastodonSDK/Sources/MastodonUI/Service/KeyboardResponderService.swift b/MastodonSDK/Sources/MastodonUI/Service/KeyboardResponderService.swift index 65328afa8..db600a67c 100644 --- a/MastodonSDK/Sources/MastodonUI/Service/KeyboardResponderService.swift +++ b/MastodonSDK/Sources/MastodonUI/Service/KeyboardResponderService.swift @@ -90,3 +90,47 @@ extension KeyboardResponderService { case dock } } + +extension KeyboardResponderService { + public static func configure( + scrollView: UIScrollView, + layoutNeedsUpdate: AnyPublisher<Void, Never>, + additionalSafeAreaInsets: AnyPublisher<UIEdgeInsets, Never> = CurrentValueSubject(.zero).eraseToAnyPublisher() + ) -> AnyCancellable { + let tuple = Publishers.CombineLatest3( + KeyboardResponderService.shared.isShow, + KeyboardResponderService.shared.state, + KeyboardResponderService.shared.endFrame + ) + + return Publishers.CombineLatest3( + tuple, + layoutNeedsUpdate, + additionalSafeAreaInsets + ) + .sink(receiveValue: { [weak scrollView] tuple, _, additionalSafeAreaInsets in + guard let scrollView = scrollView else { return } + guard let view = scrollView.superview else { return } + + let (isShow, state, endFrame) = tuple + + guard isShow, state == .dock else { + scrollView.contentInset.bottom = additionalSafeAreaInsets.bottom + scrollView.verticalScrollIndicatorInsets.bottom = additionalSafeAreaInsets.bottom + return + } + + // isShow AND dock state + let contentFrame = view.convert(scrollView.frame, to: nil) + let padding = contentFrame.maxY - endFrame.minY + guard padding > 0 else { + scrollView.contentInset.bottom = additionalSafeAreaInsets.bottom + scrollView.verticalScrollIndicatorInsets.bottom = additionalSafeAreaInsets.bottom + return + } + + scrollView.contentInset.bottom = padding - scrollView.safeAreaInsets.bottom + additionalSafeAreaInsets.bottom + scrollView.verticalScrollIndicatorInsets.bottom = padding - scrollView.safeAreaInsets.bottom + additionalSafeAreaInsets.bottom + }) + } +} diff --git a/MastodonSDK/Sources/MastodonUI/UserIdentifier.swift b/MastodonSDK/Sources/MastodonUI/UserIdentifier.swift new file mode 100644 index 000000000..ecde41d32 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/UserIdentifier.swift @@ -0,0 +1,14 @@ +// +// UserIdentifier.swift +// +// +// Created by MainasuK on 2022-1-12. +// + +import Foundation +import MastodonSDK + +public protocol UserIdentifier { + var domain: String { get } + var userID: Mastodon.Entity.Account.ID { get } +} diff --git a/MastodonSDK/Sources/MastodonUI/Vendor/ItemProviderLoader.swift b/MastodonSDK/Sources/MastodonUI/Vendor/ItemProviderLoader.swift index 6662f90ec..ef0c36f1b 100644 --- a/MastodonSDK/Sources/MastodonUI/Vendor/ItemProviderLoader.swift +++ b/MastodonSDK/Sources/MastodonUI/Vendor/ItemProviderLoader.swift @@ -55,6 +55,24 @@ extension ItemProviderLoader { ] as CFDictionary guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, downsampleOptions) else { + // fallback to loadItem when create thumbnail failure + itemProvider.loadItem(forTypeIdentifier: UTType.image.identifier, options: nil) { image, error in + if let error = error { + promise(.failure(error)) + } + + guard let image = image as? UIImage, + let data = image.jpegData(compressionQuality: 0.75) + else { + promise(.success(nil)) + assertionFailure() + return + } + + let file = Mastodon.Query.MediaAttachment.jpeg(data) + promise(.success(file)) + + } // end itemProvider.loadItem return } diff --git a/Mastodon/Scene/Share/View/Button/AvatarButton.swift b/MastodonSDK/Sources/MastodonUI/View/Button/AvatarButton.swift similarity index 74% rename from Mastodon/Scene/Share/View/Button/AvatarButton.swift rename to MastodonSDK/Sources/MastodonUI/View/Button/AvatarButton.swift index 6249ea373..57257fd89 100644 --- a/Mastodon/Scene/Share/View/Button/AvatarButton.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Button/AvatarButton.swift @@ -7,27 +7,28 @@ import os.log import UIKit +import MastodonLocalization -class AvatarButton: UIControl { +open class AvatarButton: UIControl { // UIControl.Event - Application: 0x0F000000 static let primaryAction = UIControl.Event(rawValue: 1 << 25) // 0x01000000 - var primaryActionState: UIControl.State = .normal + public var primaryActionState: UIControl.State = .normal - var avatarImageSize = CGSize(width: 42, height: 42) - let avatarImageView = AvatarImageView() + public var size = CGSize(width: 46, height: 46) + public let avatarImageView = AvatarImageView() - override init(frame: CGRect) { + public override init(frame: CGRect) { super.init(frame: frame) _init() } - required init?(coder: NSCoder) { + public required init?(coder: NSCoder) { super.init(coder: coder) _init() } - func _init() { + open func _init() { avatarImageView.frame = bounds avatarImageView.translatesAutoresizingMaskIntoConstraints = false addSubview(avatarImageView) @@ -37,21 +38,24 @@ class AvatarButton: UIControl { avatarImageView.trailingAnchor.constraint(equalTo: trailingAnchor), avatarImageView.bottomAnchor.constraint(equalTo: bottomAnchor), ]) + + isAccessibilityElement = true + accessibilityLabel = L10n.Common.Controls.Status.showUserProfile } - override func layoutSubviews() { + public override func layoutSubviews() { super.layoutSubviews() updateAppearance() } - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) updateAppearance() } - func updateAppearance() { + open func updateAppearance() { avatarImageView.alpha = primaryActionState.contains(.highlighted) ? 0.6 : 1.0 } @@ -59,25 +63,25 @@ class AvatarButton: UIControl { extension AvatarButton { - override var intrinsicContentSize: CGSize { - return avatarImageSize + public override var intrinsicContentSize: CGSize { + return size } - override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { + public override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { defer { updateAppearance() } updateState(touch: touch, event: event) return super.beginTracking(touch, with: event) } - override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { + public override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { defer { updateAppearance() } updateState(touch: touch, event: event) return super.continueTracking(touch, with: event) } - override func endTracking(_ touch: UITouch?, with event: UIEvent?) { + public override func endTracking(_ touch: UITouch?, with event: UIEvent?) { defer { updateAppearance() } resetState() @@ -92,7 +96,7 @@ extension AvatarButton { super.endTracking(touch, with: event) } - override func cancelTracking(with event: UIEvent?) { + public override func cancelTracking(with event: UIEvent?) { defer { updateAppearance() } resetState() diff --git a/Mastodon/Scene/Share/View/Button/CircleAvatarButton.swift b/MastodonSDK/Sources/MastodonUI/View/Button/CircleAvatarButton.swift similarity index 58% rename from Mastodon/Scene/Share/View/Button/CircleAvatarButton.swift rename to MastodonSDK/Sources/MastodonUI/View/Button/CircleAvatarButton.swift index 74591dda9..ff4dcf75b 100644 --- a/Mastodon/Scene/Share/View/Button/CircleAvatarButton.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Button/CircleAvatarButton.swift @@ -7,14 +7,14 @@ import UIKit -final class CircleAvatarButton: AvatarButton { +public final class CircleAvatarButton: AvatarButton { - @Published var needsHighlighted = false + @Published public var needsHighlighted = false - var borderColor: UIColor = UIColor.systemFill - var borderWidth: CGFloat = 1.0 + public var borderColor: UIColor = UIColor.systemFill + public var borderWidth: CGFloat = 1.0 - override func updateAppearance() { + public override func updateAppearance() { super.updateAppearance() layer.masksToBounds = true diff --git a/MastodonSDK/Sources/MastodonUI/View/Button/HitTestExpandedButton.swift b/MastodonSDK/Sources/MastodonUI/View/Button/HitTestExpandedButton.swift new file mode 100644 index 000000000..c07d1d8d0 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/Button/HitTestExpandedButton.swift @@ -0,0 +1,18 @@ +// +// HitTestExpandedButton.swift +// Mastodon +// +// Created by sxiaojian on 2021/2/1. +// + +import UIKit + +public final class HitTestExpandedButton: UIButton { + + public var expandEdgeInsets = UIEdgeInsets(top: -10, left: -10, bottom: -10, right: -10) + + public override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + return bounds.inset(by: expandEdgeInsets).contains(point) + } + +} diff --git a/MastodonSDK/Sources/MastodonUI/View/Button/RoundedEdgesButton.swift b/MastodonSDK/Sources/MastodonUI/View/Button/RoundedEdgesButton.swift index 4d62a5c2c..1fd608091 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Button/RoundedEdgesButton.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Button/RoundedEdgesButton.swift @@ -8,12 +8,18 @@ import UIKit open class RoundedEdgesButton: UIButton { + + public var cornerRadius: CGFloat = .zero { + didSet { + setNeedsDisplay() + } + } open override func layoutSubviews() { super.layoutSubviews() layer.masksToBounds = true - layer.cornerRadius = bounds.height * 0.5 + layer.cornerRadius = cornerRadius > .zero ? cornerRadius : bounds.height * 0.5 } } diff --git a/MastodonSDK/Sources/MastodonUI/View/Container/AdaptiveMarginContainerView.swift b/MastodonSDK/Sources/MastodonUI/View/Container/AdaptiveMarginContainerView.swift new file mode 100644 index 000000000..3bc6c781a --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/Container/AdaptiveMarginContainerView.swift @@ -0,0 +1,61 @@ +// +// AdaptiveMarginContainerView.swift +// +// +// Created by MainasuK on 2022-2-18. +// + +import UIKit + +public final class AdaptiveMarginContainerView: UIView { + + public var margin: CGFloat = 0 { + didSet { updateConstraints() } + } + + public var contentView: UIView? { + didSet { + guard let contentView = contentView else { return } + guard contentView.superview == nil else { return } + + contentView.translatesAutoresizingMaskIntoConstraints = false + addSubview(contentView) + + let _topLayoutConstraint = contentView.topAnchor.constraint(equalTo: topAnchor) + let _leadingLayoutConstraint = contentView.leadingAnchor.constraint(equalTo: leadingAnchor) + let _trailingLayoutConstraint = trailingAnchor.constraint(equalTo: contentView.trailingAnchor) + let _bottomLayoutConstraint = bottomAnchor.constraint(equalTo: contentView.bottomAnchor) + + NSLayoutConstraint.activate([ + _topLayoutConstraint, + _leadingLayoutConstraint, + _trailingLayoutConstraint, + _bottomLayoutConstraint + ]) + + topLayoutConstraint = _topLayoutConstraint + leadingLayoutConstraint = _leadingLayoutConstraint + trailingLayoutConstraint = _trailingLayoutConstraint + bottomLayoutConstraint = _bottomLayoutConstraint + + updateConstraints() + } + } + + private(set) var topLayoutConstraint: NSLayoutConstraint? + private(set) var leadingLayoutConstraint: NSLayoutConstraint? + private(set) var trailingLayoutConstraint: NSLayoutConstraint? + private(set) var bottomLayoutConstraint: NSLayoutConstraint? + +} + +extension AdaptiveMarginContainerView { + + public override func updateConstraints() { + super.updateConstraints() + + leadingLayoutConstraint?.constant = margin + trailingLayoutConstraint?.constant = margin + } + +} diff --git a/MastodonSDK/Sources/MastodonUI/View/Container/AudioContainerView.swift b/MastodonSDK/Sources/MastodonUI/View/Container/AudioContainerView.swift new file mode 100644 index 000000000..d23759a31 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/Container/AudioContainerView.swift @@ -0,0 +1,136 @@ +// +// AudioViewContainer.swift +// Mastodon +// +// Created by sxiaojian on 2021/3/8. +// + +import CoreDataStack +import os.log +import UIKit +import MastodonAsset +import MastodonLocalization + +//public final class AudioContainerView: UIView { +// static let cornerRadius: CGFloat = 22 +// +// let container: UIStackView = { +// let stackView = UIStackView() +// stackView.axis = .horizontal +// stackView.distribution = .fill +// stackView.alignment = .center +// stackView.spacing = 11 +// stackView.layoutMargins = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8) +// stackView.isLayoutMarginsRelativeArrangement = true +// stackView.layer.cornerRadius = AudioContainerView.cornerRadius +// stackView.clipsToBounds = true +// stackView.backgroundColor = Asset.Colors.brandBlue.color +// stackView.translatesAutoresizingMaskIntoConstraints = false +// return stackView +// }() +// +// let playButtonBackgroundView: UIView = { +// let view = UIView() +// view.layer.cornerRadius = 16 +// view.clipsToBounds = true +// view.backgroundColor = Asset.Colors.brandBlue.color +// view.translatesAutoresizingMaskIntoConstraints = false +// return view +// }() +// +// let playButton: UIButton = { +// let button = HighlightDimmableButton(type: .custom) +// let image = UIImage(systemName: "play.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 32, weight: .bold))! +// button.setImage(image.withRenderingMode(.alwaysTemplate), for: .normal) +// +// let pauseImage = UIImage(systemName: "pause.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 32, weight: .bold))! +// button.setImage(pauseImage.withRenderingMode(.alwaysTemplate), for: .selected) +// +// button.tintColor = .white +// button.translatesAutoresizingMaskIntoConstraints = false +// button.isEnabled = true +// return button +// }() +// +// let slider: UISlider = { +// let slider = UISlider() +// slider.isContinuous = true +// slider.translatesAutoresizingMaskIntoConstraints = false +// slider.minimumTrackTintColor = Asset.Colors.Slider.track.color +// slider.maximumTrackTintColor = Asset.Colors.Slider.track.color +// if let image = UIImage.placeholder(size: CGSize(width: 22, height: 22), color: .white).withRoundedCorners(radius: 11) { +// slider.setThumbImage(image, for: .normal) +// } +// return slider +// }() +// +// let timeLabel: UILabel = { +// let label = UILabel() +// label.translatesAutoresizingMaskIntoConstraints = false +// label.font = .systemFont(ofSize: 13, weight: .regular) +// label.textColor = .white +// label.textAlignment = UIApplication.shared.userInterfaceLayoutDirection == .leftToRight ? .right : .left +// return label +// }() +// +// override init(frame: CGRect) { +// super.init(frame: frame) +// _init() +// } +// +// required init?(coder: NSCoder) { +// super.init(coder: coder) +// _init() +// } +//} +// +//extension AudioContainerView { +// private func _init() { +// addSubview(container) +// NSLayoutConstraint.activate([ +// container.topAnchor.constraint(equalTo: topAnchor), +// container.leadingAnchor.constraint(equalTo: leadingAnchor), +// trailingAnchor.constraint(equalTo: container.trailingAnchor), +// bottomAnchor.constraint(equalTo: container.bottomAnchor), +// ]) +// +// // checkmark +// playButtonBackgroundView.addSubview(playButton) +// container.addArrangedSubview(playButtonBackgroundView) +// NSLayoutConstraint.activate([ +// playButton.centerXAnchor.constraint(equalTo: playButtonBackgroundView.centerXAnchor), +// playButton.centerYAnchor.constraint(equalTo: playButtonBackgroundView.centerYAnchor), +// playButtonBackgroundView.heightAnchor.constraint(equalToConstant: 32).priority(.required - 1), +// playButtonBackgroundView.widthAnchor.constraint(equalToConstant: 32).priority(.required - 1), +// ]) +// +// container.addArrangedSubview(slider) +// +// container.addArrangedSubview(timeLabel) +// NSLayoutConstraint.activate([ +// timeLabel.widthAnchor.constraint(equalToConstant: 40).priority(.required - 1), +// ]) +// } +//} +// +//extension AudioContainerView { +// public struct Configuration: Hashable { +// +// } +//} +// +//#if canImport(SwiftUI) && DEBUG +//import SwiftUI +// +//struct AudioContainerView_Previews: PreviewProvider { +// +// static var previews: some View { +// UIViewPreview(width: 375) { +// AudioContainerView() +// } +// .previewLayout(.fixed(width: 375, height: 100)) +// } +// +//} +//#endif +// diff --git a/MastodonSDK/Sources/MastodonUI/View/Container/MediaGridContainerView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/View/Container/MediaGridContainerView+ViewModel.swift new file mode 100644 index 000000000..c48ed3ca8 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/Container/MediaGridContainerView+ViewModel.swift @@ -0,0 +1,29 @@ +// +// MediaGridContainerView+ViewModel.swift +// +// +// Created by MainasuK on 2021-12-14. +// + +import UIKit +import Combine + +extension MediaGridContainerView { + public class ViewModel { + var disposeBag = Set<AnyCancellable>() + + @Published public var isSensitiveToggleButtonDisplay: Bool = false + } +} + +extension MediaGridContainerView.ViewModel { + + func bind(view: MediaGridContainerView) { + $isSensitiveToggleButtonDisplay + .sink { isDisplay in + // view.sensitiveToggleButtonBlurVisualEffectView.isHidden = !isDisplay + } + .store(in: &disposeBag) + } + +} diff --git a/MastodonSDK/Sources/MastodonUI/View/Container/MediaGridContainerView.swift b/MastodonSDK/Sources/MastodonUI/View/Container/MediaGridContainerView.swift new file mode 100644 index 000000000..cb9c53f35 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/Container/MediaGridContainerView.swift @@ -0,0 +1,315 @@ +// +// MediaGridContainerView.swift +// MediaGridContainerView +// +// Created by Cirno MainasuK on 2021-8-23. +// Copyright © 2021 Twidere. All rights reserved. +// + +import os.log +import UIKit +import func AVFoundation.AVMakeRect + +public protocol MediaGridContainerViewDelegate: AnyObject { + func mediaGridContainerView(_ container: MediaGridContainerView, didTapMediaView mediaView: MediaView, at index: Int) + func mediaGridContainerView(_ container: MediaGridContainerView, mediaSensitiveButtonDidPressed button: UIButton) +} + +public final class MediaGridContainerView: UIView { + + static let sensitiveToggleButtonSize = CGSize(width: 34, height: 34) + public static let maxCount = 9 + + let logger = Logger(subsystem: "MediaGridContainerView", category: "UI") + + public weak var delegate: MediaGridContainerViewDelegate? + public private(set) lazy var viewModel: ViewModel = { + let viewModel = ViewModel() + viewModel.bind(view: self) + return viewModel + }() + + // lazy var is required here to setup gesture recognizer target-action + // Swift not doesn't emit compiler error if without `lazy` here + private(set) lazy var _mediaViews: [MediaView] = { + var mediaViews: [MediaView] = [] + for i in 0..<MediaGridContainerView.maxCount { + // init media view + let mediaView = MediaView() + mediaView.tag = i + mediaViews.append(mediaView) + + // add gesture recognizer + let tapGesture = UITapGestureRecognizer.singleTapGestureRecognizer + tapGesture.addTarget(self, action: #selector(MediaGridContainerView.mediaViewTapGestureRecognizerHandler(_:))) + mediaView.container.addGestureRecognizer(tapGesture) + mediaView.container.isUserInteractionEnabled = true + } + return mediaViews + }() + + +// let sensitiveToggleButtonBlurVisualEffectView: UIVisualEffectView = { +// let visualEffectView = UIVisualEffectView(effect: ContentWarningOverlayView.blurVisualEffect) +// visualEffectView.layer.masksToBounds = true +// visualEffectView.layer.cornerRadius = MediaGridContainerView.sensitiveToggleButtonSize.width / 2 +// visualEffectView.layer.cornerCurve = .continuous +// return visualEffectView +// }() +// let sensitiveToggleButtonVibrancyVisualEffectView = UIVisualEffectView(effect: UIVibrancyEffect(blurEffect: ContentWarningOverlayView.blurVisualEffect)) +// let sensitiveToggleButton: HitTestExpandedButton = { +// let button = HitTestExpandedButton(type: .system) +// button.contentEdgeInsets = UIEdgeInsets(top: 4, left: 4, bottom: 4, right: 4) +// button.imageView?.contentMode = .scaleAspectFit +// button.setImage(UIImage(systemName: "eye.slash.fill"), for: .normal) +// return button +// }() + + public override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + public required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + + public override var accessibilityElements: [Any]? { + get { + mediaViews + } + set { } + } + +} + +extension MediaGridContainerView { + private func _init() { +// sensitiveToggleButton.addTarget(self, action: #selector(MediaGridContainerView.sensitiveToggleButtonDidPressed(_:)), for: .touchUpInside) + } +} + +extension MediaGridContainerView { + @objc private func mediaViewTapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) { + guard let index = _mediaViews.firstIndex(where: { $0.container === sender.view }) else { return } + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): \(index)") + let mediaView = _mediaViews[index] + delegate?.mediaGridContainerView(self, didTapMediaView: mediaView, at: index) + } + + @objc private func sensitiveToggleButtonDidPressed(_ sender: UIButton) { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") + delegate?.mediaGridContainerView(self, mediaSensitiveButtonDidPressed: sender) + } +} + +extension MediaGridContainerView { + + public func dequeueMediaView(adaptiveLayout layout: AdaptiveLayout) -> MediaView { + prepareForReuse() + + let mediaView = _mediaViews[0] + layout.layout(in: self, mediaView: mediaView) + +// layoutSensitiveToggleButton() +// bringSubviewToFront(sensitiveToggleButtonBlurVisualEffectView) + + return mediaView + } + + public func dequeueMediaView(gridLayout layout: GridLayout) -> [MediaView] { + prepareForReuse() + + let mediaViews = Array(_mediaViews[0..<layout.count]) + layout.layout(in: self, mediaViews: mediaViews) + +// layoutSensitiveToggleButton() +// bringSubviewToFront(sensitiveToggleButtonBlurVisualEffectView) + + return mediaViews + } + + public func prepareForReuse() { + _mediaViews.forEach { view in + view.removeFromSuperview() + view.removeConstraints(view.constraints) + view.prepareForReuse() + } + + subviews.forEach { view in + view.removeFromSuperview() + } + + removeConstraints(constraints) + } + +} + +extension MediaGridContainerView { +// private func layoutSensitiveToggleButton() { +// sensitiveToggleButtonBlurVisualEffectView.translatesAutoresizingMaskIntoConstraints = false +// addSubview(sensitiveToggleButtonBlurVisualEffectView) +// NSLayoutConstraint.activate([ +// sensitiveToggleButtonBlurVisualEffectView.topAnchor.constraint(equalTo: topAnchor, constant: 16), +// trailingAnchor.constraint(equalTo: sensitiveToggleButtonBlurVisualEffectView.trailingAnchor, constant: 16), +// ]) +// +// sensitiveToggleButtonVibrancyVisualEffectView.translatesAutoresizingMaskIntoConstraints = false +// sensitiveToggleButtonBlurVisualEffectView.contentView.addSubview(sensitiveToggleButtonVibrancyVisualEffectView) +// NSLayoutConstraint.activate([ +// sensitiveToggleButtonVibrancyVisualEffectView.topAnchor.constraint(equalTo: sensitiveToggleButtonBlurVisualEffectView.contentView.topAnchor), +// sensitiveToggleButtonVibrancyVisualEffectView.leadingAnchor.constraint(equalTo: sensitiveToggleButtonBlurVisualEffectView.contentView.leadingAnchor), +// sensitiveToggleButtonVibrancyVisualEffectView.trailingAnchor.constraint(equalTo: sensitiveToggleButtonBlurVisualEffectView.contentView.trailingAnchor), +// sensitiveToggleButtonVibrancyVisualEffectView.bottomAnchor.constraint(equalTo: sensitiveToggleButtonBlurVisualEffectView.contentView.bottomAnchor), +// ]) +// +// sensitiveToggleButton.translatesAutoresizingMaskIntoConstraints = false +// sensitiveToggleButtonVibrancyVisualEffectView.contentView.addSubview(sensitiveToggleButton) +// NSLayoutConstraint.activate([ +// sensitiveToggleButton.topAnchor.constraint(equalTo: sensitiveToggleButtonVibrancyVisualEffectView.contentView.topAnchor), +// sensitiveToggleButton.leadingAnchor.constraint(equalTo: sensitiveToggleButtonVibrancyVisualEffectView.contentView.leadingAnchor), +// sensitiveToggleButtonVibrancyVisualEffectView.contentView.trailingAnchor.constraint(equalTo: sensitiveToggleButton.trailingAnchor), +// sensitiveToggleButtonVibrancyVisualEffectView.contentView.bottomAnchor.constraint(equalTo: sensitiveToggleButton.bottomAnchor), +// sensitiveToggleButton.widthAnchor.constraint(equalToConstant: MediaGridContainerView.sensitiveToggleButtonSize.width).priority(.required - 1), +// sensitiveToggleButton.heightAnchor.constraint(equalToConstant: MediaGridContainerView.sensitiveToggleButtonSize.height).priority(.required - 1), +// ]) +// } +} + +extension MediaGridContainerView { + + public var mediaViews: [MediaView] { + _mediaViews.filter { $0.superview != nil } + } + + public func setAlpha(_ alpha: CGFloat) { + _mediaViews.forEach { $0.alpha = alpha } + } + + public func setAlpha(_ alpha: CGFloat, index: Int) { + if index < _mediaViews.count { + _mediaViews[index].alpha = alpha + } + } + +} + +extension MediaGridContainerView { + public struct AdaptiveLayout { + let aspectRatio: CGSize + let maxSize: CGSize + + func layout(in view: UIView, mediaView: MediaView) { + let imageViewSize = AVMakeRect(aspectRatio: aspectRatio, insideRect: CGRect(origin: .zero, size: maxSize)).size + mediaView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(mediaView) + NSLayoutConstraint.activate([ + mediaView.topAnchor.constraint(equalTo: view.topAnchor), + mediaView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + mediaView.trailingAnchor.constraint(equalTo: view.trailingAnchor).priority(.defaultLow), + mediaView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + mediaView.widthAnchor.constraint(equalToConstant: imageViewSize.width).priority(.required - 1), + mediaView.heightAnchor.constraint(equalToConstant: imageViewSize.height).priority(.required - 1), + ]) + } + } + + public struct GridLayout { + static let spacing: CGFloat = 1 + + let count: Int + let maxSize: CGSize + + init(count: Int, maxSize: CGSize) { + self.count = min(count, 9) + self.maxSize = maxSize + + } + + private func createStackView(axis: NSLayoutConstraint.Axis) -> UIStackView { + let stackView = UIStackView() + stackView.axis = axis + stackView.semanticContentAttribute = .forceLeftToRight + stackView.spacing = GridLayout.spacing + stackView.distribution = .fillEqually + return stackView + } + + public func layout(in view: UIView, mediaViews: [MediaView]) { + let containerVerticalStackView = createStackView(axis: .vertical) + containerVerticalStackView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(containerVerticalStackView) + NSLayoutConstraint.activate([ + containerVerticalStackView.topAnchor.constraint(equalTo: view.topAnchor), + containerVerticalStackView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + containerVerticalStackView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + containerVerticalStackView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + let count = mediaViews.count + switch count { + case 1: + assertionFailure("should use Adaptive Layout") + containerVerticalStackView.addArrangedSubview(mediaViews[0]) + case 2: + let horizontalStackView = createStackView(axis: .horizontal) + containerVerticalStackView.addArrangedSubview(horizontalStackView) + horizontalStackView.addArrangedSubview(mediaViews[0]) + horizontalStackView.addArrangedSubview(mediaViews[1]) + case 3: + let horizontalStackView = createStackView(axis: .horizontal) + containerVerticalStackView.addArrangedSubview(horizontalStackView) + horizontalStackView.addArrangedSubview(mediaViews[0]) + + let verticalStackView = createStackView(axis: .vertical) + horizontalStackView.addArrangedSubview(verticalStackView) + verticalStackView.addArrangedSubview(mediaViews[1]) + verticalStackView.addArrangedSubview(mediaViews[2]) + case 4: + let topHorizontalStackView = createStackView(axis: .horizontal) + containerVerticalStackView.addArrangedSubview(topHorizontalStackView) + topHorizontalStackView.addArrangedSubview(mediaViews[0]) + topHorizontalStackView.addArrangedSubview(mediaViews[1]) + + let bottomHorizontalStackView = createStackView(axis: .horizontal) + containerVerticalStackView.addArrangedSubview(bottomHorizontalStackView) + bottomHorizontalStackView.addArrangedSubview(mediaViews[2]) + bottomHorizontalStackView.addArrangedSubview(mediaViews[3]) + case 5...9: + let topHorizontalStackView = createStackView(axis: .horizontal) + containerVerticalStackView.addArrangedSubview(topHorizontalStackView) + topHorizontalStackView.addArrangedSubview(mediaViews[0]) + topHorizontalStackView.addArrangedSubview(mediaViews[1]) + topHorizontalStackView.addArrangedSubview(mediaViews[2]) + + func mediaViewOrPlaceholderView(at index: Int) -> UIView { + return index < mediaViews.count ? mediaViews[index] : UIView() + } + let middleHorizontalStackView = createStackView(axis: .horizontal) + containerVerticalStackView.addArrangedSubview(middleHorizontalStackView) + middleHorizontalStackView.addArrangedSubview(mediaViews[3]) + middleHorizontalStackView.addArrangedSubview(mediaViews[4]) + middleHorizontalStackView.addArrangedSubview(mediaViewOrPlaceholderView(at: 5)) + + if count > 6 { + let bottomHorizontalStackView = createStackView(axis: .horizontal) + containerVerticalStackView.addArrangedSubview(bottomHorizontalStackView) + bottomHorizontalStackView.addArrangedSubview(mediaViewOrPlaceholderView(at: 6)) + bottomHorizontalStackView.addArrangedSubview(mediaViewOrPlaceholderView(at: 7)) + bottomHorizontalStackView.addArrangedSubview(mediaViewOrPlaceholderView(at: 8)) + } + default: + assertionFailure() + return + } + + let containerWidth = maxSize.width + let containerHeight = count > 6 ? containerWidth : containerWidth * 2 / 3 + NSLayoutConstraint.activate([ + view.widthAnchor.constraint(equalToConstant: containerWidth).priority(.required - 1), + view.heightAnchor.constraint(equalToConstant: containerHeight).priority(.required - 1), + ]) + } + } +} diff --git a/MastodonSDK/Sources/MastodonUI/View/Container/ShadowBackgroundContainer.swift b/MastodonSDK/Sources/MastodonUI/View/Container/ShadowBackgroundContainer.swift new file mode 100644 index 000000000..3f2f5df40 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/Container/ShadowBackgroundContainer.swift @@ -0,0 +1,60 @@ +// +// ShadowBackgroundContainer.swift +// +// +// Created by MainasuK on 2022-1-5. +// + +import UIKit +import MastodonExtension + +public final class ShadowBackgroundContainer: UIView { + + public var shadowAlpha: CGFloat = 0.25 { + didSet { setNeedsLayout() } + } + + public var shadowColor: UIColor = .black { + didSet { setNeedsLayout() } + } + + public var cornerRadius: CGFloat = 10 { + didSet { setNeedsLayout() } + } + + public let shadowLayer = CALayer() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ShadowBackgroundContainer { + private func _init() { + layer.insertSublayer(shadowLayer, at: 0) + } + + public override func layoutSubviews() { + super.layoutSubviews() + + shadowLayer.frame = bounds + shadowLayer.setupShadow( + color: shadowColor, + alpha: Float(shadowAlpha), + x: 0, + y: 1, + blur: 2, + spread: 0, + roundedRect: bounds, + byRoundingCorners: .allCorners, + cornerRadii: CGSize(width: cornerRadius, height: cornerRadius) + ) + } +} diff --git a/Mastodon/Scene/Share/View/Container/TouchBlockingView.swift b/MastodonSDK/Sources/MastodonUI/View/Container/TouchBlockingView.swift similarity index 69% rename from Mastodon/Scene/Share/View/Container/TouchBlockingView.swift rename to MastodonSDK/Sources/MastodonUI/View/Container/TouchBlockingView.swift index b86137f1c..94cd99622 100644 --- a/Mastodon/Scene/Share/View/Container/TouchBlockingView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Container/TouchBlockingView.swift @@ -7,14 +7,14 @@ import UIKit -final class TouchBlockingView: UIView { +public class TouchBlockingView: UIView { - override init(frame: CGRect) { + public override init(frame: CGRect) { super.init(frame: frame) _init() } - required init?(coder: NSCoder) { + public required init?(coder: NSCoder) { super.init(coder: coder) _init() } @@ -27,7 +27,7 @@ extension TouchBlockingView { isUserInteractionEnabled = true } - override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) { + public override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) { // Blocking responder chain by not call super // The subviews in this view will received touch event but superview not } diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/MediaView+Configuration.swift b/MastodonSDK/Sources/MastodonUI/View/Content/MediaView+Configuration.swift new file mode 100644 index 000000000..6026e668f --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/Content/MediaView+Configuration.swift @@ -0,0 +1,144 @@ +// +// MediaView+Configuration.swift +// TwidereX +// +// Created by Cirno MainasuK on 2021-10-14. +// Copyright © 2021 Twidere. All rights reserved. +// + +import UIKit +import Combine +import CoreData +import Photos + +extension MediaView { + public class Configuration: Hashable { + + var disposeBag = Set<AnyCancellable>() + + public let info: Info + public let blurhash: String? + + @Published public var isReveal = true + @Published public var previewImage: UIImage? + @Published public var blurhashImage: UIImage? + public var blurhashImageDisposeBag = Set<AnyCancellable>() + + public init( + info: MediaView.Configuration.Info, + blurhash: String? + ) { + self.info = info + self.blurhash = blurhash + } + + public var aspectRadio: CGSize { + switch info { + case .image(let info): return info.aspectRadio + case .gif(let info): return info.aspectRadio + case .video(let info): return info.aspectRadio + } + } + + public var previewURL: String? { + switch info { + case .image(let info): + return info.assetURL + case .gif(let info): + return info.previewURL + case .video(let info): + return info.previewURL + } + } + + public var assetURL: String? { + switch info { + case .image(let info): + return info.assetURL + case .gif(let info): + return info.assetURL + case .video(let info): + return info.assetURL + } + } + + public var resourceType: PHAssetResourceType { + switch info { + case .image: + return .photo + case .gif: + return .video + case .video: + return .video + } + } + + public static func == (lhs: MediaView.Configuration, rhs: MediaView.Configuration) -> Bool { + return lhs.info == rhs.info + && lhs.blurhash == rhs.blurhash + && lhs.isReveal == rhs.isReveal + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(info) + hasher.combine(blurhash) + } + + } +} + +extension MediaView.Configuration { + + public enum Info: Hashable { + case image(info: ImageInfo) + case gif(info: VideoInfo) + case video(info: VideoInfo) + } + + public struct ImageInfo: Hashable { + public let aspectRadio: CGSize + public let assetURL: String? + + public init( + aspectRadio: CGSize, + assetURL: String? + ) { + self.aspectRadio = aspectRadio + self.assetURL = assetURL + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(aspectRadio.width) + hasher.combine(aspectRadio.height) + assetURL.flatMap { hasher.combine($0) } + } + } + + public struct VideoInfo: Hashable { + public let aspectRadio: CGSize + public let assetURL: String? + public let previewURL: String? + public let durationMS: Int? + + public init( + aspectRadio: CGSize, + assetURL: String?, + previewURL: String?, + durationMS: Int? + ) { + self.aspectRadio = aspectRadio + self.assetURL = assetURL + self.previewURL = previewURL + self.durationMS = durationMS + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(aspectRadio.width) + hasher.combine(aspectRadio.height) + assetURL.flatMap { hasher.combine($0) } + previewURL.flatMap { hasher.combine($0) } + durationMS.flatMap { hasher.combine($0) } + } + } + +} diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/MediaView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/MediaView.swift new file mode 100644 index 000000000..f4cee0922 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/Content/MediaView.swift @@ -0,0 +1,351 @@ +// +// MediaView.swift +// MediaView +// +// Created by Cirno MainasuK on 2021-8-23. +// Copyright © 2021 Twidere. All rights reserved. +// + +import AVKit +import UIKit +import Combine +import AlamofireImage + +public final class MediaView: UIView { + + var _disposeBag = Set<AnyCancellable>() + + public static let cornerRadius: CGFloat = 0 + public static let durationFormatter: DateComponentsFormatter = { + let formatter = DateComponentsFormatter() + formatter.zeroFormattingBehavior = .pad + formatter.allowedUnits = [.minute, .second] + return formatter + }() + public static let placeholderImage = UIImage.placeholder(color: .systemGray6) + + public let container = TouchBlockingView() + + public private(set) var configuration: Configuration? + + private(set) lazy var blurhashImageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFill + imageView.isUserInteractionEnabled = false + imageView.layer.masksToBounds = true // clip overflow + return imageView + }() + + private(set) lazy var imageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFill + imageView.isUserInteractionEnabled = false + imageView.layer.masksToBounds = true // clip overflow + return imageView + }() + + private(set) lazy var playerViewController: AVPlayerViewController = { + let playerViewController = AVPlayerViewController() + playerViewController.view.layer.masksToBounds = true + playerViewController.view.isUserInteractionEnabled = false + playerViewController.videoGravity = .resizeAspectFill + playerViewController.updatesNowPlayingInfoCenter = false + return playerViewController + }() + private var playerLooper: AVPlayerLooper? + private(set) lazy var playbackImageView: UIImageView = { + let imageView = UIImageView() + imageView.image = UIImage(systemName: "play.circle.fill") + imageView.tintColor = .white + return imageView + }() + + private(set) lazy var indicatorBlurEffectView: UIVisualEffectView = { + let effectView = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterial)) + effectView.layer.masksToBounds = true + effectView.layer.cornerCurve = .continuous + effectView.layer.cornerRadius = 4 + return effectView + }() + private(set) lazy var indicatorVibrancyEffectView = UIVisualEffectView( + effect: UIVibrancyEffect(blurEffect: UIBlurEffect(style: .systemUltraThinMaterial)) + ) + private(set) lazy var playerIndicatorLabel: UILabel = { + let label = UILabel() + label.font = .preferredFont(forTextStyle: .caption1) + label.textColor = .secondaryLabel + return label + }() + + public override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + public required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension MediaView { + + @MainActor + public func thumbnail() async -> UIImage? { + return imageView.image ?? configuration?.previewImage + } + + public func thumbnail() -> UIImage? { + return imageView.image ?? configuration?.previewImage + } + +} + +extension MediaView { + private func _init() { + // lazy load content later + + isAccessibilityElement = true + } + + public func setup(configuration: Configuration) { + self.configuration = configuration + + setupContainerViewHierarchy() + + switch configuration.info { + case .image(let info): + layoutImage() + bindImage(configuration: configuration, info: info) + accessibilityLabel = "Show image" // TODO: i18n + case .gif(let info): + layoutGIF() + bindGIF(configuration: configuration, info: info) + accessibilityLabel = "Show GIF" // TODO: i18n + case .video(let info): + layoutVideo() + bindVideo(configuration: configuration, info: info) + accessibilityLabel = "Show video player" // TODO: i18n + } + + accessibilityHint = "Tap then hold to show menu" // TODO: i18n + + layoutBlurhash() + bindBlurhash(configuration: configuration) + } + + private func layoutImage() { + imageView.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(imageView) + NSLayoutConstraint.activate([ + imageView.topAnchor.constraint(equalTo: container.topAnchor), + imageView.leadingAnchor.constraint(equalTo: container.leadingAnchor), + imageView.trailingAnchor.constraint(equalTo: container.trailingAnchor), + imageView.bottomAnchor.constraint(equalTo: container.bottomAnchor), + ]) + } + + private func bindImage(configuration: Configuration, info: Configuration.ImageInfo) { + Publishers.CombineLatest3( + configuration.$isReveal, + configuration.$previewImage, + configuration.$blurhashImage + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] isReveal, previewImage, blurhashImage in + guard let self = self else { return } + + let image = isReveal ? + (previewImage ?? blurhashImage ?? MediaView.placeholderImage) : + (blurhashImage ?? MediaView.placeholderImage) + self.imageView.image = image + } + .store(in: &configuration.disposeBag) + } + + private func layoutGIF() { + // use view controller as View here + playerViewController.view.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(playerViewController.view) + NSLayoutConstraint.activate([ + playerViewController.view.topAnchor.constraint(equalTo: container.topAnchor), + playerViewController.view.leadingAnchor.constraint(equalTo: container.leadingAnchor), + playerViewController.view.trailingAnchor.constraint(equalTo: container.trailingAnchor), + playerViewController.view.bottomAnchor.constraint(equalTo: container.bottomAnchor), + ]) + + setupIndicatorViewHierarchy() + playerIndicatorLabel.attributedText = NSAttributedString(string: "GIF") + } + + private func bindGIF(configuration: Configuration, info: Configuration.VideoInfo) { + guard let player = setupGIFPlayer(info: info) else { return } + setupPlayerLooper(player: player) + playerViewController.player = player + playerViewController.showsPlaybackControls = false + + // auto play for GIF + player.play() + } + + private func layoutVideo() { + layoutImage() + + playbackImageView.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(playbackImageView) + NSLayoutConstraint.activate([ + playbackImageView.centerXAnchor.constraint(equalTo: container.centerXAnchor), + playbackImageView.centerYAnchor.constraint(equalTo: container.centerYAnchor), + playbackImageView.widthAnchor.constraint(equalToConstant: 88).priority(.required - 1), + playbackImageView.heightAnchor.constraint(equalToConstant: 88).priority(.required - 1), + ]) + } + + private func bindVideo(configuration: Configuration, info: Configuration.VideoInfo) { + let imageInfo = Configuration.ImageInfo( + aspectRadio: info.aspectRadio, + assetURL: info.previewURL + ) + bindImage(configuration: configuration, info: imageInfo) + } + + private func layoutBlurhash() { + blurhashImageView.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(blurhashImageView) + NSLayoutConstraint.activate([ + blurhashImageView.topAnchor.constraint(equalTo: container.topAnchor), + blurhashImageView.leadingAnchor.constraint(equalTo: container.leadingAnchor), + blurhashImageView.trailingAnchor.constraint(equalTo: container.trailingAnchor), + blurhashImageView.bottomAnchor.constraint(equalTo: container.bottomAnchor), + ]) + } + + private func bindBlurhash(configuration: Configuration) { + configuration.$blurhashImage + .receive(on: DispatchQueue.main) + .assign(to: \.image, on: blurhashImageView) + .store(in: &_disposeBag) + blurhashImageView.alpha = configuration.isReveal ? 0 : 1 + + configuration.$isReveal + .dropFirst() + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] isReveal in + guard let self = self else { return } + let animator = UIViewPropertyAnimator(duration: 0.3, curve: .easeInOut) + animator.addAnimations { + self.blurhashImageView.alpha = isReveal ? 0 : 1 + } + animator.startAnimation() + } + .store(in: &_disposeBag) + } + + public func prepareForReuse() { + _disposeBag.removeAll() + + // reset appearance + alpha = 1 + + // reset image + imageView.removeFromSuperview() + imageView.removeConstraints(imageView.constraints) + imageView.af.cancelImageRequest() + imageView.image = nil + + // reset player + playerViewController.view.removeFromSuperview() + playerViewController.contentOverlayView.flatMap { view in + view.removeConstraints(view.constraints) + } + playerViewController.player?.pause() + playerViewController.player = nil + playerLooper = nil + + playbackImageView.removeFromSuperview() + + // blurhash + blurhashImageView.removeFromSuperview() + blurhashImageView.removeConstraints(blurhashImageView.constraints) + blurhashImageView.image = nil + + // reset indicator + indicatorBlurEffectView.removeFromSuperview() + + // reset container + container.removeFromSuperview() + container.removeConstraints(container.constraints) + + // reset configuration + configuration = nil + } +} + +extension MediaView { + private func setupGIFPlayer(info: Configuration.VideoInfo) -> AVPlayer? { + guard let urlString = info.assetURL, + let url = URL(string: urlString) + else { return nil } + let playerItem = AVPlayerItem(url: url) + let player = AVQueuePlayer(playerItem: playerItem) + player.isMuted = true + return player + } + + private func setupPlayerLooper(player: AVPlayer) { + guard let queuePlayer = player as? AVQueuePlayer else { return } + guard let templateItem = queuePlayer.items().first else { return } + playerLooper = AVPlayerLooper(player: queuePlayer, templateItem: templateItem) + } + + private func setupContainerViewHierarchy() { + guard container.superview == nil else { return } + container.translatesAutoresizingMaskIntoConstraints = false + addSubview(container) + NSLayoutConstraint.activate([ + container.topAnchor.constraint(equalTo: topAnchor), + container.leadingAnchor.constraint(equalTo: leadingAnchor), + container.trailingAnchor.constraint(equalTo: trailingAnchor), + container.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } + + private func setupIndicatorViewHierarchy() { + let blurEffectView = indicatorBlurEffectView + let vibrancyEffectView = indicatorVibrancyEffectView + + assert(playerViewController.contentOverlayView != nil) + if let contentOverlayView = playerViewController.contentOverlayView { + blurEffectView.translatesAutoresizingMaskIntoConstraints = false + contentOverlayView.addSubview(indicatorBlurEffectView) + NSLayoutConstraint.activate([ + contentOverlayView.trailingAnchor.constraint(equalTo: blurEffectView.trailingAnchor, constant: 16), + contentOverlayView.bottomAnchor.constraint(equalTo: blurEffectView.bottomAnchor, constant: 8), + ]) + } + + if vibrancyEffectView.superview == nil { + vibrancyEffectView.translatesAutoresizingMaskIntoConstraints = false + blurEffectView.contentView.addSubview(vibrancyEffectView) + NSLayoutConstraint.activate([ + vibrancyEffectView.topAnchor.constraint(equalTo: blurEffectView.contentView.topAnchor), + vibrancyEffectView.leadingAnchor.constraint(equalTo: blurEffectView.contentView.leadingAnchor), + vibrancyEffectView.trailingAnchor.constraint(equalTo: blurEffectView.contentView.trailingAnchor), + vibrancyEffectView.bottomAnchor.constraint(equalTo: blurEffectView.contentView.bottomAnchor), + ]) + } + + if playerIndicatorLabel.superview == nil { + playerIndicatorLabel.translatesAutoresizingMaskIntoConstraints = false + vibrancyEffectView.contentView.addSubview(playerIndicatorLabel) + NSLayoutConstraint.activate([ + playerIndicatorLabel.topAnchor.constraint(equalTo: vibrancyEffectView.contentView.topAnchor), + playerIndicatorLabel.leadingAnchor.constraint(equalTo: vibrancyEffectView.contentView.leadingAnchor, constant: 3), + vibrancyEffectView.contentView.trailingAnchor.constraint(equalTo: playerIndicatorLabel.trailingAnchor, constant: 3), + playerIndicatorLabel.bottomAnchor.constraint(equalTo: vibrancyEffectView.contentView.bottomAnchor), + ]) + } + } +} diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView+ViewModel.swift new file mode 100644 index 000000000..2151b55b0 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView+ViewModel.swift @@ -0,0 +1,149 @@ +// +// NotificationView+ViewModel.swift +// +// +// Created by MainasuK on 2022-1-21. +// + +import os.log +import UIKit +import Combine +import Meta +import MastodonSDK +import MastodonAsset +import MastodonLocalization +import MastodonExtension + +extension NotificationView { + public final class ViewModel: ObservableObject { + public var disposeBag = Set<AnyCancellable>() + + let logger = Logger(subsystem: "StatusView", category: "ViewModel") + + @Published public var userIdentifier: UserIdentifier? // me + + @Published public var notificationIndicatorText: MetaContent? + + @Published public var authorAvatarImage: UIImage? + @Published public var authorAvatarImageURL: URL? + @Published public var authorName: MetaContent? + @Published public var authorUsername: String? + + @Published public var isMyself = false + @Published public var isMuting = false + @Published public var isBlocking = false + + @Published public var timestamp: Date? + + let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common) + .autoconnect() + .share() + .eraseToAnyPublisher() + + } +} + +extension NotificationView.ViewModel { + func bind(notificationView: NotificationView) { + bindAuthor(notificationView: notificationView) + bindAuthorMenu(notificationView: notificationView) + + $userIdentifier + .assign(to: \.userIdentifier, on: notificationView.statusView.viewModel) + .store(in: &disposeBag) + $userIdentifier + .assign(to: \.userIdentifier, on: notificationView.quoteStatusView.viewModel) + .store(in: &disposeBag) + } + + private func bindAuthor(notificationView: NotificationView) { + // avatar + Publishers.CombineLatest( + $authorAvatarImage, + $authorAvatarImageURL + ) + .sink { image, url in + let configuration: AvatarImageView.Configuration = { + if let image = image { + return AvatarImageView.Configuration(image: image) + } else { + return AvatarImageView.Configuration(url: url) + } + }() + notificationView.avatarButton.avatarImageView.configure(configuration: configuration) + notificationView.avatarButton.avatarImageView.configure(cornerConfiguration: .init(corner: .fixed(radius: 12))) + } + .store(in: &disposeBag) + // name + $authorName + .sink { metaContent in + let metaContent = metaContent ?? PlaintextMetaContent(string: " ") + notificationView.authorNameLabel.configure(content: metaContent) + } + .store(in: &disposeBag) + // username + $authorUsername + .map { text -> String in + guard let text = text else { return "" } + return "@\(text)" + } + .sink { username in + let metaContent = PlaintextMetaContent(string: username) + notificationView.authorUsernameLabel.configure(content: metaContent) + } + .store(in: &disposeBag) + // timestamp + Publishers.CombineLatest( + $timestamp, + timestampUpdatePublisher.prepend(Date()).eraseToAnyPublisher() + ) + .sink { [weak self] timestamp, _ in + guard let self = self else { return } + guard let timestamp = timestamp else { + notificationView.dateLabel.configure(content: PlaintextMetaContent(string: "")) + return + } + + let text = timestamp.localizedTimeAgoSinceNow + notificationView.dateLabel.configure(content: PlaintextMetaContent(string: text)) + } + .store(in: &disposeBag) + // notification type indicator + $notificationIndicatorText + .sink { text in + if let text = text { + notificationView.notificationTypeIndicatorLabel.configure(content: text) + } else { + notificationView.notificationTypeIndicatorLabel.reset() + } + } + .store(in: &disposeBag) + } + + private func bindAuthorMenu(notificationView: NotificationView) { + Publishers.CombineLatest4( + $authorName, + $isMuting, + $isBlocking, + $isMyself + ) + .sink { authorName, isMuting, isBlocking, isMyself in + guard let name = authorName?.string else { + notificationView.menuButton.menu = nil + return + } + + let menuContext = NotificationView.AuthorMenuContext( + name: name, + isMuting: isMuting, + isBlocking: isBlocking, + isMyself: isMyself + ) + notificationView.menuButton.menu = notificationView.setupAuthorMenu(menuContext: menuContext) + notificationView.menuButton.showsMenuAsPrimaryAction = true + + notificationView.menuButton.isHidden = menuContext.isMyself + } + .store(in: &disposeBag) + } +} diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView.swift new file mode 100644 index 000000000..8827eadae --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView.swift @@ -0,0 +1,450 @@ +// +// NotificationView.swift +// +// +// Created by MainasuK on 2022-1-21. +// + +import os.log +import UIKit +import Combine +import MetaTextKit +import Meta +import MastodonAsset +import MastodonLocalization + +public protocol NotificationViewDelegate: AnyObject { + func notificationView(_ notificationView: NotificationView, authorAvatarButtonDidPressed button: AvatarButton) + func notificationView(_ notificationView: NotificationView, menuButton button: UIButton, didSelectAction action: MastodonMenu.Action) + + func notificationView(_ notificationView: NotificationView, statusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta) + func notificationView(_ notificationView: NotificationView, statusView: StatusView, spoilerOverlayViewDidPressed overlayView: SpoilerOverlayView) + func notificationView(_ notificationView: NotificationView, statusView: StatusView, mediaGridContainerView: MediaGridContainerView, mediaView: MediaView, didSelectMediaViewAt index: Int) + + func notificationView(_ notificationView: NotificationView, statusView: StatusView, actionToolbarContainer: ActionToolbarContainer, buttonDidPressed button: UIButton, action: ActionToolbarContainer.Action) + + func notificationView(_ notificationView: NotificationView, quoteStatusView: StatusView, authorAvatarButtonDidPressed button: AvatarButton) + func notificationView(_ notificationView: NotificationView, quoteStatusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta) + func notificationView(_ notificationView: NotificationView, quoteStatusView: StatusView, spoilerOverlayViewDidPressed overlayView: SpoilerOverlayView) + func notificationView(_ notificationView: NotificationView, quoteStatusView: StatusView, mediaGridContainerView: MediaGridContainerView, mediaView: MediaView, didSelectMediaViewAt index: Int) + + // a11y + func notificationView(_ notificationView: NotificationView, accessibilityActivate: Void) +} + +public final class NotificationView: UIView { + + static let containerLayoutMargin = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16) + + let logger = Logger(subsystem: "NotificationView", category: "View") + + public weak var delegate: NotificationViewDelegate? + + var _disposeBag = Set<AnyCancellable>() + public var disposeBag = Set<AnyCancellable>() + + public private(set) lazy var viewModel: ViewModel = { + let viewModel = ViewModel() + viewModel.bind(notificationView: self) + return viewModel + }() + + let containerStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.spacing = 10 + return stackView + }() + + // author + let authorAdaptiveMarginContainerView = AdaptiveMarginContainerView() + let authorContainerView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.spacing = 12 + return stackView + }() + let authorContainerViewBottomPaddingView = UIView() + + // avatar + public let avatarButton = AvatarButton() + + // author name + public let authorNameLabel = MetaLabel(style: .statusName) + + // author username + public let authorUsernameLabel = MetaLabel(style: .statusUsername) + + public let usernameTrialingDotLabel: MetaLabel = { + let label = MetaLabel(style: .statusUsername) + label.configure(content: PlaintextMetaContent(string: "·")) + return label + }() + + // timestamp + public let dateLabel = MetaLabel(style: .statusUsername) + + public let menuButton: UIButton = { + let button = HitTestExpandedButton(type: .system) + let image = UIImage(systemName: "ellipsis", withConfiguration: UIImage.SymbolConfiguration(font: .systemFont(ofSize: 15))) + button.setImage(image, for: .normal) + return button + }() + + // notification type indicator imageView + public let notificationTypeIndicatorImageView: UIImageView = { + let imageView = UIImageView() + imageView.tintColor = Asset.Colors.Label.secondary.color + return imageView + }() + + // notification type indicator imageView + public let notificationTypeIndicatorLabel = MetaLabel(style: .notificationTitle) + + public let statusView = StatusView() + + public let quoteStatusViewContainerView = UIView() + public let quoteBackgroundView = UIView() + public let quoteStatusView = StatusView() + + public func prepareForReuse() { + disposeBag.removeAll() + + viewModel.authorAvatarImageURL = nil + avatarButton.avatarImageView.cancelTask() + + authorContainerViewBottomPaddingView.isHidden = true + + statusView.isHidden = true + statusView.prepareForReuse() + + quoteStatusViewContainerView.isHidden = true + quoteStatusView.prepareForReuse() + } + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension NotificationView { + private func _init() { + // container: V - [ author container | (authorContainerViewBottomPaddingView) | statusView | quoteStatusView ] + // containerStackView.layoutMargins = StatusView.containerLayoutMargin + + containerStackView.translatesAutoresizingMaskIntoConstraints = false + addSubview(containerStackView) + NSLayoutConstraint.activate([ + containerStackView.topAnchor.constraint(equalTo: topAnchor), + containerStackView.leadingAnchor.constraint(equalTo: leadingAnchor), + containerStackView.trailingAnchor.constraint(equalTo: trailingAnchor), + bottomAnchor.constraint(equalTo: containerStackView.bottomAnchor), + ]) + + // author container: H - [ avatarButton | author meta container ] + authorAdaptiveMarginContainerView.contentView = authorContainerView + authorAdaptiveMarginContainerView.margin = StatusView.containerLayoutMargin + containerStackView.addArrangedSubview(authorAdaptiveMarginContainerView) + + UIContentSizeCategory.publisher + .sink { [weak self] category in + guard let self = self else { return } + self.authorContainerView.axis = category > .accessibilityLarge ? .vertical : .horizontal + self.authorContainerView.alignment = category > .accessibilityLarge ? .leading : .center + } + .store(in: &_disposeBag) + + // avatarButton + let authorAvatarButtonSize = CGSize(width: 46, height: 46) + avatarButton.size = authorAvatarButtonSize + avatarButton.avatarImageView.imageViewSize = authorAvatarButtonSize + avatarButton.translatesAutoresizingMaskIntoConstraints = false + authorContainerView.addArrangedSubview(avatarButton) + NSLayoutConstraint.activate([ + avatarButton.widthAnchor.constraint(equalToConstant: authorAvatarButtonSize.width).priority(.required - 1), + avatarButton.heightAnchor.constraint(equalToConstant: authorAvatarButtonSize.height).priority(.required - 1), + ]) + avatarButton.setContentHuggingPriority(.required - 1, for: .vertical) + avatarButton.setContentCompressionResistancePriority(.required - 1, for: .vertical) + + // authrMetaContainer: V - [ authorPrimaryContainer | authorSecondaryMetaContainer ] + let authrMetaContainer = UIStackView() + authrMetaContainer.axis = .vertical + authrMetaContainer.spacing = 4 + authorContainerView.addArrangedSubview(authrMetaContainer) + + // authorPrimaryContainer: H - [ authorNameLabel | notificationTypeIndicatorLabel | (padding) | menuButton ] + let authorPrimaryContainer = UIStackView() + authorPrimaryContainer.axis = .horizontal + authrMetaContainer.addArrangedSubview(authorPrimaryContainer) + + authorPrimaryContainer.addArrangedSubview(authorNameLabel) + authorPrimaryContainer.addArrangedSubview(notificationTypeIndicatorLabel) + authorPrimaryContainer.addArrangedSubview(UIView()) + authorPrimaryContainer.addArrangedSubview(menuButton) + authorNameLabel.setContentHuggingPriority(.required - 10, for: .horizontal) + authorNameLabel.setContentCompressionResistancePriority(.required - 10, for: .horizontal) + notificationTypeIndicatorLabel.setContentHuggingPriority(.required - 4, for: .horizontal) + notificationTypeIndicatorLabel.setContentCompressionResistancePriority(.required - 4, for: .horizontal) + menuButton.setContentHuggingPriority(.required - 5, for: .horizontal) + menuButton.setContentCompressionResistancePriority(.required - 5, for: .horizontal) + + // authorSecondaryMetaContainer: H - [ authorUsername | (padding) ] + let authorSecondaryMetaContainer = UIStackView() + authorSecondaryMetaContainer.axis = .horizontal + authorSecondaryMetaContainer.spacing = 4 + authrMetaContainer.addArrangedSubview(authorSecondaryMetaContainer) + authrMetaContainer.setCustomSpacing(4, after: authorSecondaryMetaContainer) + + authorSecondaryMetaContainer.addArrangedSubview(authorUsernameLabel) + authorUsernameLabel.setContentHuggingPriority(.required - 8, for: .horizontal) + authorUsernameLabel.setContentCompressionResistancePriority(.required - 8, for: .horizontal) + authorSecondaryMetaContainer.addArrangedSubview(usernameTrialingDotLabel) + usernameTrialingDotLabel.setContentHuggingPriority(.required - 2, for: .horizontal) + usernameTrialingDotLabel.setContentCompressionResistancePriority(.required - 2, for: .horizontal) + authorSecondaryMetaContainer.addArrangedSubview(dateLabel) + dateLabel.setContentHuggingPriority(.required - 1, for: .horizontal) + dateLabel.setContentCompressionResistancePriority(.required - 1, for: .horizontal) + authorSecondaryMetaContainer.addArrangedSubview(UIView()) + + // authorContainerViewBottomPaddingView + authorContainerViewBottomPaddingView.translatesAutoresizingMaskIntoConstraints = false + containerStackView.addArrangedSubview(authorContainerViewBottomPaddingView) + NSLayoutConstraint.activate([ + authorContainerViewBottomPaddingView.heightAnchor.constraint(equalToConstant: 16).priority(.required - 1), + ]) + authorContainerViewBottomPaddingView.isHidden = true + + // statusView + containerStackView.addArrangedSubview(statusView) + statusView.setup(style: .notification) + + // quoteStatusView + containerStackView.addArrangedSubview(quoteStatusViewContainerView) + quoteStatusViewContainerView.layoutMargins = UIEdgeInsets(top: 0, left: 0, bottom: 16, right: 0) + + quoteBackgroundView.layoutMargins = UIEdgeInsets(top: 16, left: 0, bottom: 0, right: 0) + quoteBackgroundView.translatesAutoresizingMaskIntoConstraints = false + quoteStatusViewContainerView.addSubview(quoteBackgroundView) + NSLayoutConstraint.activate([ + quoteBackgroundView.topAnchor.constraint(equalTo: quoteStatusViewContainerView.layoutMarginsGuide.topAnchor), + quoteBackgroundView.leadingAnchor.constraint(equalTo: quoteStatusViewContainerView.layoutMarginsGuide.leadingAnchor), + quoteBackgroundView.trailingAnchor.constraint(equalTo: quoteStatusViewContainerView.layoutMarginsGuide.trailingAnchor), + quoteBackgroundView.bottomAnchor.constraint(equalTo: quoteStatusViewContainerView.layoutMarginsGuide.bottomAnchor), + ]) + quoteBackgroundView.backgroundColor = .secondarySystemBackground + quoteBackgroundView.layer.masksToBounds = true + quoteBackgroundView.layer.cornerCurve = .continuous + quoteBackgroundView.layer.cornerRadius = 8 + quoteBackgroundView.layer.borderWidth = 1 + quoteBackgroundView.layer.borderColor = UIColor.separator.cgColor + + quoteStatusView.translatesAutoresizingMaskIntoConstraints = false + quoteBackgroundView.addSubview(quoteStatusView) + NSLayoutConstraint.activate([ + quoteStatusView.topAnchor.constraint(equalTo: quoteBackgroundView.layoutMarginsGuide.topAnchor), + quoteStatusView.leadingAnchor.constraint(equalTo: quoteBackgroundView.layoutMarginsGuide.leadingAnchor), + quoteStatusView.trailingAnchor.constraint(equalTo: quoteBackgroundView.layoutMarginsGuide.trailingAnchor), + quoteStatusView.bottomAnchor.constraint(equalTo: quoteBackgroundView.layoutMarginsGuide.bottomAnchor), + ]) + quoteStatusView.setup(style: .notificationQuote) + + statusView.isHidden = true + quoteStatusViewContainerView.isHidden = true + + authorNameLabel.isUserInteractionEnabled = false + authorUsernameLabel.isUserInteractionEnabled = false + notificationTypeIndicatorLabel.isUserInteractionEnabled = false + + avatarButton.addTarget(self, action: #selector(NotificationView.avatarButtonDidPressed(_:)), for: .touchUpInside) + + statusView.delegate = self + quoteStatusView.delegate = self + } +} + +extension NotificationView { + @objc private func avatarButtonDidPressed(_ sender: UIButton) { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") + delegate?.notificationView(self, authorAvatarButtonDidPressed: avatarButton) + } +} + +extension NotificationView { + + public func setAuthorContainerBottomPaddingViewDisplay() { + authorContainerViewBottomPaddingView.isHidden = false + } + + public func setStatusViewDisplay() { + statusView.isHidden = false + } + + public func setQuoteStatusViewDisplay() { + quoteStatusViewContainerView.isHidden = false + } + +} + +// MARK: - AdaptiveContainerView +extension NotificationView: AdaptiveContainerView { + public func updateContainerViewComponentsLayoutMarginsRelativeArrangementBehavior(isEnabled: Bool) { + let margin = isEnabled ? StatusView.containerLayoutMargin : .zero + authorAdaptiveMarginContainerView.margin = margin + quoteStatusViewContainerView.layoutMargins.left = margin + quoteStatusViewContainerView.layoutMargins.right = margin + + statusView.updateContainerViewComponentsLayoutMarginsRelativeArrangementBehavior(isEnabled: isEnabled) + quoteStatusView.updateContainerViewComponentsLayoutMarginsRelativeArrangementBehavior(isEnabled: true) // always set margins + } +} + +extension NotificationView { + public typealias AuthorMenuContext = StatusView.AuthorMenuContext + + public func setupAuthorMenu(menuContext: AuthorMenuContext) -> UIMenu { + var actions: [MastodonMenu.Action] = [] + + actions = [ + .muteUser(.init( + name: menuContext.name, + isMuting: menuContext.isMuting + )), + .blockUser(.init( + name: menuContext.name, + isBlocking: menuContext.isBlocking + )), + .reportUser( + .init(name: menuContext.name) + ), + ] + + if menuContext.isMyself { + actions.append(.deleteStatus) + } + + + let menu = MastodonMenu.setupMenu( + actions: actions, + delegate: self + ) + + return menu + } + +} + +// MARK: - StatusViewDelegate +extension NotificationView: StatusViewDelegate { + + public func statusView(_ statusView: StatusView, headerDidPressed header: UIView) { + // do nothing + } + + public func statusView(_ statusView: StatusView, authorAvatarButtonDidPressed button: AvatarButton) { + switch statusView { + case self.statusView: + assertionFailure() + case quoteStatusView: + delegate?.notificationView(self, quoteStatusView: statusView, authorAvatarButtonDidPressed: button) + default: + assertionFailure() + } + } + + public func statusView(_ statusView: StatusView, contentSensitiveeToggleButtonDidPressed button: UIButton) { + assertionFailure() + } + + public func statusView(_ statusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta) { + switch statusView { + case self.statusView: + delegate?.notificationView(self, statusView: statusView, metaText: metaText, didSelectMeta: meta) + case quoteStatusView: + delegate?.notificationView(self, quoteStatusView: statusView, metaText: metaText, didSelectMeta: meta) + default: + assertionFailure() + } + } + + public func statusView(_ statusView: StatusView, mediaGridContainerView: MediaGridContainerView, mediaView: MediaView, didSelectMediaViewAt index: Int) { + switch statusView { + case self.statusView: + delegate?.notificationView(self, statusView: statusView, mediaGridContainerView: mediaGridContainerView, mediaView: mediaView, didSelectMediaViewAt: index) + case quoteStatusView: + delegate?.notificationView(self, quoteStatusView: statusView, mediaGridContainerView: mediaGridContainerView, mediaView: mediaView, didSelectMediaViewAt: index) + default: + assertionFailure() + } + } + + public func statusView(_ statusView: StatusView, pollTableView tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + assertionFailure() + } + + public func statusView(_ statusView: StatusView, pollVoteButtonPressed button: UIButton) { + assertionFailure() + } + + public func statusView(_ statusView: StatusView, actionToolbarContainer: ActionToolbarContainer, buttonDidPressed button: UIButton, action: ActionToolbarContainer.Action) { + switch statusView { + case self.statusView: + delegate?.notificationView(self, statusView: statusView, actionToolbarContainer: actionToolbarContainer, buttonDidPressed: button, action: action) + case quoteStatusView: + assertionFailure() + default: + assertionFailure() + } + } + + public func statusView(_ statusView: StatusView, menuButton button: UIButton, didSelectAction action: MastodonMenu.Action) { + assertionFailure() + } + + public func statusView(_ statusView: StatusView, spoilerOverlayViewDidPressed overlayView: SpoilerOverlayView) { + switch statusView { + case self.statusView: + delegate?.notificationView(self, statusView: statusView, spoilerOverlayViewDidPressed: overlayView) + case quoteStatusView: + delegate?.notificationView(self, quoteStatusView: statusView, spoilerOverlayViewDidPressed: overlayView) + default: + assertionFailure() + } + } + +// public func statusView(_ statusView: StatusView, spoilerBannerViewDidPressed bannerView: SpoilerBannerView) { +// switch statusView { +// case self.statusView: +// delegate?.notificationView(self, statusView: statusView, spoilerBannerViewDidPressed: bannerView) +// case quoteStatusView: +// delegate?.notificationView(self, quoteStatusView: statusView, spoilerBannerViewDidPressed: bannerView) +// default: +// assertionFailure() +// } +// } + + public func statusView(_ statusView: StatusView, mediaGridContainerView: MediaGridContainerView, mediaSensitiveButtonDidPressed button: UIButton) { + assertionFailure() + } + + public func statusView(_ statusView: StatusView, accessibilityActivate: Void) { + assertionFailure() + } + +} + +// MARK: - MastodonMenuDelegate +extension NotificationView: MastodonMenuDelegate { + public func menuAction(_ action: MastodonMenu.Action) { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") + + delegate?.notificationView(self, menuButton: menuButton, didSelectAction: action) + } +} diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/PollOptionView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/View/Content/PollOptionView+ViewModel.swift new file mode 100644 index 000000000..e25e5d0a8 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/Content/PollOptionView+ViewModel.swift @@ -0,0 +1,199 @@ +// +// PollOptionView+ViewModel.swift +// +// +// Created by MainasuK on 2021-12-8. +// + +import UIKit +import Combine +import CoreData +import MetaTextKit +import MastodonAsset + +extension PollOptionView { + + static let percentageFormatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.numberStyle = .percent + formatter.maximumFractionDigits = 1 + formatter.minimumIntegerDigits = 1 + formatter.roundingMode = .down + return formatter + }() + + public final class ViewModel: ObservableObject { + var disposeBag = Set<AnyCancellable>() + var observations = Set<NSKeyValueObservation>() + public var objects = Set<NSManagedObject>() + + @Published public var userIdentifier: UserIdentifier? + + @Published public var style: PollOptionView.Style? + + @Published public var content: String = "" // for edit style + + @Published public var metaContent: MetaContent? // for plain style + @Published public var percentage: Double? + + @Published public var isExpire: Bool = false + @Published public var isMultiple: Bool = false + @Published public var isSelect: Bool? = false // nil for server not return selection array + @Published public var isPollVoted: Bool = false + @Published public var isMyPoll: Bool = false + @Published public var isReveal: Bool = false + + @Published public var selectState: SelectState = .none + @Published public var voteState: VoteState = .hidden + + @Published public var roundedBackgroundViewColor: UIColor = .clear + @Published public var primaryStripProgressViewTintColor: UIColor = Asset.Colors.brandBlue.color + @Published public var secondaryStripProgressViewTintColor: UIColor = Asset.Colors.brandBlue.color.withAlphaComponent(0.5) + + @Published public var groupedAccessibilityLabel = "" + + init() { + // selectState + Publishers.CombineLatest3( + $isSelect, + $isExpire, + $isPollVoted + ) + .map { isSelect, isExpire, isPollVoted -> SelectState in + if isSelect == true { + return .on + } else if isExpire { + return .none + } else if isPollVoted, isSelect == nil { + return .none + } else { + return .off + } + } + .assign(to: &$selectState) + // voteState + Publishers.CombineLatest3( + $isReveal, + $isSelect, + $percentage + ) + .map { isReveal, isSelect, percentage -> VoteState in + guard isReveal else { + return .hidden + } + let oldPercentage = self.percentage + let animated = oldPercentage != nil && percentage != nil + + return .reveal(voted: isSelect == true, percentage: percentage ?? 0, animating: animated) + } + .assign(to: &$voteState) + // isReveal + Publishers.CombineLatest3( + $isExpire, + $isPollVoted, + $isMyPoll + ) + .map { isExpire, isPollVoted, isMyPoll in + return isExpire || isPollVoted || isMyPoll + } + .assign(to: &$isReveal) + + + } + + public enum Corner: Hashable { + case none + case circle + case radius(CGFloat) + } + + public enum SelectState: Equatable, Hashable { + case none + case off + case on + } + + public enum VoteState: Equatable, Hashable { + case hidden + case reveal(voted: Bool, percentage: Double, animating: Bool) + } + } +} + +extension PollOptionView.ViewModel { + public func bind(view: PollOptionView) { + // backgroundColor + $roundedBackgroundViewColor + .map { $0 as UIColor? } + .assign(to: \.backgroundColor, on: view.roundedBackgroundView) + .store(in: &disposeBag) + // content + NotificationCenter.default + .publisher(for: UITextField.textDidChangeNotification, object: view.optionTextField) + .receive(on: DispatchQueue.main) + .map { _ in view.optionTextField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" } + .assign(to: &$content) + // metaContent + $metaContent + .sink { metaContent in + guard let metaContent = metaContent else { + view.optionTextField.text = "" + view.optionTextField.accessibilityLabel = "" + return + } + view.optionTextField.text = metaContent.string + view.optionTextField.accessibilityLabel = metaContent.string + } + .store(in: &disposeBag) + // selectState + $selectState + .sink { selectState in + switch selectState { + case .none: + view.checkmarkBackgroundView.isHidden = true + view.checkmarkImageView.isHidden = true + case .off: + view.checkmarkBackgroundView.isHidden = false + view.checkmarkImageView.isHidden = true + case .on: + view.checkmarkBackgroundView.isHidden = false + view.checkmarkImageView.isHidden = false + } + } + .store(in: &disposeBag) + // voteState + $voteState + .sink { [weak self] voteState in + guard let self = self else { return } + switch voteState { + case .hidden: + view.optionPercentageLabel.isHidden = true + view.voteProgressStripView.isHidden = true + view.voteProgressStripView.setProgress(0.0, animated: false) + case .reveal(let voted, let percentage, let animating): + view.optionPercentageLabel.isHidden = false + view.optionPercentageLabel.text = String(Int(100 * percentage)) + "%" + view.voteProgressStripView.isHidden = false + view.voteProgressStripView.tintColor = voted ? self.primaryStripProgressViewTintColor : self.secondaryStripProgressViewTintColor + view.voteProgressStripView.setProgress(CGFloat(percentage), animated: animating) + } + } + .store(in: &disposeBag) + + bindAccessibility(view: view) + } + + private func bindAccessibility(view: PollOptionView) { + $selectState + .sink { selectState in + switch selectState { + case .on: + view.accessibilityTraits.insert(.selected) + default: + view.accessibilityTraits.remove(.selected) + } + } + .store(in: &disposeBag) + } +} + diff --git a/Mastodon/Scene/Share/View/Content/PollOptionView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/PollOptionView.swift similarity index 64% rename from Mastodon/Scene/Share/View/Content/PollOptionView.swift rename to MastodonSDK/Sources/MastodonUI/View/Content/PollOptionView.swift index db84b95df..df000233c 100644 --- a/Mastodon/Scene/Share/View/Content/PollOptionView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/PollOptionView.swift @@ -7,34 +7,41 @@ import UIKit import Combine +import MastodonAsset +import MastodonLocalization -final class PollOptionView: UIView { +public final class PollOptionView: UIView { - static let height: CGFloat = optionHeight + 2 * verticalMargin - static let optionHeight: CGFloat = 44 - static let verticalMargin: CGFloat = 5 - static let checkmarkImageSize = CGSize(width: 26, height: 26) - static let checkmarkBackgroundLeadingMargin: CGFloat = 9 + public static let height: CGFloat = optionHeight + 2 * verticalMargin + public static let optionHeight: CGFloat = 44 + public static let verticalMargin: CGFloat = 5 + public static let checkmarkImageSize = CGSize(width: 26, height: 26) + public static let checkmarkBackgroundLeadingMargin: CGFloat = 9 private var viewStateDisposeBag = Set<AnyCancellable>() - var disposeBag = Set<AnyCancellable>() + public var disposeBag = Set<AnyCancellable>() + public private(set) lazy var viewModel: ViewModel = { + let viewModel = ViewModel() + viewModel.bind(view: self) + return viewModel + }() - let roundedBackgroundView = UIView() - let voteProgressStripView: StripProgressView = { + public private(set) var style: Style? + + public let roundedBackgroundView = UIView() + public let voteProgressStripView: StripProgressView = { let view = StripProgressView() view.tintColor = Asset.Colors.brandBlue.color return view }() - let checkmarkBackgroundView: UIView = { + public let checkmarkBackgroundView: UIView = { let view = UIView() - // FIXME: missing update trigger - view.backgroundColor = ThemeService.shared.currentTheme.value.tertiarySystemBackgroundColor return view }() - let checkmarkImageView: UIImageView = { + public let checkmarkImageView: UIImageView = { let imageView = UIImageView() let image = UIImage(systemName: "checkmark", withConfiguration: UIImage.SymbolConfiguration(pointSize: 14, weight: .bold))! imageView.image = image.withRenderingMode(.alwaysTemplate) @@ -42,7 +49,7 @@ final class PollOptionView: UIView { return imageView }() - let plusCircleImageView: UIImageView = { + public let plusCircleImageView: UIImageView = { let imageView = UIImageView() let image = Asset.Circles.plusCircle.image imageView.image = image.withRenderingMode(.alwaysTemplate) @@ -50,7 +57,7 @@ final class PollOptionView: UIView { return imageView }() - let optionTextField: DeleteBackwardResponseTextField = { + public let optionTextField: DeleteBackwardResponseTextField = { let textField = DeleteBackwardResponseTextField() textField.font = .systemFont(ofSize: 15, weight: .medium) textField.textColor = Asset.Colors.Label.primary.color @@ -59,9 +66,9 @@ final class PollOptionView: UIView { return textField }() - let optionLabelMiddlePaddingView = UIView() + public let optionLabelMiddlePaddingView = UIView() - let optionPercentageLabel: UILabel = { + public let optionPercentageLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 13, weight: .regular) label.textColor = Asset.Colors.Label.primary.color @@ -70,12 +77,19 @@ final class PollOptionView: UIView { return label }() - override init(frame: CGRect) { + public func prepareForReuse() { + disposeBag.removeAll() + viewModel.objects.removeAll() + viewModel.percentage = nil + voteProgressStripView.setProgress(0, animated: false) + } + + public override init(frame: CGRect) { super.init(frame: frame) _init() } - required init?(coder: NSCoder) { + public required init?(coder: NSCoder) { super.init(coder: coder) _init() } @@ -84,9 +98,6 @@ final class PollOptionView: UIView { extension PollOptionView { private func _init() { - // default color in the timeline - roundedBackgroundView.backgroundColor = ThemeService.shared.currentTheme.value.secondarySystemBackgroundColor - roundedBackgroundView.translatesAutoresizingMaskIntoConstraints = false addSubview(roundedBackgroundView) NSLayoutConstraint.activate([ @@ -164,13 +175,91 @@ extension PollOptionView { optionPercentageLabel.setContentCompressionResistancePriority(.required - 1, for: .horizontal) plusCircleImageView.isHidden = true + + updateCornerRadius() + + isAccessibilityElement = true } - override func layoutSubviews() { + public override var accessibilityLabel: String? { + get { + switch viewModel.voteState { + case .reveal: + return [ + optionTextField, + optionPercentageLabel + ] + .compactMap { $0.accessibilityLabel } + .joined(separator: ", ") + + case .hidden: + return optionTextField.accessibilityLabel + } + } + set { } + } + + public override func layoutSubviews() { super.layoutSubviews() + updateCornerRadius() } + public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) { + +// func updateTextAppearance() { +// // guard let voteState = attribute?.voteState else { +// // pollOptionView.optionTextField.textColor = Asset.Colors.Label.primary.color +// // pollOptionView.optionTextField.layer.removeShadow() +// // return +// // } +// // +// // switch voteState { +// // case .hidden: +// // pollOptionView.optionTextField.textColor = Asset.Colors.Label.primary.color +// // pollOptionView.optionTextField.layer.removeShadow() +// // case .reveal(_, let percentage, _): +// // if CGFloat(percentage) * pollOptionView.voteProgressStripView.frame.width > pollOptionView.optionLabelMiddlePaddingView.frame.minX { +// // pollOptionView.optionTextField.textColor = .white +// // pollOptionView.optionTextField.layer.setupShadow(x: 0, y: 0, blur: 4, spread: 0) +// // } else { +// // pollOptionView.optionTextField.textColor = Asset.Colors.Label.primary.color +// // pollOptionView.optionTextField.layer.removeShadow() +// // } +// // +// // if CGFloat(percentage) * pollOptionView.voteProgressStripView.frame.width > pollOptionView.optionLabelMiddlePaddingView.frame.maxX { +// // pollOptionView.optionPercentageLabel.textColor = .white +// // pollOptionView.optionPercentageLabel.layer.setupShadow(x: 0, y: 0, blur: 4, spread: 0) +// // } else { +// // pollOptionView.optionPercentageLabel.textColor = Asset.Colors.Label.primary.color +// // pollOptionView.optionPercentageLabel.layer.removeShadow() +// // } +// // } +// } + + } + } + +} + +extension PollOptionView { + public enum Style { + case plain + case edit + } + + public func setup(style: Style) { + guard self.style == nil else { + assertionFailure("Should only setup once") + return + } + self.style = style + self.viewModel.style = style + } + } extension PollOptionView { diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusMetricView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusMetricView.swift new file mode 100644 index 000000000..2a770f302 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusMetricView.swift @@ -0,0 +1,96 @@ +// +// StatusMetricView.swift +// +// +// Created by MainasuK on 2022-1-17. +// + +import UIKit + +public final class StatusMetricView: UIView { + + // container + public let containerStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.spacing = 4 + return stackView + }() + + // date + public let dateLabel: UILabel = { + let label = UILabel() + label.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(for: .systemFont(ofSize: 15, weight: .regular)) + label.text = "Date" + label.adjustsFontSizeToFitWidth = true + label.minimumScaleFactor = 0.5 + label.numberOfLines = 2 + return label + }() + + // meter + public let meterContainer: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.spacing = 20 + return stackView + }() + + // reblog meter + public let reblogButton: UIButton = { + let button = UIButton(type: .system) + button.titleLabel?.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .semibold)) + button.setTitle("0 reblog", for: .normal) + return button + }() + + // favorite meter + public let favoriteButton: UIButton = { + let button = UIButton(type: .system) + button.titleLabel?.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .semibold)) + button.setTitle("0 favorite", for: .normal) + return button + }() + + public override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + public required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension StatusMetricView { + private func _init() { + // container: H - [ dateLabel | meterContainer ] + containerStackView.translatesAutoresizingMaskIntoConstraints = false + addSubview(containerStackView) + NSLayoutConstraint.activate([ + containerStackView.topAnchor.constraint(equalTo: topAnchor, constant: 8), + containerStackView.leadingAnchor.constraint(equalTo: leadingAnchor), + containerStackView.trailingAnchor.constraint(equalTo: trailingAnchor), + bottomAnchor.constraint(equalTo: containerStackView.bottomAnchor, constant: 12), + ]) + + containerStackView.addArrangedSubview(dateLabel) + dateLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) + containerStackView.addArrangedSubview(meterContainer) + + // meterContainer: H - [ reblogButton | favoriteButton ] + meterContainer.addArrangedSubview(reblogButton) + meterContainer.addArrangedSubview(favoriteButton) + reblogButton.setContentHuggingPriority(.required - 2, for: .horizontal) + reblogButton.setContentCompressionResistancePriority(.required - 2, for: .horizontal) + favoriteButton.setContentHuggingPriority(.required - 1, for: .horizontal) + favoriteButton.setContentCompressionResistancePriority(.required - 1, for: .horizontal) + + // TODO: + reblogButton.isAccessibilityElement = false + favoriteButton.isAccessibilityElement = false + } + +} diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift new file mode 100644 index 000000000..f848b37e1 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift @@ -0,0 +1,730 @@ +// +// StatusView+ViewModel.swift +// +// +// Created by MainasuK on 2022-1-10. +// + +import os.log +import UIKit +import Combine +import CoreData +import Meta +import MastodonSDK +import MastodonAsset +import MastodonLocalization +import MastodonExtension +import CoreDataStack + +extension StatusView { + public final class ViewModel: ObservableObject { + var disposeBag = Set<AnyCancellable>() + var observations = Set<NSKeyValueObservation>() + public var objects = Set<NSManagedObject>() + + let logger = Logger(subsystem: "StatusView", category: "ViewModel") + + @Published public var userIdentifier: UserIdentifier? // me + + // Header + @Published public var header: Header = .none + + // Author + @Published public var authorAvatarImage: UIImage? + @Published public var authorAvatarImageURL: URL? + @Published public var authorName: MetaContent? + @Published public var authorUsername: String? + + @Published public var locked = false + + @Published public var isMyself = false + @Published public var isMuting = false + @Published public var isBlocking = false + + @Published public var timestamp: Date? + public var timestampFormatter: ((_ date: Date) -> String)? + @Published public var timestampText = "" + + // Spoiler + @Published public var spoilerContent: MetaContent? + + // Status + @Published public var content: MetaContent? + @Published public var language: String? + + // Media + @Published public var mediaViewConfigurations: [MediaView.Configuration] = [] + + // Audio + @Published public var audioConfigurations: [MediaView.Configuration] = [] + + // Poll + @Published public var pollItems: [PollItem] = [] + @Published public var isVotable: Bool = false + @Published public var isVoting: Bool = false + @Published public var isVoteButtonEnabled: Bool = false + @Published public var voterCount: Int? + @Published public var voteCount = 0 + @Published public var expireAt: Date? + @Published public var expired: Bool = false + + // Visibility + @Published public var visibility: MastodonVisibility = .public + + // Sensitive + @Published public var isContentSensitive: Bool = false + @Published public var isContentSensitiveToggled: Bool = false + @Published public var isMediaSensitive: Bool = false + @Published public var isMediaSensitiveToggled: Bool = false + + @Published public var isSensitive: Bool = false // isContentSensitive || isMediaSensitive + @Published public var isContentReveal: Bool = true + @Published public var isMediaReveal: Bool = true + + // Toolbar + @Published public var isReblog: Bool = false + @Published public var isReblogEnabled: Bool = true + @Published public var isFavorite: Bool = false + + @Published public var replyCount: Int = 0 + @Published public var reblogCount: Int = 0 + @Published public var favoriteCount: Int = 0 + + // Filter + @Published public var activeFilters: [Mastodon.Entity.Filter] = [] + @Published public var filterContext: Mastodon.Entity.Filter.Context? + @Published public var isFiltered = false + + @Published public var groupedAccessibilityLabel = "" + + let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common) + .autoconnect() + .share() + .eraseToAnyPublisher() + + public enum Header { + case none + case reply(info: ReplyInfo) + case repost(info: RepostInfo) + // case notification(info: NotificationHeaderInfo) + + public class ReplyInfo { + public let header: MetaContent + + public init(header: MetaContent) { + self.header = header + } + } + + public struct RepostInfo { + public let header: MetaContent + + public init(header: MetaContent) { + self.header = header + } + } + } + + public func prepareForReuse() { + authorAvatarImageURL = nil + + isContentSensitive = false + isContentSensitiveToggled = false + isMediaSensitive = false + isMediaSensitiveToggled = false + + activeFilters = [] + filterContext = nil + } + + init() { + // isReblogEnabled + Publishers.CombineLatest( + $visibility, + $isMyself + ) + .map { visibility, isMyself in + if isMyself { + return true + } + + switch visibility { + case .public, .unlisted: + return true + case .private, .direct, ._other: + return false + } + } + .assign(to: &$isReblogEnabled) + // isContentSensitive + $spoilerContent + .map { $0 != nil } + .assign(to: &$isContentSensitive) + // isSensitive + Publishers.CombineLatest( + $isContentSensitive, + $isMediaSensitive + ) + .map { $0 || $1 } + .assign(to: &$isSensitive) + // $isContentReveal + Publishers.CombineLatest( + $isContentSensitive, + $isContentSensitiveToggled + ) + .map { $0 ? $1 : true } + .assign(to: &$isContentReveal) + // $isMediaReveal + Publishers.CombineLatest( + $isMediaSensitive, + $isMediaSensitiveToggled + ) + .map { $1 ? !$0 : $0 } + .map { !$0 } + .assign(to: &$isMediaReveal) + } + } +} + +extension StatusView.ViewModel { + func bind(statusView: StatusView) { + bindHeader(statusView: statusView) + bindAuthor(statusView: statusView) + bindContent(statusView: statusView) + bindMedia(statusView: statusView) + bindPoll(statusView: statusView) + bindToolbar(statusView: statusView) + bindMetric(statusView: statusView) + bindMenu(statusView: statusView) + bindFilter(statusView: statusView) + bindAccessibility(statusView: statusView) + } + + private func bindHeader(statusView: StatusView) { + $header + .sink { header in + switch header { + case .none: + return + case .repost(let info): + statusView.headerIconImageView.image = Asset.Arrow.repeatSmall.image.withRenderingMode(.alwaysTemplate) + statusView.headerInfoLabel.configure(content: info.header) + statusView.setHeaderDisplay() + case .reply(let info): + statusView.headerIconImageView.image = UIImage(systemName: "arrowshape.turn.up.left.fill") + statusView.headerInfoLabel.configure(content: info.header) + statusView.setHeaderDisplay() + } + } + .store(in: &disposeBag) + } + + private func bindAuthor(statusView: StatusView) { + // avatar + Publishers.CombineLatest( + $authorAvatarImage.removeDuplicates(), + $authorAvatarImageURL.removeDuplicates() + ) + .sink { image, url in + let configuration: AvatarImageView.Configuration = { + if let image = image { + return AvatarImageView.Configuration(image: image) + } else { + return AvatarImageView.Configuration(url: url) + } + }() + statusView.avatarButton.avatarImageView.configure(configuration: configuration) + statusView.avatarButton.avatarImageView.configure(cornerConfiguration: .init(corner: .fixed(radius: 12))) + } + .store(in: &disposeBag) + // name + $authorName + .sink { metaContent in + let metaContent = metaContent ?? PlaintextMetaContent(string: " ") + statusView.authorNameLabel.configure(content: metaContent) + } + .store(in: &disposeBag) + // username + $authorUsername + .map { text -> String in + guard let text = text else { return "" } + return "@\(text)" + } + .sink { username in + let metaContent = PlaintextMetaContent(string: username) + statusView.authorUsernameLabel.configure(content: metaContent) + } + .store(in: &disposeBag) + // timestamp + Publishers.CombineLatest( + $timestamp, + timestampUpdatePublisher.prepend(Date()).eraseToAnyPublisher() + ) + .compactMap { [weak self] timestamp, _ -> String? in + guard let self = self else { return nil } + guard let timestamp = timestamp, + let text = self.timestampFormatter?(timestamp) + else { return "" } + return text + } + .removeDuplicates() + .assign(to: &$timestampText) + + $timestampText + .sink { [weak self] text in + guard let _ = self else { return } + statusView.dateLabel.configure(content: PlaintextMetaContent(string: text)) + } + .store(in: &disposeBag) + } + + private func bindContent(statusView: StatusView) { + Publishers.CombineLatest4( + $spoilerContent, + $content, + $language, + $isContentReveal.removeDuplicates() + ) + .sink { spoilerContent, content, language, isContentReveal in + if let spoilerContent = spoilerContent { + statusView.spoilerOverlayView.spoilerMetaLabel.configure(content: spoilerContent) + // statusView.spoilerBannerView.label.configure(content: spoilerContent) + // statusView.setSpoilerBannerViewHidden(isHidden: !isContentReveal) + + } else { + statusView.spoilerOverlayView.spoilerMetaLabel.reset() + // statusView.spoilerBannerView.label.reset() + } + + let paragraphStyle = statusView.contentMetaText.paragraphStyle + if let language = language { + let direction = Locale.characterDirection(forLanguage: language) + paragraphStyle.alignment = direction == .rightToLeft ? .right : .left + } else { + paragraphStyle.alignment = .natural + } + statusView.contentMetaText.paragraphStyle = paragraphStyle + + if let content = content { + statusView.contentMetaText.configure( + content: content + ) + statusView.contentMetaText.textView.accessibilityLabel = content.string + statusView.contentMetaText.textView.accessibilityTraits = [.staticText] + statusView.contentMetaText.textView.accessibilityElementsHidden = false + } else { + statusView.contentMetaText.reset() + statusView.contentMetaText.textView.accessibilityLabel = "" + } + + statusView.contentMetaText.textView.alpha = isContentReveal ? 1 : 0 // keep the frame size and only display when revealing + + statusView.setSpoilerOverlayViewHidden(isHidden: isContentReveal) + + let image = isContentReveal ? UIImage(systemName: "eye.slash.fill") : UIImage(systemName: "eye.fill") + statusView.contentSensitiveeToggleButton.setImage(image, for: .normal) + + self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): isContentReveal: \(isContentReveal)") + } + .store(in: &disposeBag) + + $isSensitive + .sink { isSensitive in + guard isSensitive else { return } + statusView.setContentSensitiveeToggleButtonDisplay() + } + .store(in: &disposeBag) + +// // visibility +// Publishers.CombineLatest( +// $visibility, +// $isMyself +// ) +// .sink { visibility, isMyself in +// switch visibility { +// case .public: +// break +// case .unlisted: +// statusView.statusVisibilityView.label.text = "Everyone can see this post but not display in the public timeline." +// statusView.setVisibilityDisplay() +// case .private: +// statusView.statusVisibilityView.label.text = isMyself ? "Only my followers can see this post." : "Only their followers can see this post." +// statusView.setVisibilityDisplay() +// case .direct: +// statusView.statusVisibilityView.label.text = "Only mentioned user can see this post." +// statusView.setVisibilityDisplay() +// case ._other: +// break +// } +// } +// .store(in: &disposeBag) + } + + private func bindMedia(statusView: StatusView) { + $mediaViewConfigurations + .sink { [weak self] configurations in + guard let self = self else { return } + self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): configure media") + + statusView.mediaGridContainerView.prepareForReuse() + + let maxSize = CGSize( + width: statusView.contentMaxLayoutWidth, + height: 9999 // fulfill the width + ) + var needsDisplay = true + switch configurations.count { + case 0: + needsDisplay = false + case 1: + let configuration = configurations[0] + let adaptiveLayout = MediaGridContainerView.AdaptiveLayout( + aspectRatio: configuration.aspectRadio, + maxSize: maxSize + ) + let mediaView = statusView.mediaGridContainerView.dequeueMediaView(adaptiveLayout: adaptiveLayout) + mediaView.setup(configuration: configuration) + default: + let gridLayout = MediaGridContainerView.GridLayout( + count: configurations.count, + maxSize: maxSize + ) + let mediaViews = statusView.mediaGridContainerView.dequeueMediaView(gridLayout: gridLayout) + for (i, (configuration, mediaView)) in zip(configurations, mediaViews).enumerated() { + guard i < MediaGridContainerView.maxCount else { break } + mediaView.setup(configuration: configuration) + } + } + if needsDisplay { + statusView.setMediaDisplay() + } + } + .store(in: &disposeBag) + + Publishers.CombineLatest( + $mediaViewConfigurations, + $isMediaReveal + ) + .sink { configurations, isMediaReveal in + for configuration in configurations { + configuration.isReveal = isMediaReveal + } + } + .store(in: &disposeBag) + + $isMediaReveal + .sink { isMediaReveal in + statusView.mediaGridContainerView.viewModel.isSensitiveToggleButtonDisplay = isMediaReveal + } + .store(in: &disposeBag) + } + + private func bindPoll(statusView: StatusView) { + $pollItems + .sink { items in + guard !items.isEmpty else { return } + + var snapshot = NSDiffableDataSourceSnapshot<PollSection, PollItem>() + snapshot.appendSections([.main]) + snapshot.appendItems(items, toSection: .main) + if #available(iOS 15.0, *) { + statusView.pollTableViewDiffableDataSource?.applySnapshotUsingReloadData(snapshot) + } else { + // Fallback on earlier versions + statusView.pollTableViewDiffableDataSource?.apply(snapshot, animatingDifferences: false) + } + + statusView.pollTableViewHeightLayoutConstraint.constant = CGFloat(items.count) * PollOptionTableViewCell.height + statusView.setPollDisplay() + } + .store(in: &disposeBag) + $isVotable + .sink { isVotable in + statusView.pollTableView.allowsSelection = isVotable + } + .store(in: &disposeBag) + // poll + let pollVoteDescription = Publishers.CombineLatest( + $voterCount, + $voteCount + ) + .map { voterCount, voteCount -> String in + var description = "" + if let voterCount = voterCount { + description += L10n.Plural.Count.voter(voterCount) + } else { + description += L10n.Plural.Count.vote(voteCount) + } + return description + } + let pollCountdownDescription = Publishers.CombineLatest3( + $expireAt, + $expired, + timestampUpdatePublisher.prepend(Date()).eraseToAnyPublisher() + ) + .map { expireAt, expired, _ -> String? in + guard !expired else { + return L10n.Common.Controls.Status.Poll.closed + } + + guard let expireAt = expireAt else { + return nil + } + let timeLeft = expireAt.localizedTimeLeft() + + return timeLeft + } + Publishers.CombineLatest( + pollVoteDescription, + pollCountdownDescription + ) + .sink { pollVoteDescription, pollCountdownDescription in + statusView.pollVoteCountLabel.text = pollVoteDescription ?? "-" + statusView.pollCountdownLabel.text = pollCountdownDescription ?? "-" + } + .store(in: &disposeBag) + Publishers.CombineLatest( + $isVotable, + $isVoting + ) + .sink { isVotable, isVoting in + guard isVotable else { + statusView.pollVoteButton.isHidden = true + statusView.pollVoteActivityIndicatorView.isHidden = true + return + } + + statusView.pollVoteButton.isHidden = isVoting + statusView.pollVoteActivityIndicatorView.isHidden = !isVoting + statusView.pollVoteActivityIndicatorView.startAnimating() + } + .store(in: &disposeBag) + $isVoteButtonEnabled + .assign(to: \.isEnabled, on: statusView.pollVoteButton) + .store(in: &disposeBag) + } + + private func bindToolbar(statusView: StatusView) { + $replyCount + .sink { count in + statusView.actionToolbarContainer.configureReply( + count: count, + isEnabled: true + ) + } + .store(in: &disposeBag) + Publishers.CombineLatest3( + $reblogCount, + $isReblog, + $isReblogEnabled + ) + .sink { count, isHighlighted, isEnabled in + statusView.actionToolbarContainer.configureReblog( + count: count, + isEnabled: isEnabled, + isHighlighted: isHighlighted + ) + } + .store(in: &disposeBag) + Publishers.CombineLatest( + $favoriteCount, + $isFavorite + ) + .sink { count, isHighlighted in + statusView.actionToolbarContainer.configureFavorite( + count: count, + isEnabled: true, + isHighlighted: isHighlighted + ) + } + .store(in: &disposeBag) + } + + private func bindMetric(statusView: StatusView) { + let reblogButtonTitle = $reblogCount.map { count in + L10n.Plural.Count.reblog(count) + }.share() + + let favoriteButtonTitle = $favoriteCount.map { count in + L10n.Plural.Count.favorite(count) + }.share() + + + let metricButtonTitleLength = Publishers.CombineLatest( + reblogButtonTitle, + favoriteButtonTitle + ).map { $0.count + $1.count } + + Publishers.CombineLatest( + $timestamp, + metricButtonTitleLength + ) + .sink { timestamp, metricButtonTitleLength in + let text: String = { + guard let timestamp = timestamp else { return " " } + + let formatter = DateFormatter() + + // make adaptive UI + if UIView.isZoomedMode || metricButtonTitleLength > 20 { + formatter.dateStyle = .short + formatter.timeStyle = .short + } else { + formatter.dateStyle = .medium + formatter.timeStyle = .short + } + return formatter.string(from: timestamp) + }() + + statusView.statusMetricView.dateLabel.text = text + } + .store(in: &disposeBag) + + reblogButtonTitle + .sink { title in + statusView.statusMetricView.reblogButton.setTitle(title, for: .normal) + } + .store(in: &disposeBag) + + favoriteButtonTitle + .sink { title in + statusView.statusMetricView.favoriteButton.setTitle(title, for: .normal) + } + .store(in: &disposeBag) + } + + private func bindMenu(statusView: StatusView) { + Publishers.CombineLatest4( + $authorName, + $isMuting, + $isBlocking, + $isMyself + ) + .sink { authorName, isMuting, isBlocking, isMyself in + guard let name = authorName?.string else { + statusView.menuButton.menu = nil + return + } + + let menuContext = StatusView.AuthorMenuContext( + name: name, + isMuting: isMuting, + isBlocking: isBlocking, + isMyself: isMyself + ) + statusView.menuButton.menu = statusView.setupAuthorMenu(menuContext: menuContext) + statusView.menuButton.showsMenuAsPrimaryAction = true + } + .store(in: &disposeBag) + } + + private func bindFilter(statusView: StatusView) { + $isFiltered + .sink { isFiltered in + statusView.containerStackView.isHidden = isFiltered + if isFiltered { + statusView.setFilterHintLabelDisplay() + } + } + .store(in: &disposeBag) + } + + private func bindAccessibility(statusView: StatusView) { + let authorAccessibilityLabel = Publishers.CombineLatest3( + $header, + $authorName, + $timestampText + ) + .map { header, authorName, timestamp -> String? in + var strings: [String?] = [] + + switch header { + case .none: + break + case .reply(let info): + strings.append(info.header.string) + case .repost(let info): + strings.append(info.header.string) + } + + strings.append(authorName?.string) + strings.append(timestamp) + + return strings.compactMap { $0 }.joined(separator: ", ") + } + + let contentAccessibilityLabel = Publishers.CombineLatest3( + $isContentReveal, + $spoilerContent, + $content + ) + .map { isContentReveal, spoilerContent, content -> String? in + var strings: [String?] = [] + + if let spoilerContent = spoilerContent, !spoilerContent.string.isEmpty { + strings.append(L10n.Common.Controls.Status.contentWarning) + strings.append(spoilerContent.string) + + // TODO: replace with "Tap to reveal" + strings.append(L10n.Common.Controls.Status.mediaContentWarning) + } + + if isContentReveal { + strings.append(content?.string) + } + + return strings.compactMap { $0 }.joined(separator: ", ") + } + + $isContentReveal + .map { isContentReveal in + isContentReveal ? L10n.Scene.Compose.Accessibility.enableContentWarning : L10n.Scene.Compose.Accessibility.disableContentWarning + } + .sink { label in + statusView.contentSensitiveeToggleButton.accessibilityLabel = label + } + .store(in: &disposeBag) + + contentAccessibilityLabel + .sink { contentAccessibilityLabel in + statusView.spoilerOverlayView.accessibilityLabel = contentAccessibilityLabel + } + .store(in: &disposeBag) + + let meidaAccessibilityLabel = $mediaViewConfigurations + .map { configurations -> String? in + let count = configurations.count + // TODO: i18n + return count > 0 ? "\(count) media" : nil + } + + // TODO: Toolbar + + Publishers.CombineLatest3( + authorAccessibilityLabel, + contentAccessibilityLabel, + meidaAccessibilityLabel + ) + .map { author, content, media in + let group = [ + author, + content, + media + ] + + return group + .compactMap { $0 } + .joined(separator: ", ") + } + .assign(to: &$groupedAccessibilityLabel) + + $groupedAccessibilityLabel + .sink { accessibilityLabel in + statusView.accessibilityLabel = accessibilityLabel + } + .store(in: &disposeBag) + } + +} + + diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift new file mode 100644 index 000000000..b938f2b97 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift @@ -0,0 +1,828 @@ +// +// StatusView.swift +// +// +// Created by MainasuK on 2022-1-10. +// + +import os.log +import UIKit +import Combine +import MetaTextKit +import Meta +import MastodonAsset +import MastodonLocalization + +public protocol StatusViewDelegate: AnyObject { + func statusView(_ statusView: StatusView, headerDidPressed header: UIView) + func statusView(_ statusView: StatusView, authorAvatarButtonDidPressed button: AvatarButton) + func statusView(_ statusView: StatusView, contentSensitiveeToggleButtonDidPressed button: UIButton) + func statusView(_ statusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta) + func statusView(_ statusView: StatusView, mediaGridContainerView: MediaGridContainerView, mediaView: MediaView, didSelectMediaViewAt index: Int) + func statusView(_ statusView: StatusView, pollTableView tableView: UITableView, didSelectRowAt indexPath: IndexPath) + func statusView(_ statusView: StatusView, pollVoteButtonPressed button: UIButton) + func statusView(_ statusView: StatusView, actionToolbarContainer: ActionToolbarContainer, buttonDidPressed button: UIButton, action: ActionToolbarContainer.Action) + func statusView(_ statusView: StatusView, menuButton button: UIButton, didSelectAction action: MastodonMenu.Action) + func statusView(_ statusView: StatusView, spoilerOverlayViewDidPressed overlayView: SpoilerOverlayView) + func statusView(_ statusView: StatusView, mediaGridContainerView: MediaGridContainerView, mediaSensitiveButtonDidPressed button: UIButton) + + // a11y + func statusView(_ statusView: StatusView, accessibilityActivate: Void) +} + +public final class StatusView: UIView { + + public static let containerLayoutMargin: CGFloat = 16 + + let logger = Logger(subsystem: "StatusView", category: "View") + + private var _disposeBag = Set<AnyCancellable>() // which lifetime same to view scope + public var disposeBag = Set<AnyCancellable>() + + public weak var delegate: StatusViewDelegate? + + public private(set) var style: Style? + + public private(set) lazy var viewModel: ViewModel = { + let viewModel = ViewModel() + viewModel.bind(statusView: self) + return viewModel + }() + + let containerStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.spacing = 10 + return stackView + }() + + // header + let headerAdaptiveMarginContainerView = AdaptiveMarginContainerView() + public let headerContainerView = UIView() + + // header icon + let headerIconImageView: UIImageView = { + let imageView = UIImageView() + imageView.tintColor = Asset.Colors.Label.secondary.color + imageView.contentMode = .scaleAspectFill + return imageView + }() + + // header info + let headerInfoLabel = MetaLabel(style: .statusHeader) + + // author + let authorAdaptiveMarginContainerView = AdaptiveMarginContainerView() + let authorContainerView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.spacing = 12 + return stackView + }() + + // avatar + public let avatarButton = AvatarButton() + + // author name + public let authorNameLabel = MetaLabel(style: .statusName) + + // author username + public let authorUsernameLabel = MetaLabel(style: .statusUsername) + + public let usernameTrialingDotLabel: MetaLabel = { + let label = MetaLabel(style: .statusUsername) + label.configure(content: PlaintextMetaContent(string: "·")) + return label + }() + + // timestamp + public let dateLabel = MetaLabel(style: .statusUsername) + + public let menuButton: UIButton = { + let button = HitTestExpandedButton(type: .system) + button.expandEdgeInsets = UIEdgeInsets(top: -20, left: -10, bottom: -5, right: -10) + button.tintColor = Asset.Colors.Label.secondary.color + let image = UIImage(systemName: "ellipsis", withConfiguration: UIImage.SymbolConfiguration(font: .systemFont(ofSize: 15))) + button.setImage(image, for: .normal) + button.accessibilityLabel = L10n.Common.Controls.Status.Actions.menu + return button + }() + + public let contentSensitiveeToggleButton: UIButton = { + let button = HitTestExpandedButton(type: .system) + button.expandEdgeInsets = UIEdgeInsets(top: -5, left: -10, bottom: -20, right: -10) + button.tintColor = Asset.Colors.Label.secondary.color + button.imageView?.contentMode = .scaleAspectFill + button.imageView?.clipsToBounds = false + let image = UIImage(systemName: "eye.slash.fill", withConfiguration: UIImage.SymbolConfiguration(font: .systemFont(ofSize: 15))) + button.setImage(image, for: .normal) + return button + }() + + // content + let contentAdaptiveMarginContainerView = AdaptiveMarginContainerView() + let contentContainer = UIStackView() + public let contentMetaText: MetaText = { + let metaText = MetaText() + metaText.textView.backgroundColor = .clear + metaText.textView.isEditable = false + metaText.textView.isSelectable = false + metaText.textView.isScrollEnabled = false + metaText.textView.textContainer.lineFragmentPadding = 0 + metaText.textView.textContainerInset = .zero + metaText.textView.layer.masksToBounds = false + metaText.textView.textDragInteraction?.isEnabled = false // disable drag for link and attachment + + metaText.paragraphStyle = { + let style = NSMutableParagraphStyle() + style.lineSpacing = 5 + style.paragraphSpacing = 8 + style.alignment = .natural + return style + }() + metaText.textAttributes = [ + .font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15, weight: .regular)), + .foregroundColor: Asset.Colors.Label.primary.color, + ] + metaText.linkAttributes = [ + .font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15, weight: .semibold)), + .foregroundColor: Asset.Colors.brandBlue.color, + ] + return metaText + }() + + // content warning + public let spoilerOverlayView = SpoilerOverlayView() + + // media + public let mediaContainerView = UIView() + public let mediaGridContainerView = MediaGridContainerView() + + // poll + let pollAdaptiveMarginContainerView = AdaptiveMarginContainerView() + let pollContainerView = UIStackView() + public let pollTableView: UITableView = { + let tableView = UITableView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + tableView.register(PollOptionTableViewCell.self, forCellReuseIdentifier: String(describing: PollOptionTableViewCell.self)) + tableView.isScrollEnabled = false + tableView.estimatedRowHeight = 36 + tableView.tableFooterView = UIView() + tableView.backgroundColor = .clear + tableView.separatorStyle = .none + return tableView + }() + public var pollTableViewHeightLayoutConstraint: NSLayoutConstraint! + public var pollTableViewDiffableDataSource: UITableViewDiffableDataSource<PollSection, PollItem>? + + public let pollStatusStackView = UIStackView() + let pollVoteCountLabel: UILabel = { + let label = UILabel() + label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 12, weight: .regular)) + label.textColor = Asset.Colors.Label.secondary.color + label.text = L10n.Plural.Count.vote(0) + return label + }() + let pollStatusDotLabel: UILabel = { + let label = UILabel() + label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 12, weight: .regular)) + label.textColor = Asset.Colors.Label.secondary.color + label.text = " · " + label.isAccessibilityElement = false + return label + }() + let pollCountdownLabel: UILabel = { + let label = UILabel() + label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 12, weight: .regular)) + label.textColor = Asset.Colors.Label.secondary.color + label.text = "1 day left" + return label + }() + let pollVoteButton: UIButton = { + let button = HitTestExpandedButton() + button.titleLabel?.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 14, weight: .semibold)) + button.setTitle(L10n.Common.Controls.Status.Poll.vote, for: .normal) + button.setTitleColor(Asset.Colors.brandBlue.color, for: .normal) + button.setTitleColor(Asset.Colors.brandBlue.color.withAlphaComponent(0.8), for: .highlighted) + button.setTitleColor(Asset.Colors.Button.disabled.color, for: .disabled) + button.isEnabled = false + return button + }() + let pollVoteActivityIndicatorView: UIActivityIndicatorView = { + let indicatorView = UIActivityIndicatorView(style: .medium) + indicatorView.hidesWhenStopped = true + indicatorView.stopAnimating() + return indicatorView + }() + + // toolbar + let actionToolbarAdaptiveMarginContainerView = AdaptiveMarginContainerView() + public let actionToolbarContainer = ActionToolbarContainer() + + // metric + let statusMetricViewAdaptiveMarginContainerView = AdaptiveMarginContainerView() + public let statusMetricView = StatusMetricView() + + // filter hint + public let filterHintLabel: UILabel = { + let label = UILabel() + label.textColor = Asset.Colors.Label.secondary.color + label.text = L10n.Common.Controls.Timeline.filtered + label.font = .systemFont(ofSize: 17, weight: .regular) + return label + }() + + public func prepareForReuse() { + disposeBag.removeAll() + + viewModel.objects.removeAll() + viewModel.prepareForReuse() + + avatarButton.avatarImageView.cancelTask() + if var snapshot = pollTableViewDiffableDataSource?.snapshot() { + snapshot.deleteAllItems() + if #available(iOS 15.0, *) { + pollTableViewDiffableDataSource?.applySnapshotUsingReloadData(snapshot) + } else { + // Fallback on earlier versions + pollTableViewDiffableDataSource?.apply(snapshot, animatingDifferences: false) + } + } + + setHeaderDisplay(isDisplay: false) + setContentSensitiveeToggleButtonDisplay(isDisplay: false) + setSpoilerOverlayViewHidden(isHidden: true) + setMediaDisplay(isDisplay: false) + setPollDisplay(isDisplay: false) + setFilterHintLabelDisplay(isDisplay: false) + } + + public override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + public required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension StatusView { + private func _init() { + // container + containerStackView.translatesAutoresizingMaskIntoConstraints = false + addSubview(containerStackView) + NSLayoutConstraint.activate([ + containerStackView.topAnchor.constraint(equalTo: topAnchor), + containerStackView.leadingAnchor.constraint(equalTo: leadingAnchor), + containerStackView.trailingAnchor.constraint(equalTo: trailingAnchor), + containerStackView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + // header + headerIconImageView.isUserInteractionEnabled = false + headerInfoLabel.isUserInteractionEnabled = false + let headerTapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer + headerTapGestureRecognizer.addTarget(self, action: #selector(StatusView.headerDidPressed(_:))) + headerContainerView.addGestureRecognizer(headerTapGestureRecognizer) + + // avatar button + avatarButton.addTarget(self, action: #selector(StatusView.authorAvatarButtonDidPressed(_:)), for: .touchUpInside) + authorNameLabel.isUserInteractionEnabled = false + authorUsernameLabel.isUserInteractionEnabled = false + + // contentSensitiveeToggleButton + contentSensitiveeToggleButton.addTarget(self, action: #selector(StatusView.contentSensitiveeToggleButtonDidPressed(_:)), for: .touchUpInside) + + // dateLabel + dateLabel.isUserInteractionEnabled = false + + // content warning + let spoilerOverlayViewTapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer + spoilerOverlayView.addGestureRecognizer(spoilerOverlayViewTapGestureRecognizer) + spoilerOverlayViewTapGestureRecognizer.addTarget(self, action: #selector(StatusView.spoilerOverlayViewTapGestureRecognizerHandler(_:))) + + // content + contentMetaText.textView.delegate = self + contentMetaText.textView.linkDelegate = self + + // media + mediaGridContainerView.delegate = self + + // poll + pollTableView.translatesAutoresizingMaskIntoConstraints = false + pollTableViewHeightLayoutConstraint = pollTableView.heightAnchor.constraint(equalToConstant: 44.0).priority(.required - 1) + NSLayoutConstraint.activate([ + pollTableViewHeightLayoutConstraint, + ]) + pollTableView.delegate = self + pollVoteButton.addTarget(self, action: #selector(StatusView.pollVoteButtonDidPressed(_:)), for: .touchUpInside) + // toolbar + actionToolbarContainer.delegate = self + } +} + +extension StatusView { + + @objc private func headerDidPressed(_ sender: UITapGestureRecognizer) { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") + assert(sender.view === headerContainerView) + delegate?.statusView(self, headerDidPressed: headerContainerView) + } + + @objc private func authorAvatarButtonDidPressed(_ sender: UIButton) { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") + delegate?.statusView(self, authorAvatarButtonDidPressed: avatarButton) + } + + @objc private func contentSensitiveeToggleButtonDidPressed(_ sender: UIButton) { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") + delegate?.statusView(self, contentSensitiveeToggleButtonDidPressed: sender) + } + + @objc private func pollVoteButtonDidPressed(_ sender: UIButton) { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") + delegate?.statusView(self, pollVoteButtonPressed: pollVoteButton) + } + + @objc private func spoilerOverlayViewTapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") + delegate?.statusView(self, spoilerOverlayViewDidPressed: spoilerOverlayView) + } + +} + +extension StatusView { + + public func setup(style: Style) { + guard self.style == nil else { + assertionFailure("Should only setup once") + return + } + self.style = style + style.layout(statusView: self) + prepareForReuse() + } + + public enum Style { + case inline + case plain + case report + case notification + case notificationQuote + case composeStatusReplica + case composeStatusAuthor + } +} + +extension StatusView.Style { + + func layout(statusView: StatusView) { + switch self { + case .inline: inline(statusView: statusView) + case .plain: plain(statusView: statusView) + case .report: report(statusView: statusView) + case .notification: notification(statusView: statusView) + case .notificationQuote: notificationQuote(statusView: statusView) + case .composeStatusReplica: composeStatusReplica(statusView: statusView) + case .composeStatusAuthor: composeStatusAuthor(statusView: statusView) + } + } + + private func base(statusView: StatusView) { + // container: V - [ header container | author container | content container | media container | pollTableView | actionToolbarContainer ] + + // header container: H - [ icon | label ] + statusView.headerAdaptiveMarginContainerView.contentView = statusView.headerContainerView + statusView.headerAdaptiveMarginContainerView.margin = StatusView.containerLayoutMargin + statusView.containerStackView.addArrangedSubview(statusView.headerAdaptiveMarginContainerView) + + statusView.headerIconImageView.translatesAutoresizingMaskIntoConstraints = false + statusView.headerInfoLabel.translatesAutoresizingMaskIntoConstraints = false + statusView.headerContainerView.addSubview(statusView.headerIconImageView) + statusView.headerContainerView.addSubview(statusView.headerInfoLabel) + NSLayoutConstraint.activate([ + statusView.headerIconImageView.leadingAnchor.constraint(equalTo: statusView.headerContainerView.leadingAnchor), + statusView.headerIconImageView.heightAnchor.constraint(equalTo: statusView.headerInfoLabel.heightAnchor, multiplier: 1.0).priority(.required - 1), + statusView.headerIconImageView.widthAnchor.constraint(equalTo: statusView.headerIconImageView.heightAnchor, multiplier: 1.0).priority(.required - 1), + statusView.headerInfoLabel.topAnchor.constraint(equalTo: statusView.headerContainerView.topAnchor), + statusView.headerInfoLabel.leadingAnchor.constraint(equalTo: statusView.headerIconImageView.trailingAnchor, constant: 6), + statusView.headerInfoLabel.trailingAnchor.constraint(equalTo: statusView.headerContainerView.trailingAnchor), + statusView.headerInfoLabel.bottomAnchor.constraint(equalTo: statusView.headerContainerView.bottomAnchor), + statusView.headerInfoLabel.centerYAnchor.constraint(equalTo: statusView.headerIconImageView.centerYAnchor), + ]) + statusView.headerInfoLabel.setContentHuggingPriority(.required, for: .vertical) + statusView.headerIconImageView.setContentHuggingPriority(.defaultLow, for: .vertical) + statusView.headerIconImageView.setContentHuggingPriority(.defaultLow, for: .horizontal) + statusView.headerIconImageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical) + statusView.headerIconImageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + // author container: H - [ avatarButton | author meta container | contentWarningToggleButton ] + statusView.authorAdaptiveMarginContainerView.contentView = statusView.authorContainerView + statusView.authorAdaptiveMarginContainerView.margin = StatusView.containerLayoutMargin + statusView.containerStackView.addArrangedSubview(statusView.authorAdaptiveMarginContainerView) + + UIContentSizeCategory.publisher + .sink { category in + statusView.authorContainerView.axis = category > .accessibilityLarge ? .vertical : .horizontal + statusView.authorContainerView.alignment = category > .accessibilityLarge ? .leading : .center + } + .store(in: &statusView._disposeBag) + + // avatarButton + let authorAvatarButtonSize = CGSize(width: 46, height: 46) + statusView.avatarButton.size = authorAvatarButtonSize + statusView.avatarButton.avatarImageView.imageViewSize = authorAvatarButtonSize + statusView.avatarButton.translatesAutoresizingMaskIntoConstraints = false + statusView.authorContainerView.addArrangedSubview(statusView.avatarButton) + NSLayoutConstraint.activate([ + statusView.avatarButton.widthAnchor.constraint(equalToConstant: authorAvatarButtonSize.width).priority(.required - 1), + statusView.avatarButton.heightAnchor.constraint(equalToConstant: authorAvatarButtonSize.height).priority(.required - 1), + ]) + statusView.avatarButton.setContentHuggingPriority(.required - 1, for: .vertical) + statusView.avatarButton.setContentCompressionResistancePriority(.required - 1, for: .vertical) + + // authrMetaContainer: V - [ authorPrimaryMetaContainer | authorSecondaryMetaContainer ] + let authorMetaContainer = UIStackView() + authorMetaContainer.axis = .vertical + authorMetaContainer.spacing = 4 + statusView.authorContainerView.addArrangedSubview(authorMetaContainer) + + // authorPrimaryMetaContainer: H - [ authorNameLabel | (padding) | menuButton ] + let authorPrimaryMetaContainer = UIStackView() + authorPrimaryMetaContainer.axis = .horizontal + authorPrimaryMetaContainer.spacing = 10 + authorMetaContainer.addArrangedSubview(authorPrimaryMetaContainer) + + // authorNameLabel + authorPrimaryMetaContainer.addArrangedSubview(statusView.authorNameLabel) + statusView.authorNameLabel.setContentHuggingPriority(.required - 10, for: .horizontal) + statusView.authorNameLabel.setContentCompressionResistancePriority(.required - 10, for: .horizontal) + authorPrimaryMetaContainer.addArrangedSubview(UIView()) + // menuButton + authorPrimaryMetaContainer.addArrangedSubview(statusView.menuButton) + statusView.menuButton.setContentHuggingPriority(.required - 2, for: .horizontal) + statusView.menuButton.setContentCompressionResistancePriority(.required - 2, for: .horizontal) + + // authorSecondaryMetaContainer: H - [ authorUsername | usernameTrialingDotLabel | dateLabel | (padding) | contentSensitiveeToggleButton ] + let authorSecondaryMetaContainer = UIStackView() + authorSecondaryMetaContainer.axis = .horizontal + authorSecondaryMetaContainer.spacing = 4 + authorMetaContainer.addArrangedSubview(authorSecondaryMetaContainer) + + authorSecondaryMetaContainer.addArrangedSubview(statusView.authorUsernameLabel) + statusView.authorUsernameLabel.setContentHuggingPriority(.required - 8, for: .horizontal) + statusView.authorUsernameLabel.setContentCompressionResistancePriority(.required - 8, for: .horizontal) + authorSecondaryMetaContainer.addArrangedSubview(statusView.usernameTrialingDotLabel) + statusView.usernameTrialingDotLabel.setContentHuggingPriority(.required - 2, for: .horizontal) + statusView.usernameTrialingDotLabel.setContentCompressionResistancePriority(.required - 2, for: .horizontal) + authorSecondaryMetaContainer.addArrangedSubview(statusView.dateLabel) + statusView.dateLabel.setContentHuggingPriority(.required - 1, for: .horizontal) + statusView.dateLabel.setContentCompressionResistancePriority(.required - 1, for: .horizontal) + authorSecondaryMetaContainer.addArrangedSubview(UIView()) + statusView.contentSensitiveeToggleButton.translatesAutoresizingMaskIntoConstraints = false + authorSecondaryMetaContainer.addArrangedSubview(statusView.contentSensitiveeToggleButton) + NSLayoutConstraint.activate([ + statusView.contentSensitiveeToggleButton.heightAnchor.constraint(equalTo: statusView.authorUsernameLabel.heightAnchor, multiplier: 1.0).priority(.required - 1), + statusView.contentSensitiveeToggleButton.widthAnchor.constraint(equalTo: statusView.contentSensitiveeToggleButton.heightAnchor, multiplier: 1.0).priority(.required - 1), + ]) + statusView.authorUsernameLabel.setContentHuggingPriority(.required - 1, for: .vertical) + statusView.authorUsernameLabel.setContentCompressionResistancePriority(.required - 1, for: .vertical) + statusView.contentSensitiveeToggleButton.setContentHuggingPriority(.defaultLow, for: .vertical) + statusView.contentSensitiveeToggleButton.setContentHuggingPriority(.defaultLow, for: .horizontal) + statusView.contentSensitiveeToggleButton.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + statusView.contentSensitiveeToggleButton.setContentCompressionResistancePriority(.defaultLow, for: .vertical) + + // content container: V - [ contentMetaText ] + statusView.contentContainer.axis = .vertical + statusView.contentContainer.spacing = 12 + statusView.contentContainer.distribution = .fill + statusView.contentContainer.alignment = .top + + statusView.contentAdaptiveMarginContainerView.contentView = statusView.contentContainer + statusView.contentAdaptiveMarginContainerView.margin = StatusView.containerLayoutMargin + statusView.containerStackView.addArrangedSubview(statusView.contentAdaptiveMarginContainerView) + statusView.contentContainer.setContentHuggingPriority(.required - 1, for: .vertical) + statusView.contentContainer.setContentCompressionResistancePriority(.required - 1, for: .vertical) + + // status content + statusView.contentContainer.addArrangedSubview(statusView.contentMetaText.textView) + + statusView.spoilerOverlayView.translatesAutoresizingMaskIntoConstraints = false + statusView.containerStackView.addSubview(statusView.spoilerOverlayView) + NSLayoutConstraint.activate([ + statusView.contentContainer.topAnchor.constraint(equalTo: statusView.spoilerOverlayView.topAnchor), + statusView.contentContainer.leadingAnchor.constraint(equalTo: statusView.spoilerOverlayView.leadingAnchor), + statusView.contentContainer.trailingAnchor.constraint(equalTo: statusView.spoilerOverlayView.trailingAnchor), + statusView.contentContainer.bottomAnchor.constraint(equalTo: statusView.spoilerOverlayView.bottomAnchor), + ]) + + // media container: V - [ mediaGridContainerView ] + statusView.mediaContainerView.translatesAutoresizingMaskIntoConstraints = false + statusView.containerStackView.addArrangedSubview(statusView.mediaContainerView) + NSLayoutConstraint.activate([ + statusView.mediaContainerView.leadingAnchor.constraint(equalTo: statusView.containerStackView.leadingAnchor), + statusView.mediaContainerView.trailingAnchor.constraint(equalTo: statusView.containerStackView.trailingAnchor), + ]) + + statusView.mediaGridContainerView.translatesAutoresizingMaskIntoConstraints = false + statusView.mediaContainerView.addSubview(statusView.mediaGridContainerView) + NSLayoutConstraint.activate([ + statusView.mediaGridContainerView.topAnchor.constraint(equalTo: statusView.mediaContainerView.topAnchor), + statusView.mediaGridContainerView.leadingAnchor.constraint(equalTo: statusView.mediaContainerView.leadingAnchor), + statusView.mediaGridContainerView.trailingAnchor.constraint(equalTo: statusView.mediaContainerView.trailingAnchor), + statusView.mediaGridContainerView.bottomAnchor.constraint(equalTo: statusView.mediaContainerView.bottomAnchor), + ]) + + // pollContainerView: V - [ pollTableView | pollStatusStackView ] + statusView.pollAdaptiveMarginContainerView.contentView = statusView.pollContainerView + statusView.pollAdaptiveMarginContainerView.margin = StatusView.containerLayoutMargin + statusView.pollContainerView.axis = .vertical + statusView.containerStackView.addArrangedSubview(statusView.pollAdaptiveMarginContainerView) + + // pollTableView + statusView.pollContainerView.addArrangedSubview(statusView.pollTableView) + + // pollStatusStackView: H - [ pollVoteCountLabel | pollCountdownLabel | pollVoteButton ] + statusView.pollStatusStackView.axis = .horizontal + statusView.pollContainerView.addArrangedSubview(statusView.pollStatusStackView) + + statusView.pollStatusStackView.addArrangedSubview(statusView.pollVoteCountLabel) + statusView.pollStatusStackView.addArrangedSubview(statusView.pollStatusDotLabel) + statusView.pollStatusStackView.addArrangedSubview(statusView.pollCountdownLabel) + statusView.pollStatusStackView.addArrangedSubview(statusView.pollVoteButton) + statusView.pollStatusStackView.addArrangedSubview(statusView.pollVoteActivityIndicatorView) + statusView.pollVoteCountLabel.setContentHuggingPriority(.defaultHigh + 2, for: .horizontal) + statusView.pollStatusDotLabel.setContentHuggingPriority(.defaultHigh + 1, for: .horizontal) + statusView.pollCountdownLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) + statusView.pollVoteButton.setContentHuggingPriority(.defaultHigh + 3, for: .horizontal) + + // action toolbar + statusView.actionToolbarAdaptiveMarginContainerView.contentView = statusView.actionToolbarContainer + statusView.actionToolbarAdaptiveMarginContainerView.margin = StatusView.containerLayoutMargin + statusView.actionToolbarContainer.configure(for: .inline) + statusView.containerStackView.addArrangedSubview(statusView.actionToolbarAdaptiveMarginContainerView) + + // filterHintLabel + statusView.filterHintLabel.translatesAutoresizingMaskIntoConstraints = false + statusView.addSubview(statusView.filterHintLabel) + NSLayoutConstraint.activate([ + statusView.filterHintLabel.centerXAnchor.constraint(equalTo: statusView.containerStackView.centerXAnchor), + statusView.filterHintLabel.centerYAnchor.constraint(equalTo: statusView.containerStackView.centerYAnchor), + ]) + } + + func inline(statusView: StatusView) { + base(statusView: statusView) + } + + func plain(statusView: StatusView) { + // container: V - [ … | statusMetricView ] + base(statusView: statusView) // override the base style + + // statusMetricView + statusView.statusMetricViewAdaptiveMarginContainerView.contentView = statusView.statusMetricView + statusView.statusMetricViewAdaptiveMarginContainerView.margin = StatusView.containerLayoutMargin + statusView.containerStackView.addArrangedSubview(statusView.statusMetricViewAdaptiveMarginContainerView) + + UIContentSizeCategory.publisher + .sink { category in + statusView.statusMetricView.containerStackView.axis = category > .accessibilityLarge ? .vertical : .horizontal + statusView.statusMetricView.containerStackView.alignment = category > .accessibilityLarge ? .leading : .fill + } + .store(in: &statusView._disposeBag) + } + + func report(statusView: StatusView) { + base(statusView: statusView) // override the base style + + statusView.menuButton.removeFromSuperview() + statusView.actionToolbarAdaptiveMarginContainerView.removeFromSuperview() + } + + func notification(statusView: StatusView) { + base(statusView: statusView) // override the base style + + statusView.headerAdaptiveMarginContainerView.removeFromSuperview() + statusView.authorAdaptiveMarginContainerView.removeFromSuperview() + } + + func notificationQuote(statusView: StatusView) { + base(statusView: statusView) // override the base style + + statusView.contentAdaptiveMarginContainerView.bottomLayoutConstraint?.constant = 16 // fix bottom margin missing issue + statusView.pollAdaptiveMarginContainerView.bottomLayoutConstraint?.constant = 16 // fix bottom margin missing issue + statusView.contentSensitiveeToggleButton.removeFromSuperview() + statusView.menuButton.removeFromSuperview() + statusView.actionToolbarAdaptiveMarginContainerView.removeFromSuperview() + } + + func composeStatusReplica(statusView: StatusView) { + base(statusView: statusView) + + statusView.avatarButton.isUserInteractionEnabled = false + statusView.menuButton.removeFromSuperview() + statusView.actionToolbarAdaptiveMarginContainerView.removeFromSuperview() + } + + func composeStatusAuthor(statusView: StatusView) { + base(statusView: statusView) + + statusView.avatarButton.isUserInteractionEnabled = false + statusView.menuButton.removeFromSuperview() + statusView.usernameTrialingDotLabel.removeFromSuperview() + statusView.dateLabel.removeFromSuperview() + statusView.contentAdaptiveMarginContainerView.removeFromSuperview() + statusView.spoilerOverlayView.removeFromSuperview() + statusView.mediaContainerView.removeFromSuperview() + statusView.pollAdaptiveMarginContainerView.removeFromSuperview() + statusView.actionToolbarAdaptiveMarginContainerView.removeFromSuperview() + } + +} + +extension StatusView { + func setHeaderDisplay(isDisplay: Bool = true) { + headerAdaptiveMarginContainerView.isHidden = !isDisplay + } + + func setContentSensitiveeToggleButtonDisplay(isDisplay: Bool = true) { + contentSensitiveeToggleButton.isHidden = !isDisplay + } + + func setSpoilerOverlayViewHidden(isHidden: Bool) { + spoilerOverlayView.isHidden = isHidden + spoilerOverlayView.setComponentHidden(isHidden) + } + + func setMediaDisplay(isDisplay: Bool = true) { + mediaContainerView.isHidden = !isDisplay + } + + func setPollDisplay(isDisplay: Bool = true) { + pollAdaptiveMarginContainerView.isHidden = !isDisplay + } + + func setFilterHintLabelDisplay(isDisplay: Bool = true) { + filterHintLabel.isHidden = !isDisplay + } + + // container width + public var contentMaxLayoutWidth: CGFloat { + return frame.width + } + +} + +// MARK: - AdaptiveContainerView +extension StatusView: AdaptiveContainerView { + public func updateContainerViewComponentsLayoutMarginsRelativeArrangementBehavior(isEnabled: Bool) { + let margin = isEnabled ? StatusView.containerLayoutMargin : .zero + headerAdaptiveMarginContainerView.margin = margin + authorAdaptiveMarginContainerView.margin = margin + contentAdaptiveMarginContainerView.margin = margin + pollAdaptiveMarginContainerView.margin = margin + actionToolbarAdaptiveMarginContainerView.margin = margin + statusMetricViewAdaptiveMarginContainerView.margin = margin + } +} + +extension StatusView { + + public struct AuthorMenuContext { + public let name: String + + public let isMuting: Bool + public let isBlocking: Bool + public let isMyself: Bool + } + + public func setupAuthorMenu(menuContext: AuthorMenuContext) -> UIMenu { + var actions: [MastodonMenu.Action] = [] + + actions = [ + .muteUser(.init( + name: menuContext.name, + isMuting: menuContext.isMuting + )), + .blockUser(.init( + name: menuContext.name, + isBlocking: menuContext.isBlocking + )), + .reportUser( + .init(name: menuContext.name) + ), + ] + + if menuContext.isMyself { + actions.append(.deleteStatus) + } + + + let menu = MastodonMenu.setupMenu( + actions: actions, + delegate: self + ) + + return menu + } + +} + +// MARK: - UITextViewDelegate +extension StatusView: UITextViewDelegate { + + public func textView(_ textView: UITextView, shouldInteractWith textAttachment: NSTextAttachment, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { + switch textView { + case contentMetaText.textView: + return false + default: + assertionFailure() + return true + } + } + + public func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { + switch textView { + case contentMetaText.textView: + return false + default: + assertionFailure() + return true + } + } +} + +// MARK: - MetaTextViewDelegate +extension StatusView: MetaTextViewDelegate { + public func metaTextView(_ metaTextView: MetaTextView, didSelectMeta meta: Meta) { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") + switch metaTextView { + case contentMetaText.textView: + delegate?.statusView(self, metaText: contentMetaText, didSelectMeta: meta) + default: + assertionFailure() + break + } + } +} + +// MARK: - MediaGridContainerViewDelegate +extension StatusView: MediaGridContainerViewDelegate { + public func mediaGridContainerView(_ container: MediaGridContainerView, didTapMediaView mediaView: MediaView, at index: Int) { + delegate?.statusView(self, mediaGridContainerView: container, mediaView: mediaView, didSelectMediaViewAt: index) + } + + public func mediaGridContainerView(_ container: MediaGridContainerView, mediaSensitiveButtonDidPressed button: UIButton) { + delegate?.statusView(self, mediaGridContainerView: container, mediaSensitiveButtonDidPressed: button) + } +} + +// MARK: - UITableViewDelegate +extension StatusView: UITableViewDelegate { + public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): select \(indexPath.debugDescription)") + + switch tableView { + case pollTableView: + delegate?.statusView(self, pollTableView: tableView, didSelectRowAt: indexPath) + default: + assertionFailure() + } + } +} + +// MARK: ActionToolbarContainerDelegate +extension StatusView: ActionToolbarContainerDelegate { + public func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, buttonDidPressed button: UIButton, action: ActionToolbarContainer.Action) { + delegate?.statusView(self, actionToolbarContainer: actionToolbarContainer, buttonDidPressed: button, action: action) + } +} + +// MARK: - MastodonMenuDelegate +extension StatusView: MastodonMenuDelegate { + public func menuAction(_ action: MastodonMenu.Action) { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") + delegate?.statusView(self, menuButton: menuButton, didSelectAction: action) + } +} + +#if DEBUG +import SwiftUI + +struct StatusView_Preview: PreviewProvider { + static var previews: some View { + UIViewPreview { + let statusView = StatusView() + statusView.setup(style: .inline) + configureStub(statusView: statusView) + return statusView + } + } + + static func configureStub(statusView: StatusView) { + // statusView.viewModel + } +} +#endif diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/UserView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/View/Content/UserView+ViewModel.swift new file mode 100644 index 000000000..0a970e884 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/Content/UserView+ViewModel.swift @@ -0,0 +1,65 @@ +// +// UserView+ViewModel.swift +// +// +// Created by MainasuK on 2022-1-19. +// + +import os.log +import UIKit +import Combine +import MetaTextKit + +extension UserView { + public final class ViewModel: ObservableObject { + public var disposeBag = Set<AnyCancellable>() + public var observations = Set<NSKeyValueObservation>() + + let logger = Logger(subsystem: "StatusView", category: "ViewModel") + + @Published public var authorAvatarImage: UIImage? + @Published public var authorAvatarImageURL: URL? + @Published public var authorName: MetaContent? + @Published public var authorUsername: String? + } +} + +extension UserView.ViewModel { + func bind(userView: UserView) { + // avatar + Publishers.CombineLatest( + $authorAvatarImage, + $authorAvatarImageURL + ) + .sink { image, url in + let configuration: AvatarImageView.Configuration = { + if let image = image { + return AvatarImageView.Configuration(image: image) + } else { + return AvatarImageView.Configuration(url: url) + } + }() + userView.avatarButton.avatarImageView.configure(configuration: configuration) + userView.avatarButton.avatarImageView.configure(cornerConfiguration: .init(corner: .fixed(radius: 7))) + } + .store(in: &disposeBag) + // name + $authorName + .sink { metaContent in + let metaContent = metaContent ?? PlaintextMetaContent(string: " ") + userView.authorNameLabel.configure(content: metaContent) + } + .store(in: &disposeBag) + // username + $authorUsername + .map { text -> String in + guard let text = text else { return "" } + return "@\(text)" + } + .sink { username in + let metaContent = PlaintextMetaContent(string: username) + userView.authorUsernameLabel.configure(content: metaContent) + } + .store(in: &disposeBag) + } +} diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/UserView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/UserView.swift new file mode 100644 index 000000000..cb066abfd --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/Content/UserView.swift @@ -0,0 +1,100 @@ +// +// UserView.swift +// +// +// Created by MainasuK on 2022-1-19. +// + +import UIKit +import Combine +import MetaTextKit + +public final class UserView: UIView { + + public var disposeBag = Set<AnyCancellable>() + + public private(set) lazy var viewModel: ViewModel = { + let viewModel = ViewModel() + viewModel.bind(userView: self) + return viewModel + }() + + public let containerStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.alignment = .center + stackView.spacing = 12 + stackView.layoutMargins = UIEdgeInsets(top: 12, left: 0, bottom: 12, right: 0) + stackView.isLayoutMarginsRelativeArrangement = true + return stackView + }() + + // avatar + public let avatarButton = AvatarButton() + + // author name + public let authorNameLabel = MetaLabel(style: .statusName) + + // author username + public let authorUsernameLabel = MetaLabel(style: .statusUsername) + + public func prepareForReuse() { + disposeBag.removeAll() + + // viewModel.objects.removeAll() + viewModel.authorAvatarImageURL = nil + + avatarButton.avatarImageView.cancelTask() + } + + public override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + public required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + + +extension UserView { + + private func _init() { + // container + containerStackView.translatesAutoresizingMaskIntoConstraints = false + addSubview(containerStackView) + NSLayoutConstraint.activate([ + containerStackView.topAnchor.constraint(equalTo: topAnchor), + containerStackView.leadingAnchor.constraint(equalTo: leadingAnchor), + containerStackView.trailingAnchor.constraint(equalTo: trailingAnchor), + containerStackView.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + + avatarButton.translatesAutoresizingMaskIntoConstraints = false + containerStackView.addArrangedSubview(avatarButton) + NSLayoutConstraint.activate([ + avatarButton.widthAnchor.constraint(equalToConstant: 28).priority(.required - 1), + avatarButton.heightAnchor.constraint(equalToConstant: 28).priority(.required - 1), + ]) + avatarButton.setContentHuggingPriority(.defaultLow, for: .vertical) + avatarButton.setContentHuggingPriority(.defaultLow, for: .horizontal) + + // label container + let labelStackView = UIStackView() + labelStackView.axis = .vertical + containerStackView.addArrangedSubview(labelStackView) + + labelStackView.addArrangedSubview(authorNameLabel) + labelStackView.addArrangedSubview(authorUsernameLabel) + authorNameLabel.setContentCompressionResistancePriority(.required - 1, for: .vertical) + authorUsernameLabel.setContentCompressionResistancePriority(.required - 1, for: .vertical) + + avatarButton.isUserInteractionEnabled = false + authorNameLabel.isUserInteractionEnabled = false + authorUsernameLabel.isUserInteractionEnabled = false + } + +} diff --git a/MastodonSDK/Sources/MastodonUI/View/Control/ActionToolbarContainer.swift b/MastodonSDK/Sources/MastodonUI/View/Control/ActionToolbarContainer.swift new file mode 100644 index 000000000..449254d20 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/Control/ActionToolbarContainer.swift @@ -0,0 +1,291 @@ +// +// ActionToolBarContainer.swift +// Mastodon +// +// Created by sxiaojian on 2021/2/1. +// + +import os.log +import UIKit +import MastodonAsset +import MastodonLocalization + +public protocol ActionToolbarContainerDelegate: AnyObject { + func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, buttonDidPressed button: UIButton, action: ActionToolbarContainer.Action) +} + +public final class ActionToolbarContainer: UIView { + + let logger = Logger(subsystem: "ActionToolbarContainer", category: "Control") + + static let replyImage = Asset.Communication.bubbleLeftAndBubbleRight.image.withRenderingMode(.alwaysTemplate) + static let reblogImage = Asset.Arrow.repeat.image.withRenderingMode(.alwaysTemplate) + static let starImage = Asset.ObjectsAndTools.star.image.withRenderingMode(.alwaysTemplate) + static let starFillImage = Asset.ObjectsAndTools.starFill.image.withRenderingMode(.alwaysTemplate) + static let shareImage = Asset.Communication.share.image.withRenderingMode(.alwaysTemplate) + + public let replyButton = HighlightDimmableButton() + public let reblogButton = HighlightDimmableButton() + public let favoriteButton = HighlightDimmableButton() + public let shareButton = HighlightDimmableButton() + + public weak var delegate: ActionToolbarContainerDelegate? + + private let container = UIStackView() + private var style: Style? + + public override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + public required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ActionToolbarContainer { + + private func _init() { + container.translatesAutoresizingMaskIntoConstraints = false + addSubview(container) + NSLayoutConstraint.activate([ + container.topAnchor.constraint(equalTo: topAnchor), + container.leadingAnchor.constraint(equalTo: leadingAnchor), + trailingAnchor.constraint(equalTo: container.trailingAnchor), + bottomAnchor.constraint(equalTo: container.bottomAnchor), + ]) + + replyButton.addTarget(self, action: #selector(ActionToolbarContainer.buttonDidPressed(_:)), for: .touchUpInside) + reblogButton.addTarget(self, action: #selector(ActionToolbarContainer.buttonDidPressed(_:)), for: .touchUpInside) + favoriteButton.addTarget(self, action: #selector(ActionToolbarContainer.buttonDidPressed(_:)), for: .touchUpInside) + shareButton.addTarget(self, action: #selector(ActionToolbarContainer.buttonDidPressed(_:)), for: .touchUpInside) + } + + public func configure(for style: Style) { + guard needsConfigure(for: style) else { + return + } + + self.style = style + container.arrangedSubviews.forEach { subview in + container.removeArrangedSubview(subview) + subview.removeFromSuperview() + } + + let buttons = [replyButton, reblogButton, favoriteButton, shareButton] + buttons.forEach { button in + button.tintColor = Asset.Colors.Button.actionToolbar.color + button.titleLabel?.font = .monospacedDigitSystemFont(ofSize: 12, weight: .regular) + button.setTitle("", for: .normal) + button.setTitleColor(.secondaryLabel, for: .normal) + button.expandEdgeInsets = UIEdgeInsets(top: -10, left: -10, bottom: -10, right: -10) + button.setInsets(forContentPadding: .zero, imageTitlePadding: style.buttonTitleImagePadding) + } + // add more expand for menu button + shareButton.expandEdgeInsets = UIEdgeInsets(top: -10, left: -20, bottom: -10, right: -20) + + replyButton.accessibilityLabel = L10n.Common.Controls.Status.Actions.reply + reblogButton.accessibilityLabel = L10n.Common.Controls.Status.Actions.reblog // needs update to follow state + favoriteButton.accessibilityLabel = L10n.Common.Controls.Status.Actions.favorite // needs update to follow state + shareButton.accessibilityLabel = L10n.Common.Controls.Actions.share + + switch style { + case .inline: + buttons.forEach { button in + button.contentHorizontalAlignment = .leading + } + replyButton.setImage(ActionToolbarContainer.replyImage, for: .normal) + reblogButton.setImage(ActionToolbarContainer.reblogImage, for: .normal) + favoriteButton.setImage(ActionToolbarContainer.starImage, for: .normal) + shareButton.setImage(ActionToolbarContainer.shareImage, for: .normal) + + container.axis = .horizontal + container.distribution = .fill + + replyButton.translatesAutoresizingMaskIntoConstraints = false + reblogButton.translatesAutoresizingMaskIntoConstraints = false + favoriteButton.translatesAutoresizingMaskIntoConstraints = false + shareButton.translatesAutoresizingMaskIntoConstraints = false + container.addArrangedSubview(replyButton) + container.addArrangedSubview(reblogButton) + container.addArrangedSubview(favoriteButton) + container.addArrangedSubview(shareButton) + NSLayoutConstraint.activate([ + replyButton.heightAnchor.constraint(equalToConstant: 44).priority(.defaultHigh), + replyButton.heightAnchor.constraint(equalTo: reblogButton.heightAnchor).priority(.defaultHigh), + replyButton.heightAnchor.constraint(equalTo: favoriteButton.heightAnchor).priority(.defaultHigh), + replyButton.heightAnchor.constraint(equalTo: shareButton.heightAnchor).priority(.defaultHigh), + replyButton.widthAnchor.constraint(equalTo: reblogButton.widthAnchor).priority(.defaultHigh), + replyButton.widthAnchor.constraint(equalTo: favoriteButton.widthAnchor).priority(.defaultHigh), + ]) + shareButton.setContentHuggingPriority(.defaultHigh, for: .horizontal) + shareButton.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) + + case .plain: + buttons.forEach { button in + button.contentHorizontalAlignment = .center + } + replyButton.setImage(ActionToolbarContainer.replyImage, for: .normal) + reblogButton.setImage(ActionToolbarContainer.reblogImage, for: .normal) + favoriteButton.setImage(ActionToolbarContainer.starImage, for: .normal) + + container.axis = .horizontal + container.spacing = 8 + container.distribution = .fillEqually + + container.addArrangedSubview(replyButton) + container.addArrangedSubview(reblogButton) + container.addArrangedSubview(favoriteButton) + } + } + + private func needsConfigure(for style: Style) -> Bool { + guard let oldStyle = self.style else { return true } + return oldStyle != style + } + +} + +extension ActionToolbarContainer { + + public enum Action: String, CaseIterable { + case reply + case reblog + case like + case share + } + + public enum Style { + case inline + case plain + + var buttonTitleImagePadding: CGFloat { + switch self { + case .inline: return 4.0 + case .plain: return 0 + } + } + } + + private func isReblogButtonHighlightStateDidChange(to isHighlight: Bool) { + let tintColor = isHighlight ? Asset.Colors.successGreen.color : Asset.Colors.Button.actionToolbar.color + reblogButton.tintColor = tintColor + reblogButton.setTitleColor(tintColor, for: .normal) + reblogButton.setTitleColor(tintColor, for: .highlighted) + } + + private func isFavoriteButtonHighlightStateDidChange(to isHighlight: Bool) { + let tintColor = isHighlight ? Asset.Colors.systemOrange.color : Asset.Colors.Button.actionToolbar.color + favoriteButton.tintColor = tintColor + favoriteButton.setTitleColor(tintColor, for: .normal) + favoriteButton.setTitleColor(tintColor, for: .highlighted) + } + +} + +extension ActionToolbarContainer { + + @objc private func buttonDidPressed(_ sender: UIButton) { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") + + let _action: Action? + switch sender { + case replyButton: _action = .reply + case reblogButton: _action = .reblog + case favoriteButton: _action = .like + case shareButton: _action = .share + default: _action = nil + } + + guard let action = _action else { + assertionFailure() + return + } + + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): \(action.rawValue) button pressed") + delegate?.actionToolbarContainer(self, buttonDidPressed: sender, action: action) + } + +} + +extension ActionToolbarContainer { + + public func configureReply(count: Int, isEnabled: Bool) { + let title = ActionToolbarContainer.title(from: count) + replyButton.setTitle(title, for: .normal) + replyButton.accessibilityLabel = "\(count) reply" // TODO: i18n + } + + public func configureReblog(count: Int, isEnabled: Bool, isHighlighted: Bool) { + let title = ActionToolbarContainer.title(from: count) + reblogButton.setTitle(title, for: .normal) + reblogButton.isEnabled = isEnabled + reblogButton.setImage(ActionToolbarContainer.reblogImage, for: .normal) + let tintColor = isHighlighted ? Asset.Colors.successGreen.color : Asset.Colors.Button.actionToolbar.color + reblogButton.tintColor = tintColor + reblogButton.setTitleColor(tintColor, for: .normal) + reblogButton.setTitleColor(tintColor, for: .highlighted) + + if isHighlighted { + reblogButton.accessibilityTraits.insert(.selected) + } else { + reblogButton.accessibilityTraits.remove(.selected) + } + reblogButton.accessibilityLabel = L10n.Plural.Count.reblog(count) + } + + public func configureFavorite(count: Int, isEnabled: Bool, isHighlighted: Bool) { + let title = ActionToolbarContainer.title(from: count) + favoriteButton.setTitle(title, for: .normal) + favoriteButton.isEnabled = isEnabled + let image = isHighlighted ? ActionToolbarContainer.starFillImage : ActionToolbarContainer.starImage + favoriteButton.setImage(image, for: .normal) + let tintColor = isHighlighted ? Asset.Colors.systemOrange.color : Asset.Colors.Button.actionToolbar.color + favoriteButton.tintColor = tintColor + favoriteButton.setTitleColor(tintColor, for: .normal) + favoriteButton.setTitleColor(tintColor, for: .highlighted) + + if isHighlighted { + favoriteButton.accessibilityTraits.insert(.selected) + } else { + favoriteButton.accessibilityTraits.remove(.selected) + } + favoriteButton.accessibilityLabel = L10n.Plural.Count.favorite(count) + } + +} + +extension ActionToolbarContainer { + private static func title(from number: Int?) -> String { + guard let number = number, number > 0 else { return "" } + return String(number) + } +} + +extension ActionToolbarContainer { + public override var accessibilityElements: [Any]? { + get { [replyButton, reblogButton, favoriteButton, shareButton] } + set { } + } +} + +#if DEBUG +import SwiftUI + +struct ActionToolbarContainer_Previews: PreviewProvider { + static var previews: some View { + Group { + UIViewPreview(width: 300) { + let toolbar = ActionToolbarContainer() + toolbar.configure(for: .inline) + return toolbar + } + .previewLayout(.fixed(width: 300, height: 44)) + .previewDisplayName("Inline") + } + } +} +#endif diff --git a/MastodonSDK/Sources/MastodonUI/View/Control/ContentWarningOverlayView.swift b/MastodonSDK/Sources/MastodonUI/View/Control/ContentWarningOverlayView.swift new file mode 100644 index 000000000..d559e4e04 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/Control/ContentWarningOverlayView.swift @@ -0,0 +1,83 @@ +// +// ContentWarningOverlayView.swift +// +// +// Created by MainasuK on 2021-12-14. +// + +import os.log +import UIKit + +public protocol ContentWarningOverlayViewDelegate: AnyObject { + func contentWarningOverlayViewDidPressed(_ contentWarningOverlayView: ContentWarningOverlayView) +} + +public final class ContentWarningOverlayView: UIView { + + public static let blurVisualEffect = UIBlurEffect(style: .systemUltraThinMaterial) + + let logger = Logger(subsystem: "ContentWarningOverlayView", category: "View") + + public weak var delegate: ContentWarningOverlayViewDelegate? + + public let blurVisualEffectView = UIVisualEffectView(effect: ContentWarningOverlayView.blurVisualEffect) + public let vibrancyVisualEffectView = UIVisualEffectView(effect: UIVibrancyEffect(blurEffect: ContentWarningOverlayView.blurVisualEffect)) +// let alertImageView: UIImageView = { +// let imageView = UIImageView() +// imageView.image = Asset.Indices.exclamationmarkTriangleLarge.image.withRenderingMode(.alwaysTemplate) +// return imageView +// }() + + public let tapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ContentWarningOverlayView { + private func _init() { + // overlay + blurVisualEffectView.translatesAutoresizingMaskIntoConstraints = false + addSubview(blurVisualEffectView) + NSLayoutConstraint.activate([ + blurVisualEffectView.topAnchor.constraint(equalTo: topAnchor), + blurVisualEffectView.leadingAnchor.constraint(equalTo: leadingAnchor), + blurVisualEffectView.trailingAnchor.constraint(equalTo: trailingAnchor), + blurVisualEffectView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + vibrancyVisualEffectView.translatesAutoresizingMaskIntoConstraints = false + blurVisualEffectView.contentView.addSubview(vibrancyVisualEffectView) + NSLayoutConstraint.activate([ + vibrancyVisualEffectView.topAnchor.constraint(equalTo: blurVisualEffectView.contentView.topAnchor), + vibrancyVisualEffectView.leadingAnchor.constraint(equalTo: blurVisualEffectView.contentView.leadingAnchor), + vibrancyVisualEffectView.trailingAnchor.constraint(equalTo: blurVisualEffectView.contentView.trailingAnchor), + vibrancyVisualEffectView.bottomAnchor.constraint(equalTo: blurVisualEffectView.contentView.bottomAnchor), + ]) + +// alertImageView.translatesAutoresizingMaskIntoConstraints = false +// vibrancyVisualEffectView.contentView.addSubview(alertImageView) +// NSLayoutConstraint.activate([ +// alertImageView.centerXAnchor.constraint(equalTo: vibrancyVisualEffectView.contentView.centerXAnchor), +// alertImageView.centerYAnchor.constraint(equalTo: vibrancyVisualEffectView.contentView.centerYAnchor), +// ]) + + tapGestureRecognizer.addTarget(self, action: #selector(ContentWarningOverlayView.tapGestureRecognizerHandler(_:))) + addGestureRecognizer(tapGestureRecognizer) + } +} + +extension ContentWarningOverlayView { + @objc private func tapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") + delegate?.contentWarningOverlayViewDidPressed(self) + } +} diff --git a/MastodonSDK/Sources/MastodonUI/View/Control/SpoilerBannerView.swift b/MastodonSDK/Sources/MastodonUI/View/Control/SpoilerBannerView.swift new file mode 100644 index 000000000..b7056a7a8 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/Control/SpoilerBannerView.swift @@ -0,0 +1,94 @@ +// +// SpoilerBannerView.swift +// +// +// Created by MainasuK on 2022-2-8. +// + +import UIKit +import MetaTextKit +import MastodonAsset +import MastodonLocalization + +public final class SpoilerBannerView: UIView { + + static let cornerRadius: CGFloat = 8 + static let containerMargin: CGFloat = 14 + + public let containerView = UIView() + + public let label = MetaLabel(style: .statusSpoilerBanner) + + public let hideLabel: UILabel = { + let label = UILabel() + label.textColor = Asset.Colors.Label.primary.color + label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .regular)) + label.text = L10n.Common.Controls.Status.Actions.hide + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension SpoilerBannerView { + + private func _init() { + containerView.translatesAutoresizingMaskIntoConstraints = false + addSubview(containerView) + NSLayoutConstraint.activate([ + containerView.topAnchor.constraint(equalTo: topAnchor), + containerView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), + containerView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), + containerView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + containerView.backgroundColor = .secondarySystemBackground + + containerView.layoutMargins = UIEdgeInsets( + top: StatusVisibilityView.containerMargin, + left: StatusVisibilityView.containerMargin, + bottom: StatusVisibilityView.containerMargin, + right: StatusVisibilityView.containerMargin + ) + + let labelContainer = UIStackView() + labelContainer.axis = .horizontal + labelContainer.spacing = 16 + labelContainer.alignment = .center + + labelContainer.translatesAutoresizingMaskIntoConstraints = false + containerView.addSubview(labelContainer) + NSLayoutConstraint.activate([ + labelContainer.topAnchor.constraint(equalTo: containerView.layoutMarginsGuide.topAnchor), + labelContainer.leadingAnchor.constraint(equalTo: containerView.layoutMarginsGuide.leadingAnchor), + labelContainer.trailingAnchor.constraint(equalTo: containerView.layoutMarginsGuide.trailingAnchor), + labelContainer.bottomAnchor.constraint(equalTo: containerView.layoutMarginsGuide.bottomAnchor), + ]) + + labelContainer.addArrangedSubview(label) + labelContainer.addArrangedSubview(UIView()) + labelContainer.addArrangedSubview(hideLabel) + hideLabel.setContentHuggingPriority(.required - 1, for: .horizontal) + hideLabel.setContentCompressionResistancePriority(.required - 1, for: .horizontal) + + label.isUserInteractionEnabled = false + } + + public override func layoutSubviews() { + super.layoutSubviews() + + containerView.layer.masksToBounds = false + containerView.layer.cornerCurve = .continuous + containerView.layer.cornerRadius = StatusVisibilityView.cornerRadius + } + +} diff --git a/MastodonSDK/Sources/MastodonUI/View/Control/SpoilerOverlayView.swift b/MastodonSDK/Sources/MastodonUI/View/Control/SpoilerOverlayView.swift new file mode 100644 index 000000000..17360d545 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/Control/SpoilerOverlayView.swift @@ -0,0 +1,80 @@ +// +// SpoilerOverlayView.swift +// +// +// Created by MainasuK on 2022-1-29. +// + +import UIKit +import MastodonLocalization +import MastodonAsset +import MetaTextKit + +public final class SpoilerOverlayView: UIView { + + let containerStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.spacing = 8 + stackView.alignment = .center + return stackView + }() + + let spoilerMetaLabel = MetaLabel(style: .statusSpoilerOverlay) + + let hintLabel: UILabel = { + let label = UILabel() + label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .regular)) + label.textAlignment = .center + label.textColor = Asset.Colors.Label.secondary.color + label.text = L10n.Common.Controls.Status.mediaContentWarning + return label + }() + + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension SpoilerOverlayView { + private func _init() { + containerStackView.translatesAutoresizingMaskIntoConstraints = false + addSubview(containerStackView) + NSLayoutConstraint.activate([ + containerStackView.topAnchor.constraint(equalTo: topAnchor), + containerStackView.leadingAnchor.constraint(equalTo: leadingAnchor), + containerStackView.trailingAnchor.constraint(equalTo: trailingAnchor), + containerStackView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + let topPaddingView = UIView() + topPaddingView.translatesAutoresizingMaskIntoConstraints = false + containerStackView.addArrangedSubview(topPaddingView) + containerStackView.addArrangedSubview(spoilerMetaLabel) + containerStackView.addArrangedSubview(hintLabel) + let bottomPaddingView = UIView() + bottomPaddingView.translatesAutoresizingMaskIntoConstraints = false + containerStackView.addArrangedSubview(bottomPaddingView) + NSLayoutConstraint.activate([ + topPaddingView.heightAnchor.constraint(equalTo: bottomPaddingView.heightAnchor).priority(.required - 1), + ]) + topPaddingView.setContentCompressionResistancePriority(.defaultLow - 100, for: .vertical) + bottomPaddingView.setContentCompressionResistancePriority(.defaultLow - 100, for: .vertical) + + spoilerMetaLabel.isUserInteractionEnabled = false + + isAccessibilityElement = true + } + + public func setComponentHidden(_ isHidden: Bool) { + containerStackView.arrangedSubviews.forEach { $0.isHidden = isHidden } + } +} diff --git a/MastodonSDK/Sources/MastodonUI/View/Control/StatusVisibilityView.swift b/MastodonSDK/Sources/MastodonUI/View/Control/StatusVisibilityView.swift new file mode 100644 index 000000000..1866c7e19 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/Control/StatusVisibilityView.swift @@ -0,0 +1,74 @@ +// +// StatusVisibilityView.swift +// +// +// Created by MainasuK on 2022-1-28. +// + +import UIKit + +public final class StatusVisibilityView: UIView { + + static let cornerRadius: CGFloat = 8 + static let containerMargin: CGFloat = 14 + + public let containerView = UIView() + + public let label: UILabel = { + let label = UILabel() + label.font = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .systemFont(ofSize: 15, weight: .regular)) + label.numberOfLines = 0 + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension StatusVisibilityView { + + private func _init() { + containerView.translatesAutoresizingMaskIntoConstraints = false + addSubview(containerView) + NSLayoutConstraint.activate([ + containerView.topAnchor.constraint(equalTo: topAnchor), + containerView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), + containerView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), + containerView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + containerView.backgroundColor = .secondarySystemBackground + + containerView.layoutMargins = UIEdgeInsets( + top: StatusVisibilityView.containerMargin, + left: StatusVisibilityView.containerMargin, + bottom: StatusVisibilityView.containerMargin, + right: StatusVisibilityView.containerMargin + ) + label.translatesAutoresizingMaskIntoConstraints = false + addSubview(label) + NSLayoutConstraint.activate([ + label.topAnchor.constraint(equalTo: containerView.layoutMarginsGuide.topAnchor), + label.leadingAnchor.constraint(equalTo: containerView.layoutMarginsGuide.leadingAnchor), + label.trailingAnchor.constraint(equalTo: containerView.layoutMarginsGuide.trailingAnchor), + label.bottomAnchor.constraint(equalTo: containerView.layoutMarginsGuide.bottomAnchor), + ]) + } + + public override func layoutSubviews() { + super.layoutSubviews() + + containerView.layer.masksToBounds = false + containerView.layer.cornerCurve = .continuous + containerView.layer.cornerRadius = StatusVisibilityView.cornerRadius + } + +} diff --git a/Mastodon/Scene/Share/View/Control/StripProgressView.swift b/MastodonSDK/Sources/MastodonUI/View/Control/StripProgressView.swift similarity index 91% rename from Mastodon/Scene/Share/View/Control/StripProgressView.swift rename to MastodonSDK/Sources/MastodonUI/View/Control/StripProgressView.swift index 710d8567d..8d429594f 100644 --- a/Mastodon/Scene/Share/View/Control/StripProgressView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Control/StripProgressView.swift @@ -9,15 +9,15 @@ import os.log import UIKit import Combine -private final class StripProgressLayer: CALayer { +public final class StripProgressLayer: CALayer { static let progressAnimationKey = "progressAnimationKey" static let progressKey = "progress" - var tintColor: UIColor = .black + public var tintColor: UIColor = .black @NSManaged var progress: CGFloat - override class func needsDisplay(forKey key: String) -> Bool { + public override class func needsDisplay(forKey key: String) -> Bool { switch key { case StripProgressLayer.progressKey: return true @@ -26,7 +26,7 @@ private final class StripProgressLayer: CALayer { } } - override func display() { + public override func display() { let progress: CGFloat = { guard animation(forKey: StripProgressLayer.progressAnimationKey) != nil else { return self.progress @@ -64,7 +64,7 @@ private final class StripProgressLayer: CALayer { } -final class StripProgressView: UIView { +public final class StripProgressView: UIView { var disposeBag = Set<AnyCancellable>() @@ -73,7 +73,7 @@ final class StripProgressView: UIView { return layer }() - override var tintColor: UIColor! { + public override var tintColor: UIColor! { didSet { stripProgressLayer.tintColor = tintColor setNeedsDisplay() @@ -97,12 +97,12 @@ final class StripProgressView: UIView { } } - override init(frame: CGRect) { + public override init(frame: CGRect) { super.init(frame: frame) _init() } - required init?(coder: NSCoder) { + public required init?(coder: NSCoder) { super.init(coder: coder) _init() } @@ -116,7 +116,7 @@ extension StripProgressView { updateLayerPath() } - override func layoutSubviews() { + public override func layoutSubviews() { super.layoutSubviews() updateLayerPath() } diff --git a/MastodonSDK/Sources/MastodonUI/View/ImageView/AvatarImageView.swift b/MastodonSDK/Sources/MastodonUI/View/ImageView/AvatarImageView.swift new file mode 100644 index 000000000..c0204bc65 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/ImageView/AvatarImageView.swift @@ -0,0 +1,126 @@ +// +// AvatarImageView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-7-21. +// + +import UIKit +import FLAnimatedImage +import AlamofireImage + +public class AvatarImageView: FLAnimatedImageView { + public var imageViewSize: CGSize? + public var configuration = Configuration(url: nil) + public var cornerConfiguration = CornerConfiguration() +} + +extension AvatarImageView { + + public func prepareForReuse() { + cancelTask() + af.cancelImageRequest() + } + + override public func layoutSubviews() { + super.layoutSubviews() + + setup(corner: cornerConfiguration.corner) + } + + private func setup(corner: CornerConfiguration.Corner) { + layer.masksToBounds = true + switch corner { + case .circle: + layer.cornerCurve = .circular + layer.cornerRadius = frame.width / 2 + case .fixed(let radius): + layer.cornerCurve = .continuous + layer.cornerRadius = radius + case .scale(let ratio): + let radius = CGFloat(Int(bounds.width) / ratio) // even number from quoter of width + layer.cornerCurve = .continuous + layer.cornerRadius = radius + } + } + +} + +extension AvatarImageView { + + public static let placeholder = UIImage.placeholder(color: .systemFill) + + public struct Configuration { + public let url: URL? + public let placeholder: UIImage? + + public init( + url: URL?, + placeholder: UIImage = AvatarImageView.placeholder + ) { + self.url = url + self.placeholder = placeholder + } + + public init( + image: UIImage + ) { + self.url = nil + self.placeholder = image + } + } + + public func configure(configuration: Configuration) { + prepareForReuse() + + self.configuration = configuration + + guard let url = configuration.url else { + image = configuration.placeholder + return + } + + switch url.pathExtension.lowercased() { + case "gif": + setImage( + url: configuration.url, + placeholder: configuration.placeholder, + scaleToSize: imageViewSize + ) + default: + let filter: ImageFilter? = { + if let imageViewSize = self.imageViewSize { + return ScaledToSizeFilter(size: imageViewSize) + } + guard self.frame.size.width != 0, + self.frame.size.height != 0 + else { return nil } + return ScaledToSizeFilter(size: self.frame.size) + }() + + af.setImage(withURL: url, filter: filter) + } + } + +} + +extension AvatarImageView { + public struct CornerConfiguration { + public let corner: Corner + + public init(corner: Corner = .circle) { + self.corner = corner + } + + public enum Corner { + case circle + case fixed(radius: CGFloat) + case scale(ratio: Int = 4) // width / ratio + } + } + + public func configure(cornerConfiguration: CornerConfiguration) { + self.cornerConfiguration = cornerConfiguration + setup(corner: cornerConfiguration.corner) + } +} diff --git a/MastodonSDK/Sources/MastodonUI/View/Menu/MastodonMenu.swift b/MastodonSDK/Sources/MastodonUI/View/Menu/MastodonMenu.swift new file mode 100644 index 000000000..de4bc403d --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/Menu/MastodonMenu.swift @@ -0,0 +1,146 @@ +// +// MastodonMenu.swift +// +// +// Created by MainasuK on 2022-1-26. +// + +import UIKit +import MastodonLocalization + +public protocol MastodonMenuDelegate: AnyObject { + func menuAction(_ action: MastodonMenu.Action) +} + +public enum MastodonMenu { + public static func setupMenu( + actions: [Action], + delegate: MastodonMenuDelegate + ) -> UIMenu { + var children: [UIMenuElement] = [] + for action in actions { + let element = action.build(delegate: delegate) + children.append(element) + } + return UIMenu(title: "", options: [], children: children) + } +} + +extension MastodonMenu { + public enum Action { + case muteUser(MuteUserActionContext) + case blockUser(BlockUserActionContext) + case reportUser(ReportUserActionContext) + case shareUser(ShareUserActionContext) + case deleteStatus + + func build(delegate: MastodonMenuDelegate) -> UIMenuElement { + switch self { + case .muteUser(let context): + let muteAction = UIAction( + title: context.isMuting ? L10n.Common.Controls.Friendship.unmuteUser(context.name) : L10n.Common.Controls.Friendship.muteUser(context.name), + image: context.isMuting ? UIImage(systemName: "speaker.wave.2") : UIImage(systemName: "speaker.slash"), + identifier: nil, + discoverabilityTitle: nil, + attributes: [], + state: .off + ) { [weak delegate] _ in + guard let delegate = delegate else { return } + delegate.menuAction(self) + } + return muteAction + case .blockUser(let context): + let blockAction = UIAction( + title: context.isBlocking ? L10n.Common.Controls.Friendship.unblockUser(context.name) : L10n.Common.Controls.Friendship.blockUser(context.name), + image: context.isBlocking ? UIImage(systemName: "hand.raised") : UIImage(systemName: "hand.raised"), + identifier: nil, + discoverabilityTitle: nil, + attributes: [], + state: .off + ) { [weak delegate] _ in + guard let delegate = delegate else { return } + delegate.menuAction(self) + } + return blockAction + case .reportUser(let context): + let reportAction = UIAction( + title: L10n.Common.Controls.Actions.reportUser(context.name), + image: UIImage(systemName: "flag"), + identifier: nil, + discoverabilityTitle: nil, + attributes: [], + state: .off + ) { [weak delegate] _ in + guard let delegate = delegate else { return } + delegate.menuAction(self) + } + return reportAction + case .shareUser(let context): + let shareAction = UIAction( + title: L10n.Common.Controls.Actions.shareUser(context.name), + image: UIImage(systemName: "square.and.arrow.up"), + identifier: nil, + discoverabilityTitle: nil, + attributes: [], + state: .off + ) { [weak delegate] _ in + guard let delegate = delegate else { return } + delegate.menuAction(self) + } + return shareAction + case .deleteStatus: + let deleteAction = UIAction( + title: L10n.Common.Controls.Actions.delete, + image: UIImage(systemName: "minus.circle"), + identifier: nil, + discoverabilityTitle: nil, + attributes: .destructive, + state: .off + ) { [weak delegate] _ in + guard let delegate = delegate else { return } + delegate.menuAction(self) + } + return deleteAction + } // end switch + } // end func build + } // end enum Action +} + +extension MastodonMenu { + public struct MuteUserActionContext { + public let name: String + public let isMuting: Bool + + public init(name: String, isMuting: Bool) { + self.name = name + self.isMuting = isMuting + } + } + + public struct BlockUserActionContext { + public let name: String + public let isBlocking: Bool + + public init(name: String, isBlocking: Bool) { + self.name = name + self.isBlocking = isBlocking + } + } + + public struct ReportUserActionContext { + public let name: String + + public init(name: String) { + self.name = name + } + } + + public struct ShareUserActionContext { + public let name: String + + public init(name: String) { + self.name = name + } + } + +} diff --git a/MastodonSDK/Sources/MastodonUI/View/TableViewCell/PollOptionTableViewCell.swift b/MastodonSDK/Sources/MastodonUI/View/TableViewCell/PollOptionTableViewCell.swift new file mode 100644 index 000000000..6ae6ea0b5 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/TableViewCell/PollOptionTableViewCell.swift @@ -0,0 +1,65 @@ +// +// PollOptionTableViewCell.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-2-25. +// + +import UIKit +import Combine +import MastodonAsset +import MastodonLocalization + +public final class PollOptionTableViewCell: UITableViewCell { + + static let height: CGFloat = PollOptionView.height + + public var disposeBag = Set<AnyCancellable>() + + public let pollOptionView = PollOptionView() + + public override func prepareForReuse() { + super.prepareForReuse() + + disposeBag.removeAll() + pollOptionView.prepareForReuse() + } + + public override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + _init() + } + + public required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + + public override func setHighlighted(_ highlighted: Bool, animated: Bool) { + super.setHighlighted(highlighted, animated: animated) + + pollOptionView.alpha = highlighted ? 0.5 : 1 + } + +} + +extension PollOptionTableViewCell { + + private func _init() { + selectionStyle = .none + backgroundColor = .clear + pollOptionView.isUserInteractionEnabled = false + // pollOptionView.optionTextField.isUserInteractionEnabled = false + + pollOptionView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(pollOptionView) + NSLayoutConstraint.activate([ + pollOptionView.topAnchor.constraint(equalTo: contentView.topAnchor), + pollOptionView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + pollOptionView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + pollOptionView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + ]) + pollOptionView.setup(style: .plain) + } + +} diff --git a/Mastodon/Scene/Share/View/TextField/DeleteBackwardResponseTextField.swift b/MastodonSDK/Sources/MastodonUI/View/TextField/DeleteBackwardResponseTextField.swift similarity index 61% rename from Mastodon/Scene/Share/View/TextField/DeleteBackwardResponseTextField.swift rename to MastodonSDK/Sources/MastodonUI/View/TextField/DeleteBackwardResponseTextField.swift index 08c085aa9..6fd760430 100644 --- a/Mastodon/Scene/Share/View/TextField/DeleteBackwardResponseTextField.swift +++ b/MastodonSDK/Sources/MastodonUI/View/TextField/DeleteBackwardResponseTextField.swift @@ -7,15 +7,15 @@ import UIKit -protocol DeleteBackwardResponseTextFieldDelegate: AnyObject { +public protocol DeleteBackwardResponseTextFieldDelegate: AnyObject { func deleteBackwardResponseTextField(_ textField: DeleteBackwardResponseTextField, textBeforeDelete: String?) } -final class DeleteBackwardResponseTextField: UITextField { +public final class DeleteBackwardResponseTextField: UITextField { - weak var deleteBackwardDelegate: DeleteBackwardResponseTextFieldDelegate? + public weak var deleteBackwardDelegate: DeleteBackwardResponseTextFieldDelegate? - override func deleteBackward() { + public override func deleteBackward() { let text = self.text super.deleteBackward() deleteBackwardDelegate?.deleteBackwardResponseTextField(self, textBeforeDelete: text) diff --git a/MastodonSDK/Tests/MastodonSDKTests/API/MastodonSDK+API+OAuthTests.swift b/MastodonSDK/Tests/MastodonSDKTests/API/MastodonSDK+API+OAuthTests.swift index b14aad24e..c1f09eb99 100644 --- a/MastodonSDK/Tests/MastodonSDKTests/API/MastodonSDK+API+OAuthTests.swift +++ b/MastodonSDK/Tests/MastodonSDKTests/API/MastodonSDK+API+OAuthTests.swift @@ -22,7 +22,7 @@ extension MastodonSDKTests { os_log("%{public}s[%{public}ld], %{public}s: (%s) authorizeURL %s", ((#file as NSString).lastPathComponent), #line, #function, domain, authorizeURL.absoluteString) XCTAssertEqual( authorizeURL.absoluteString, - "https://\(domain)/oauth/authorize?response_type=code&client_id=StubClientID&redirect_uri=mastodon://joinmastodon.org/oauth&scope=read%20write%20follow%20push" + "\(URL.httpScheme(domain: domain))://\(domain)/oauth/authorize?response_type=code&client_id=StubClientID&redirect_uri=mastodon://joinmastodon.org/oauth&scope=read%20write%20follow%20push" ) } diff --git a/MastodonTests/Info.plist b/MastodonTests/Info.plist index 9fe845c60..73f11cd26 100644 --- a/MastodonTests/Info.plist +++ b/MastodonTests/Info.plist @@ -15,8 +15,8 @@ <key>CFBundlePackageType</key> <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> <key>CFBundleShortVersionString</key> - <string>1.2.0</string> + <string>1.3.0</string> <key>CFBundleVersion</key> - <string>88</string> + <string>109</string> </dict> </plist> diff --git a/MastodonTests/MastodonTests.swift b/MastodonTests/MastodonTests.swift index 5da71aa43..7264dde64 100644 --- a/MastodonTests/MastodonTests.swift +++ b/MastodonTests/MastodonTests.swift @@ -8,6 +8,7 @@ import XCTest @testable import Mastodon +@MainActor class MastodonTests: XCTestCase { override func setUpWithError() throws { @@ -43,4 +44,20 @@ extension MastodonTests { } wait(for: [expectation], timeout: 10) } + + @available(iOS 15.0, *) + func testConnectOnion() async throws { + let request = URLRequest( + url: URL(string: "http://a232ncr7jexk2chvubaq2v6qdizbocllqap7mnn7w7vrdutyvu32jeyd.onion/@k0gen")!, + cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, + timeoutInterval: 10 + ) + do { + let data = try await URLSession.shared.data(for: request, delegate: nil) + print(data) + } catch { + debugPrint(error) + assertionFailure(error.localizedDescription) + } + } } diff --git a/MastodonUITests/Info.plist b/MastodonUITests/Info.plist index 9fe845c60..73f11cd26 100644 --- a/MastodonUITests/Info.plist +++ b/MastodonUITests/Info.plist @@ -15,8 +15,8 @@ <key>CFBundlePackageType</key> <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> <key>CFBundleShortVersionString</key> - <string>1.2.0</string> + <string>1.3.0</string> <key>CFBundleVersion</key> - <string>88</string> + <string>109</string> </dict> </plist> diff --git a/MastodonUITests/MastodonUISnapshotTests.swift b/MastodonUITests/MastodonUISnapshotTests.swift new file mode 100644 index 000000000..d3c5d2bf3 --- /dev/null +++ b/MastodonUITests/MastodonUISnapshotTests.swift @@ -0,0 +1,454 @@ +// +// MastodonUISnapshotTests.swift +// MastodonUITests +// +// Created by MainasuK on 2022-3-2. +// + +import XCTest + +extension UInt64 { + static let second: UInt64 = 1_000_000_000 +} + +@MainActor +class MastodonUISnapshotTests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + override class func tearDown() { + super.tearDown() + let app = XCUIApplication() + print(app.debugDescription) + } + +} + +extension MastodonUISnapshotTests { + + func testSmoke() async throws { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct results. + // Any test you write for XCTest can be annotated as throws and async. + // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. + // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. + + } + +} + +extension MastodonUISnapshotTests { + + func takeSnapshot(name: String) { + let snapshot = XCUIScreen.main.screenshot() + let attachment = XCTAttachment( + uniformTypeIdentifier: "public.png", + name: "\(name).\(UIDevice.current.name).png", + payload: snapshot.pngRepresentation, + userInfo: nil + ) + attachment.lifetime = .keepAlways + add(attachment) + } + + // make tab display by tap it + private func tapTab(app: XCUIApplication, tab: String) { + let searchTab = app.tabBars.buttons[tab] + if searchTab.exists { searchTab.tap() } + + let searchCell = app.collectionViews.cells[tab] + if searchCell.exists { searchCell.tap() } + } + + private func showTitleButtonMenu(app: XCUIApplication) async throws { + let titleButton = app.navigationBars.buttons["TitleButton"].firstMatch + XCTAssert(titleButton.waitForExistence(timeout: 5)) + titleButton.press(forDuration: 1.0) + try await Task.sleep(nanoseconds: .second * 1) + } + + private func snapshot( + name: String, + count: Int = 3, + task: (_ app: XCUIApplication) async throws -> Void + ) async rethrows { + var app = XCUIApplication() + + // pass -1 to debug test case + guard count >= 0 else { + app.launch() + try await task(app) + takeSnapshot(name: name) + return + } + + // Light Mode + for index in 0..<count { + app.launch() + try await task(app) + + let name = "\(name).light.\(index+1)" + takeSnapshot(name: name) + } + + // Dark Mode + app = XCUIApplication() + app.launchArguments.append("UIUserInterfaceStyleForceDark") + for index in 0..<count { + app.launch() + try await task(app) + + let name = "\(name).dark.\(index+1)" + takeSnapshot(name: name) + } + } + +} + +// MARK: - Home +extension MastodonUISnapshotTests { + + func testSnapshotHome() async throws { + try await snapshot(name: "Home") { app in + tapTab(app: app, tab: "Home") + try await Task.sleep(nanoseconds: .second * 3) + } + } + +} + +// MARK: - Thread +extension MastodonUISnapshotTests { + + func testSnapshotThread() async throws { + try await snapshot(name: "Thread") { app in + let threadID = ProcessInfo.processInfo.environment["thread_id"]! + try await coordinateToThread(app: app, id: threadID) + try await Task.sleep(nanoseconds: .second * 5) + } + } + + // use debug entry goto thread scene by thread ID + // assert the thread ID is valid for current sign in user server + private func coordinateToThread(app: XCUIApplication, id: String) async throws { + try await Task.sleep(nanoseconds: .second * 1) + + try await showTitleButtonMenu(app: app) + + let showMenu = app.collectionViews.buttons["Show…"].firstMatch + XCTAssert(showMenu.waitForExistence(timeout: 3)) + showMenu.tap() + try await Task.sleep(nanoseconds: .second * 1) + + let threadAction = app.collectionViews.buttons["Thread"].firstMatch + XCTAssert(threadAction.waitForExistence(timeout: 3)) + threadAction.tap() + try await Task.sleep(nanoseconds: .second * 1) + + let textField = app.alerts.textFields.firstMatch + XCTAssert(textField.waitForExistence(timeout: 3)) + textField.typeText(id) + try await Task.sleep(nanoseconds: .second * 1) + + let showAction = app.alerts.buttons["Show"].firstMatch + XCTAssert(showAction.waitForExistence(timeout: 3)) + showAction.tap() + try await Task.sleep(nanoseconds: .second * 1) + } + +} + +// MARK: - Profile +extension MastodonUISnapshotTests { + + func testSnapshotProfile() async throws { + try await snapshot(name: "Profile") { app in + let profileID = ProcessInfo.processInfo.environment["profile_id"]! + try await coordinateToProfile(app: app, id: profileID) + try await Task.sleep(nanoseconds: .second * 5) + } + } + + // use debug entry goto thread scene by profile ID + // assert the profile ID is valid for current sign in user server + private func coordinateToProfile(app: XCUIApplication, id: String) async throws { + try await Task.sleep(nanoseconds: .second * 1) + + try await showTitleButtonMenu(app: app) + + let showMenu = app.collectionViews.buttons["Show…"].firstMatch + XCTAssert(showMenu.waitForExistence(timeout: 3)) + showMenu.tap() + try await Task.sleep(nanoseconds: .second * 1) + + let profileAction = app.collectionViews.buttons["Profile"].firstMatch + XCTAssert(profileAction.waitForExistence(timeout: 3)) + profileAction.tap() + try await Task.sleep(nanoseconds: .second * 1) + + let textField = app.alerts.textFields.firstMatch + XCTAssert(textField.waitForExistence(timeout: 3)) + textField.typeText(id) + try await Task.sleep(nanoseconds: .second * 1) + + let showAction = app.alerts.buttons["Show"].firstMatch + XCTAssert(showAction.waitForExistence(timeout: 3)) + showAction.tap() + try await Task.sleep(nanoseconds: .second * 1) + } + +} + + +// MARK: - Server Rules +extension MastodonUISnapshotTests { + + func testSnapshotServerRules() async throws { + try await snapshot(name: "ServerRules") { app in + let domain = "mastodon.social" + try await coordinateToOnboarding(app: app, page: .serverRules(domain: domain)) + try await Task.sleep(nanoseconds: .second * 3) + } + } + +} + +// MARK: - Search +extension MastodonUISnapshotTests { + + func testSnapshotSearch() async throws { + try await snapshot(name: "ServerRules") { app in + tapTab(app: app, tab: "Search") + try await Task.sleep(nanoseconds: .second * 3) + } + } + +} + +// MARK: - Compose +extension MastodonUISnapshotTests { + + func testSnapshotCompose() async throws { + try await snapshot(name: "Compose") { app in + // open Compose scene + let composeBarButtonItem = app.navigationBars.buttons["Compose"].firstMatch + let composeCollectionViewCell = app.collectionViews.cells["Compose"] + if composeBarButtonItem.waitForExistence(timeout: 5) { + composeBarButtonItem.tap() + } else if composeCollectionViewCell.waitForExistence(timeout: 5) { + composeCollectionViewCell.tap() + } else { + XCTFail() + } + + // type text + let textView = app.textViews.firstMatch + XCTAssert(textView.waitForExistence(timeout: 5)) + textView.tap() + textView.typeText("Look at that view! #Athens ") + + // tap Add Attachment toolbar button + let addAttachmentButton = app.buttons["Add Attachment"].firstMatch + XCTAssert(addAttachmentButton.waitForExistence(timeout: 5)) + addAttachmentButton.tap() + + // tap Browse menu action to add stub image + let browseButton = app.buttons["Browse"].firstMatch + XCTAssert(browseButton.waitForExistence(timeout: 5)) + browseButton.tap() + + try await Task.sleep(nanoseconds: .second * 10) + } + } + +} + +// MARK: Sign in +extension MastodonUISnapshotTests { + + // Please check the Documentation/Snapshot.md and run this test case in the command line + func testSignInAccount() async throws { + guard let domain = ProcessInfo.processInfo.environment["login_domain"] else { + fatalError("env 'login_domain' missing") + } + guard let email = ProcessInfo.processInfo.environment["login_email"] else { + fatalError("env 'login_email' missing") + } + guard let password = ProcessInfo.processInfo.environment["login_password"] else { + fatalError("env 'login_password' missing") + } + try await signInApplication( + domain: domain, + email: email, + password: password + ) + } + + func signInApplication( + domain: String, + email: String, + password: String + ) async throws { + let app = XCUIApplication() + app.launch() + + try await coordinateToOnboarding(app: app, page: .login(domain: domain)) + + // wait OAuth webpage display + try await Task.sleep(nanoseconds: .second * 10) + + let webview = app.webViews.firstMatch + XCTAssert(webview.waitForExistence(timeout: 10)) + + func tapAuthorizeButton() async throws -> Bool { + let authorizeButton = webview.buttons["AUTHORIZE"].firstMatch + if authorizeButton.exists { + authorizeButton.tap() + try await Task.sleep(nanoseconds: .second * 5) + return true + } + return false + } + + let isAuthorized = try await tapAuthorizeButton() + if !isAuthorized { + let emailTextField = webview.textFields["E-mail address"].firstMatch + XCTAssert(emailTextField.waitForExistence(timeout: 10)) + emailTextField.tap() + emailTextField.typeText(email) + + let passwordTextField = webview.secureTextFields["Password"].firstMatch + XCTAssert(passwordTextField.waitForExistence(timeout: 3)) + passwordTextField.tap() + passwordTextField.typeText(password) + + let goKeyboardButton = XCUIApplication().keyboards.buttons["Go"].firstMatch + XCTAssert(goKeyboardButton.waitForExistence(timeout: 3)) + goKeyboardButton.tap() + + var retry = 0 + let retryLimit = 20 + while webview.exists { + guard retry < retryLimit else { + fatalError("Cannot complete OAuth process") + } + retry += 1 + + // will break due to webview dismiss + _ = try await tapAuthorizeButton() + + print("Please enter the sign-in confirm code. Retry in 5s") + try await Task.sleep(nanoseconds: .second * 5) + } + } else { + // Done + } + + print("OAuth finish") + } + + enum OnboardingPage { + case welcome + case login(domain: String) + case serverRules(domain: String) + } + + private func coordinateToOnboarding(app: XCUIApplication, page: OnboardingPage) async throws { + // check in Onboarding or not + let loginButton = app.buttons["Log In"].firstMatch + try await Task.sleep(nanoseconds: .second * 3) + let loginButtonExists = loginButton.exists + + // goto Onboarding scene if already sign-in + if !loginButtonExists { + try await showTitleButtonMenu(app: app) + + let showMenu = app.collectionViews.buttons["Show…"].firstMatch + XCTAssert(showMenu.waitForExistence(timeout: 3)) + showMenu.tap() + try await Task.sleep(nanoseconds: .second * 1) + + let welcomeAction = app.collectionViews.buttons["Welcome"].firstMatch + XCTAssert(welcomeAction.waitForExistence(timeout: 3)) + welcomeAction.tap() + try await Task.sleep(nanoseconds: .second * 1) + } + + func type(domain: String) async throws { + // type domain + let domainTextField = app.textFields.firstMatch + XCTAssert(domainTextField.waitForExistence(timeout: 5)) + domainTextField.tap() + + // Skip system keyboard swipe input guide + try await skipKeyboardSwipeInputGuide(app: app) + domainTextField.typeText(domain) + XCUIApplication().keyboards.buttons["Done"].firstMatch.tap() + } + + switch page { + case .welcome: + break + case .login(let domain): + // Tap login button + XCTAssert(loginButtonExists) + loginButton.tap() + // type domain + try await type(domain: domain) + // add system alert monitor + // A. The monitor not works + // addUIInterruptionMonitor(withDescription: "Authentication Alert") { alert in + // alert.buttons["Continue"].firstMatch.tap() + // return true + // } + // tap next + try await selectServerAndContinue(app: app, domain: domain) + // wait authentication alert display + try await Task.sleep(nanoseconds: .second * 3) + // B. Workaround + let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") + let continueButton = springboard.buttons["Continue"].firstMatch + XCTAssert(continueButton.waitForExistence(timeout: 3)) + continueButton.tap() + case .serverRules(let domain): + // Tap sign up button + let signUpButton = app.buttons["Get Started"].firstMatch + XCTAssert(signUpButton.waitForExistence(timeout: 3)) + signUpButton.tap() + // type domain + try await type(domain: domain) + // tap next + try await selectServerAndContinue(app: app, domain: domain) + } + } + + private func selectServerAndContinue(app: XCUIApplication, domain: String) async throws { + // wait searching + try await Task.sleep(nanoseconds: .second * 3) + + // tap server + let cell = app.cells.containing(.staticText, identifier: domain).firstMatch + XCTAssert(cell.waitForExistence(timeout: 5)) + cell.tap() + + // tap next button + let nextButton = app.buttons.matching(NSPredicate(format: "enabled == true")).matching(identifier: "Next").firstMatch + XCTAssert(nextButton.waitForExistence(timeout: 3)) + nextButton.tap() + } + + private func skipKeyboardSwipeInputGuide(app: XCUIApplication) async throws { + let swipeInputLabel = app.staticTexts["Speed up your typing by sliding your finger across the letters to compose a word."].firstMatch + try await Task.sleep(nanoseconds: .second * 3) + guard swipeInputLabel.exists else { return } + let continueButton = app.buttons["Continue"] + continueButton.tap() + } + +} diff --git a/NotificationService/Info.plist b/NotificationService/Info.plist index 8e14f3a2a..215b572b5 100644 --- a/NotificationService/Info.plist +++ b/NotificationService/Info.plist @@ -17,9 +17,9 @@ <key>CFBundlePackageType</key> <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> <key>CFBundleShortVersionString</key> - <string>1.2.0</string> + <string>1.3.0</string> <key>CFBundleVersion</key> - <string>88</string> + <string>109</string> <key>NSExtension</key> <dict> <key>NSExtensionPointIdentifier</key> diff --git a/NotificationService/MastodonNotification.swift b/NotificationService/MastodonNotification.swift index 7d6fb034d..7d961f31d 100644 --- a/NotificationService/MastodonNotification.swift +++ b/NotificationService/MastodonNotification.swift @@ -9,10 +9,10 @@ import Foundation struct MastodonPushNotification: Codable { - let _accessToken: String - var accessToken: String { - return String.normalize(base64String: _accessToken) - } + let accessToken: String +// var accessToken: String { +// return String.normalize(base64String: _accessToken) +// } let notificationID: Int let notificationType: String @@ -23,7 +23,7 @@ struct MastodonPushNotification: Codable { let body: String enum CodingKeys: String, CodingKey { - case _accessToken = "access_token" + case accessToken = "access_token" case notificationID = "notification_id" case notificationType = "notification_type" case preferredLocale = "preferred_locale" @@ -33,7 +33,7 @@ struct MastodonPushNotification: Codable { } public init( - _accessToken: String, + accessToken: String, notificationID: Int, notificationType: String, preferredLocale: String?, @@ -41,7 +41,7 @@ struct MastodonPushNotification: Codable { title: String, body: String ) { - self._accessToken = _accessToken + self.accessToken = accessToken self.notificationID = notificationID self.notificationType = notificationType self.preferredLocale = preferredLocale diff --git a/Podfile b/Podfile index 868af1a96..ad7715abd 100644 --- a/Podfile +++ b/Podfile @@ -8,15 +8,15 @@ target 'Mastodon' do # UI pod 'UITextField+Shake', '~> 1.2' - pod 'Texture', '~> 3.0.0', :configurations => ['ASDK - Debug', 'ASDK - Release'] # misc pod 'SwiftGen', '~> 6.4.0' pod 'DateToolsSwift', '~> 5.0.0' pod 'Kanna', '~> 5.2.2' + pod 'Sourcery', '~> 1.6.1' # DEBUG - pod 'FLEX', '~> 4.4.0', :configurations => ['Debug', 'ASDK - Debug'] + pod 'FLEX', '~> 4.4.0', :configurations => ['Debug', "Release Snapshot"] target 'MastodonTests' do inherit! :search_paths @@ -63,4 +63,4 @@ post_install do |installer| config.build_settings.delete 'IPHONEOS_DEPLOYMENT_TARGET' end end -end \ No newline at end of file +end diff --git a/Podfile.lock b/Podfile.lock index 3541289d0..f8dde6937 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -3,42 +3,10 @@ PODS: - FLEX (4.4.1) - Kanna (5.2.7) - Keys (1.0.1) - - PINCache (3.0.3): - - PINCache/Arc-exception-safe (= 3.0.3) - - PINCache/Core (= 3.0.3) - - PINCache/Arc-exception-safe (3.0.3): - - PINCache/Core - - PINCache/Core (3.0.3): - - PINOperation (~> 1.2.1) - - PINOperation (1.2.1) - - PINRemoteImage/Core (3.0.3): - - PINOperation - - PINRemoteImage/iOS (3.0.3): - - PINRemoteImage/Core - - PINRemoteImage/PINCache (3.0.3): - - PINCache (~> 3.0.3) - - PINRemoteImage/Core + - Sourcery (1.6.1): + - Sourcery/CLI-Only (= 1.6.1) + - Sourcery/CLI-Only (1.6.1) - SwiftGen (6.4.0) - - Texture (3.0.0): - - Texture/AssetsLibrary (= 3.0.0) - - Texture/Core (= 3.0.0) - - Texture/MapKit (= 3.0.0) - - Texture/Photos (= 3.0.0) - - Texture/PINRemoteImage (= 3.0.0) - - Texture/Video (= 3.0.0) - - Texture/AssetsLibrary (3.0.0): - - Texture/Core - - Texture/Core (3.0.0) - - Texture/MapKit (3.0.0): - - Texture/Core - - Texture/Photos (3.0.0): - - Texture/Core - - Texture/PINRemoteImage (3.0.0): - - PINRemoteImage/iOS (~> 3.0.0) - - PINRemoteImage/PINCache - - Texture/Core - - Texture/Video (3.0.0): - - Texture/Core - "UITextField+Shake (1.2.1)" DEPENDENCIES: @@ -46,8 +14,8 @@ DEPENDENCIES: - FLEX (~> 4.4.0) - Kanna (~> 5.2.2) - Keys (from `Pods/CocoaPodsKeys`) + - Sourcery (~> 1.6.1) - SwiftGen (~> 6.4.0) - - Texture (~> 3.0.0) - "UITextField+Shake (~> 1.2)" SPEC REPOS: @@ -55,11 +23,8 @@ SPEC REPOS: - DateToolsSwift - FLEX - Kanna - - PINCache - - PINOperation - - PINRemoteImage + - Sourcery - SwiftGen - - Texture - "UITextField+Shake" EXTERNAL SOURCES: @@ -71,13 +36,10 @@ SPEC CHECKSUMS: FLEX: 7ca2c8cd3a435ff501ff6d2f2141e9bdc934eaab Kanna: 01cfbddc127f5ff0963692f285fcbc8a9d62d234 Keys: a576f4c9c1c641ca913a959a9c62ed3f215a8de9 - PINCache: 7a8fc1a691173d21dbddbf86cd515de6efa55086 - PINOperation: 00c935935f1e8cf0d1e2d6b542e75b88fc3e5e20 - PINRemoteImage: f1295b29f8c5e640e25335a1b2bd9d805171bd01 + Sourcery: f3759f803bd0739f74fc92a4341eed0473ce61ac SwiftGen: 67860cc7c3cfc2ed25b9b74cfd55495fc89f9108 - Texture: 2f109e937850d94d1d07232041c9c7313ccddb81 "UITextField+Shake": 298ac5a0f239d731bdab999b19b628c956ca0ac3 -PODFILE CHECKSUM: 4db0bdf969729c5758bd923e33d9e097cb892086 +PODFILE CHECKSUM: c471d1f9c923dc63bf8684415c79b85adb2ac36b -COCOAPODS: 1.11.2 +COCOAPODS: 1.11.3 diff --git a/README.md b/README.md index e1686b2e8..aac90354a 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ arch -x86_64 pod install 3. Select `Mastodon` scheme and run it. #### Contributors -The app require the `App Group` capability. To make sure it works for your developer membership. Please check [AppName.swift](AppShared/AppName.swift) file and set another unique `groupID` and update `App Group` settings. +The app require the `App Group` capability. To make sure it works for your developer membership. Please check [AppSecret.swift](AppShared/AppSecret.swift) file and set another unique `groupID` and update `App Group` settings. The app is compatible with [toot-relay](https://github.com/DagAgren/toot-relay) APNs. You can set your push notification endpoint via cocoapod-keys. @@ -78,7 +78,7 @@ The app is compatible with [toot-relay](https://github.com/DagAgren/toot-relay) - [SwiftUI-Introspect](https://github.com/siteline/SwiftUI-Introspect) - [SwiftyJSON](https://github.com/SwiftyJSON/SwiftyJSON) - [Tabman](https://github.com/uias/Tabman) -- [Texture](https://github.com/TextureGroup/Texture) +- [TwidereX-iOS](https://github.com/TwidereProject/TwidereX-iOS) - [ThirdPartyMailer](https://github.com/vtourraine/ThirdPartyMailer) - [TOCropViewController](https://github.com/TimOliver/TOCropViewController) - [TwitterProfile](https://github.com/OfTheWolf/TwitterProfile) diff --git a/ShareActionExtension/Info.plist b/ShareActionExtension/Info.plist index 1b3025474..82ba5658a 100644 --- a/ShareActionExtension/Info.plist +++ b/ShareActionExtension/Info.plist @@ -17,15 +17,17 @@ <key>CFBundlePackageType</key> <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> <key>CFBundleShortVersionString</key> - <string>1.2.0</string> + <string>1.3.0</string> <key>CFBundleVersion</key> - <string>88</string> + <string>109</string> <key>NSExtension</key> <dict> <key>NSExtensionAttributes</key> <dict> <key>NSExtensionActivationRule</key> <dict> + <key>NSExtensionActivationSupportsText</key> + <true/> <key>NSExtensionActivationSupportsWebURLWithMaxCount</key> <integer>1</integer> <key>NSExtensionActivationSupportsImageWithMaxCount</key> diff --git a/ShareActionExtension/Scene/ShareViewController.swift b/ShareActionExtension/Scene/ShareViewController.swift index 765c42d1e..d45558f1a 100644 --- a/ShareActionExtension/Scene/ShareViewController.swift +++ b/ShareActionExtension/Scene/ShareViewController.swift @@ -10,6 +10,8 @@ import UIKit import Combine import MastodonUI import SwiftUI +import MastodonAsset +import MastodonLocalization class ShareViewController: UIViewController { diff --git a/ShareActionExtension/Scene/ShareViewModel.swift b/ShareActionExtension/Scene/ShareViewModel.swift index 76089e17d..fbad82209 100644 --- a/ShareActionExtension/Scene/ShareViewModel.swift +++ b/ShareActionExtension/Scene/ShareViewModel.swift @@ -14,6 +14,8 @@ import MastodonSDK import MastodonUI import SwiftUI import UniformTypeIdentifiers +import MastodonAsset +import MastodonLocalization final class ShareViewModel { @@ -260,6 +262,10 @@ extension ShareViewModel { itemProviders.append(contentsOf: item.attachments ?? []) } + let _textProvider = itemProviders.first { provider in + return provider.hasRepresentationConforming(toTypeIdentifier: UTType.plainText.identifier, fileOptions: []) + } + let _urlProvider = itemProviders.first { provider in return provider.hasRepresentationConforming(toTypeIdentifier: UTType.url.identifier, fileOptions: []) } @@ -272,25 +278,51 @@ extension ShareViewModel { return provider.hasRepresentationConforming(toTypeIdentifier: UTType.image.identifier, fileOptions: []) } - if let urlProvider = _urlProvider { - urlProvider.loadItem(forTypeIdentifier: UTType.url.identifier) { [weak self] item, error in - guard let self = self else { return } - guard let url = item as? URL else { return } - DispatchQueue.main.async { - self.composeViewModel.statusContent = "\(url.absoluteString) " - } - } - } else if let movieProvider = _movieProvider { + Task { @MainActor in + async let text = ShareViewModel.loadText(textProvider: _textProvider) + async let url = ShareViewModel.loadURL(textProvider: _urlProvider) + + let content = await [text, url] + .compactMap { $0 } + .joined(separator: " ") + self.composeViewModel.statusContent = content + } + + if let movieProvider = _movieProvider { composeViewModel.setupAttachmentViewModels([ StatusAttachmentViewModel(itemProvider: movieProvider) ]) - } else { + } else if !imageProviders.isEmpty { let viewModels = imageProviders.map { provider in StatusAttachmentViewModel(itemProvider: provider) } composeViewModel.setupAttachmentViewModels(viewModels) } + } + + private static func loadText(textProvider: NSItemProvider?) async -> String? { + guard let textProvider = textProvider else { return nil } + do { + let item = try await textProvider.loadItem(forTypeIdentifier: UTType.plainText.identifier) + guard let text = item as? String else { return nil } + return text + } catch { + return nil + } + } + + private static func loadURL(textProvider: NSItemProvider?) async -> String? { + guard let textProvider = textProvider else { return nil } + do { + let item = try await textProvider.loadItem(forTypeIdentifier: UTType.url.identifier) + guard let url = item as? URL else { return nil } + return url.absoluteString + } catch { + return nil + } + } + } extension ShareViewModel { @@ -298,7 +330,8 @@ extension ShareViewModel { guard let authentication = composeViewModel.authentication else { return Fail(error: APIService.APIError.implicit(.authenticationMissing)).eraseToAnyPublisher() } - let mastodonAuthenticationBox = MastodonAuthenticationBox( + let authenticationBox = MastodonAuthenticationBox( + authenticationRecord: .init(objectID: authentication.objectID), domain: authentication.domain, userID: authentication.userID, appAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.appAccessToken), @@ -334,7 +367,7 @@ extension ShareViewModel { domain: domain, attachmentID: attachmentID, query: query, - mastodonAuthenticationBox: mastodonAuthenticationBox + mastodonAuthenticationBox: authenticationBox ) subscriptions.append(subscription) } @@ -345,7 +378,7 @@ extension ShareViewModel { return Publishers.MergeMany(updateMediaQuerySubscriptions) .collect() - .flatMap { attachments -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Status>, Error> in + .asyncMap { attachments in let query = Mastodon.API.Statuses.PublishStatusQuery( status: status, mediaIDs: mediaIDs.isEmpty ? nil : mediaIDs, @@ -356,11 +389,11 @@ extension ShareViewModel { spoilerText: spoilerText, visibility: visibility ) - return APIService.shared.publishStatus( + return try await APIService.shared.publishStatus( domain: domain, idempotencyKey: nil, // FIXME: query: query, - mastodonAuthenticationBox: mastodonAuthenticationBox + authenticationBox: authenticationBox ) } .eraseToAnyPublisher() diff --git a/ShareActionExtension/Scene/View/ComposeToolbarView.swift b/ShareActionExtension/Scene/View/ComposeToolbarView.swift index d88bb018c..73caac735 100644 --- a/ShareActionExtension/Scene/View/ComposeToolbarView.swift +++ b/ShareActionExtension/Scene/View/ComposeToolbarView.swift @@ -10,6 +10,8 @@ import UIKit import Combine import MastodonSDK import MastodonUI +import MastodonAsset +import MastodonLocalization protocol ComposeToolbarViewDelegate: AnyObject { func composeToolbarView(_ composeToolbarView: ComposeToolbarView, contentWarningButtonDidPressed sender: UIButton) diff --git a/ShareActionExtension/Scene/View/StatusAttachmentView.swift b/ShareActionExtension/Scene/View/StatusAttachmentView.swift index 4bc2ff9a5..90b8aceeb 100644 --- a/ShareActionExtension/Scene/View/StatusAttachmentView.swift +++ b/ShareActionExtension/Scene/View/StatusAttachmentView.swift @@ -105,22 +105,22 @@ extension View { } -struct StatusAttachmentView_Previews: PreviewProvider { - static var previews: some View { - ScrollView { - StatusAttachmentView( - image: UIImage(systemName: "photo"), - descriptionPlaceholder: "Describe photo", - description: .constant(""), - errorPrompt: nil, - errorPromptImage: StatusAttachmentViewModel.photoFillSplitImage, - isUploading: true, - progressViewTintColor: .systemFill, - removeButtonAction: { - // do nothing - } - ) - .padding(20) - } - } -} +//struct StatusAttachmentView_Previews: PreviewProvider { +// static var previews: some View { +// ScrollView { +// StatusAttachmentView( +// image: UIImage(systemName: "photo"), +// descriptionPlaceholder: "Describe photo", +// description: .constant(""), +// errorPrompt: nil, +// errorPromptImage: StatusAttachmentViewModel.photoFillSplitImage, +// isUploading: true, +// progressViewTintColor: .systemFill, +// removeButtonAction: { +// // do nothing +// } +// ) +// .padding(20) +// } +// } +//} diff --git a/ShareActionExtension/Scene/View/StatusAttachmentViewModel+UploadState.swift b/ShareActionExtension/Scene/View/StatusAttachmentViewModel+UploadState.swift index cfd0a4de8..ce0544aa1 100644 --- a/ShareActionExtension/Scene/View/StatusAttachmentViewModel+UploadState.swift +++ b/ShareActionExtension/Scene/View/StatusAttachmentViewModel+UploadState.swift @@ -67,6 +67,7 @@ extension StatusAttachmentViewModel.UploadState { ) let mastodonAuthenticationBox = MastodonAuthenticationBox( + authenticationRecord: .init(objectID: authentication.objectID), domain: authentication.domain, userID: authentication.userID, appAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.appAccessToken), diff --git a/ShareActionExtension/Scene/View/StatusAttachmentViewModel.swift b/ShareActionExtension/Scene/View/StatusAttachmentViewModel.swift index f0c1e6447..37d4f82e8 100644 --- a/ShareActionExtension/Scene/View/StatusAttachmentViewModel.swift +++ b/ShareActionExtension/Scene/View/StatusAttachmentViewModel.swift @@ -16,6 +16,8 @@ import AVFoundation import GameplayKit import MobileCoreServices import UniformTypeIdentifiers +import MastodonAsset +import MastodonLocalization protocol StatusAttachmentViewModelDelegate: AnyObject { func statusAttachmentViewModel(_ viewModel: StatusAttachmentViewModel, uploadStateDidChange state: StatusAttachmentViewModel.UploadState?) diff --git a/ShareActionExtension/Scene/View/StatusEditorView.swift b/ShareActionExtension/Scene/View/StatusEditorView.swift index c945874e6..595057fa0 100644 --- a/ShareActionExtension/Scene/View/StatusEditorView.swift +++ b/ShareActionExtension/Scene/View/StatusEditorView.swift @@ -81,7 +81,10 @@ public struct StatusEditorView: UIViewRepresentable { } public func textViewDidChange(_ textView: UITextView) { - parent.string = textView.text + // prevent break IME input + if textView.markedTextRange == nil { + parent.string = textView.text + } } func updateLayout(width: CGFloat) { diff --git a/swiftgen.yml b/swiftgen.yml index e086533fb..e9c21260a 100644 --- a/swiftgen.yml +++ b/swiftgen.yml @@ -1,12 +1,26 @@ strings: inputs: - - Mastodon/Resources/en.lproj/Localizable.strings - - Mastodon/Resources/en.lproj/Localizable.stringsdict + - MastodonSDK/Sources/MastodonLocalization/Resources/en.lproj/Localizable.strings + - MastodonSDK/Sources/MastodonLocalization/Resources/en.lproj/Localizable.stringsdict outputs: - templateName: structured-swift5 - output: Mastodon/Generated/Strings.swift + output: MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift + params: + bundle: Bundle.module + publicAccess: true xcassets: - inputs: Mastodon/Resources/Assets.xcassets + inputs: MastodonSDK/Sources/MastodonAsset/Assets.xcassets outputs: templateName: swift5 - output: Mastodon/Generated/Assets.swift + output: MastodonSDK/Sources/MastodonAsset/Generated/Assets.swift + params: + bundle: Bundle.module + publicAccess: true +fonts: + inputs: MastodonSDK/Sources/MastodonAsset/Font + outputs: + templateName: swift5 + output: MastodonSDK/Sources/MastodonAsset/Generated/Fonts.swift + params: + bundle: Bundle.module + publicAccess: true \ No newline at end of file diff --git a/update_localization.sh b/update_localization.sh index 006fd8cf1..b234cd933 100755 --- a/update_localization.sh +++ b/update_localization.sh @@ -12,7 +12,7 @@ cd ${SRCROOT}/Localization/StringsConvertor sh ./scripts/build.sh # task 2 copy strings file -cp -R ${SRCROOT}/Localization/StringsConvertor/output/ ${SRCROOT}/Mastodon/Resources +cp -R ${SRCROOT}/Localization/StringsConvertor/output/module/ ${SRCROOT}/MastodonSDK/Sources/MastodonLocalization/Resources cp -R ${SRCROOT}/Localization/StringsConvertor/Intents/output/ ${SRCROOT}/MastodonIntent # task 3 swiftgen