diff --git a/AppShared/Info.plist b/AppShared/Info.plist index f652792e..697cdf4d 100644 --- a/AppShared/Info.plist +++ b/AppShared/Info.plist @@ -17,6 +17,6 @@ CFBundleShortVersionString 1.3.0 CFBundleVersion - 90 + 91 diff --git a/CoreDataStack/.sourcery.yml b/CoreDataStack/.sourcery.yml new file mode 100644 index 00000000..ac3ddce8 --- /dev/null +++ b/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/CoreDataStack/CoreData.xcdatamodeld/.xccurrentversion index 3d5e5761..cdd244c9 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/.xccurrentversion +++ b/CoreDataStack/CoreData.xcdatamodeld/.xccurrentversion @@ -3,6 +3,6 @@ _XCCurrentVersionName - CoreData 2.xcdatamodel + CoreData 3.xcdatamodel diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData 2.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData 2.xcdatamodel/contents index 6d576ca1..f4a7b801 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData 2.xcdatamodel/contents +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData 2.xcdatamodel/contents @@ -1,5 +1,5 @@ - + diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData 3.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData 3.xcdatamodel/contents new file mode 100644 index 00000000..cba342df --- /dev/null +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData 3.xcdatamodel/contents @@ -0,0 +1,283 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/CoreDataStack/Entity/App/Feed.swift b/CoreDataStack/Entity/App/Feed.swift new file mode 100644 index 00000000..0a44a409 --- /dev/null +++ b/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) + } + + 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 { 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/HomeTimelineIndex.swift b/CoreDataStack/Entity/App/HomeTimelineIndex.swift similarity index 100% rename from CoreDataStack/Entity/HomeTimelineIndex.swift rename to CoreDataStack/Entity/App/HomeTimelineIndex.swift diff --git a/CoreDataStack/Entity/Setting.swift b/CoreDataStack/Entity/App/Setting.swift similarity index 100% rename from CoreDataStack/Entity/Setting.swift rename to CoreDataStack/Entity/App/Setting.swift diff --git a/CoreDataStack/Entity/Attachment.swift b/CoreDataStack/Entity/Attachment.swift deleted file mode 100644 index f3f5d262..00000000 --- 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/Application.swift b/CoreDataStack/Entity/Mastodon/Application.swift similarity index 100% rename from CoreDataStack/Entity/Application.swift rename to CoreDataStack/Entity/Mastodon/Application.swift diff --git a/CoreDataStack/Entity/DomainBlock.swift b/CoreDataStack/Entity/Mastodon/DomainBlock.swift similarity index 100% rename from CoreDataStack/Entity/DomainBlock.swift rename to CoreDataStack/Entity/Mastodon/DomainBlock.swift diff --git a/CoreDataStack/Entity/Emoji.swift b/CoreDataStack/Entity/Mastodon/Emoji.swift similarity index 100% rename from CoreDataStack/Entity/Emoji.swift rename to CoreDataStack/Entity/Mastodon/Emoji.swift diff --git a/CoreDataStack/Entity/History.swift b/CoreDataStack/Entity/Mastodon/History.swift similarity index 100% rename from CoreDataStack/Entity/History.swift rename to CoreDataStack/Entity/Mastodon/History.swift diff --git a/CoreDataStack/Entity/Instance.swift b/CoreDataStack/Entity/Mastodon/Instance.swift similarity index 100% rename from CoreDataStack/Entity/Instance.swift rename to CoreDataStack/Entity/Mastodon/Instance.swift diff --git a/CoreDataStack/Entity/MastodonAuthentication.swift b/CoreDataStack/Entity/Mastodon/MastodonAuthentication.swift similarity index 100% rename from CoreDataStack/Entity/MastodonAuthentication.swift rename to CoreDataStack/Entity/Mastodon/MastodonAuthentication.swift diff --git a/CoreDataStack/Entity/Mastodon/MastodonUser.swift b/CoreDataStack/Entity/Mastodon/MastodonUser.swift new file mode 100644 index 00000000..3def7efd --- /dev/null +++ b/CoreDataStack/Entity/Mastodon/MastodonUser.swift @@ -0,0 +1,604 @@ +// +// 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 + @NSManaged public private(set) var notifications: Set + @NSManaged public private(set) var searchHistories: Set + + // many-to-many relationship + @NSManaged public private(set) var favourite: Set + @NSManaged public private(set) var reblogged: Set + @NSManaged public private(set) var muted: Set + @NSManaged public private(set) var bookmarked: Set + @NSManaged public private(set) var votePollOptions: Set + @NSManaged public private(set) var votePolls: Set + // relationships + @NSManaged public private(set) var following: Set + @NSManaged public private(set) var followingBy: Set + @NSManaged public private(set) var followRequested: Set + @NSManaged public private(set) var followRequestedBy: Set + @NSManaged public private(set) var muting: Set + @NSManaged public private(set) var mutingBy: Set + @NSManaged public private(set) var blocking: Set + @NSManaged public private(set) var blockingBy: Set + @NSManaged public private(set) var endorsed: Set + @NSManaged public private(set) var endorsedBy: Set + @NSManaged public private(set) var domainBlocking: Set + @NSManaged public private(set) var domainBlockingBy: Set + +} + +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) + ]) + } + +} + + +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/CoreDataStack/Entity/Mastodon/Notification.swift b/CoreDataStack/Entity/Mastodon/Notification.swift new file mode 100644 index 00000000..85019b0d --- /dev/null +++ b/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 + +} + +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/CoreDataStack/Entity/Mastodon/Poll.swift b/CoreDataStack/Entity/Mastodon/Poll.swift new file mode 100644 index 00000000..a237f539 --- /dev/null +++ b/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 + + // many-to-many relationship + @NSManaged public private(set) var votedBy: Set? +} + +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/CoreDataStack/Entity/Mastodon/PollOption.swift b/CoreDataStack/Entity/Mastodon/PollOption.swift new file mode 100644 index 00000000..2799dd0a --- /dev/null +++ b/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? +} + + +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/CoreDataStack/Entity/Mastodon/PrivateNote.swift similarity index 100% rename from CoreDataStack/Entity/PrivateNote.swift rename to CoreDataStack/Entity/Mastodon/PrivateNote.swift diff --git a/CoreDataStack/Entity/Mastodon/SearchHistory.swift b/CoreDataStack/Entity/Mastodon/SearchHistory.swift new file mode 100644 index 00000000..c3c6d28c --- /dev/null +++ b/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/CoreDataStack/Entity/Mastodon/Status.swift b/CoreDataStack/Entity/Mastodon/Status.swift new file mode 100644 index 00000000..c506536d --- /dev/null +++ b/CoreDataStack/Entity/Mastodon/Status.swift @@ -0,0 +1,802 @@ +// +// 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? + + @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 + @NSManaged public private(set) var rebloggedBy: Set + @NSManaged public private(set) var mutedBy: Set + @NSManaged public private(set) var bookmarkedBy: Set + + // 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 + + @NSManaged public private(set) var reblogFrom: Set +// @NSManaged public private(set) var mentions: Set? +// @NSManaged public private(set) var homeTimelineIndexes: Set? +// @NSManaged public private(set) var mediaAttachments: Set? + @NSManaged public private(set) var replyFrom: Set + @NSManaged public private(set) var notifications: Set + @NSManaged public private(set) var searchHistories: Set + + // 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 + } + +// @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 didUpdate(at networkDate: Date) { +// self.updatedAt = 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)) + } + +} + +// 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(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) + } +} + + +//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 +// } +// +// } +//} +// diff --git a/CoreDataStack/Entity/Subscription.swift b/CoreDataStack/Entity/Mastodon/Subscription.swift similarity index 100% rename from CoreDataStack/Entity/Subscription.swift rename to CoreDataStack/Entity/Mastodon/Subscription.swift diff --git a/CoreDataStack/Entity/SubscriptionAlerts.swift b/CoreDataStack/Entity/Mastodon/SubscriptionAlerts.swift similarity index 100% rename from CoreDataStack/Entity/SubscriptionAlerts.swift rename to CoreDataStack/Entity/Mastodon/SubscriptionAlerts.swift diff --git a/CoreDataStack/Entity/Mastodon/Tag.swift b/CoreDataStack/Entity/Mastodon/Tag.swift new file mode 100644 index 00000000..b5c335db --- /dev/null +++ b/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 +} + +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/CoreDataStack/Entity/MastodonUser.swift b/CoreDataStack/Entity/MastodonUser.swift deleted file mode 100644 index 913aa1f1..00000000 --- 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? - @NSManaged public private(set) var notifications: Set? - @NSManaged public private(set) var searchHistories: Set - - // many-to-many relationship - @NSManaged public private(set) var favourite: Set? - @NSManaged public private(set) var reblogged: Set? - @NSManaged public private(set) var muted: Set? - @NSManaged public private(set) var bookmarked: Set? - @NSManaged public private(set) var votePollOptions: Set? - @NSManaged public private(set) var votePolls: Set? - // relationships - @NSManaged public private(set) var following: Set? - @NSManaged public private(set) var followingBy: Set? - @NSManaged public private(set) var followRequested: Set? - @NSManaged public private(set) var followRequestedBy: Set? - @NSManaged public private(set) var muting: Set? - @NSManaged public private(set) var mutingBy: Set? - @NSManaged public private(set) var blocking: Set? - @NSManaged public private(set) var blockingBy: Set? - @NSManaged public private(set) var endorsed: Set? - @NSManaged public private(set) var endorsedBy: Set? - @NSManaged public private(set) var domainBlocking: Set? - @NSManaged public private(set) var domainBlockingBy: Set? - -} - -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 864ca494..00000000 --- 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 04f8e9fd..00000000 --- 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 3ab48b44..00000000 --- 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 - - // many-to-many relationship - @NSManaged public private(set) var votedBy: Set? -} - -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 8917a753..00000000 --- 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? -} - -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 05e44190..00000000 --- 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 ee168e41..00000000 --- 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? - @NSManaged public private(set) var rebloggedBy: Set? - @NSManaged public private(set) var mutedBy: Set? - @NSManaged public private(set) var bookmarkedBy: Set? - - // 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? - @NSManaged public private(set) var mentions: Set? - @NSManaged public private(set) var homeTimelineIndexes: Set? - @NSManaged public private(set) var mediaAttachments: Set? - @NSManaged public private(set) var replyFrom: Set? - - @NSManaged public private(set) var inNotifications: Set? - - @NSManaged public private(set) var searchHistories: Set - - @NSManaged public private(set) var updatedAt: Date - @NSManaged public private(set) var deletedAt: Date? - @NSManaged public private(set) var revealedAt: Date? -} - -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 fa9e098d..00000000 --- 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? - @NSManaged public private(set) var searchHistories: Set -} - -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/Entity/Transient/Acct.swift b/CoreDataStack/Entity/Transient/Acct.swift new file mode 100644 index 00000000..fe59bb9d --- /dev/null +++ b/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/CoreDataStack/Entity/Transient/Feed+Kind.swift b/CoreDataStack/Entity/Transient/Feed+Kind.swift new file mode 100644 index 00000000..de32d949 --- /dev/null +++ b/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/CoreDataStack/Entity/Transient/MastodonAttachment.swift b/CoreDataStack/Entity/Transient/MastodonAttachment.swift new file mode 100644 index 00000000..aa25ada1 --- /dev/null +++ b/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/CoreDataStack/Entity/Transient/MastodonEmoji.swift b/CoreDataStack/Entity/Transient/MastodonEmoji.swift new file mode 100644 index 00000000..b067849c --- /dev/null +++ b/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/CoreDataStack/Entity/Transient/MastodonField.swift b/CoreDataStack/Entity/Transient/MastodonField.swift new file mode 100644 index 00000000..507f6f9a --- /dev/null +++ b/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/CoreDataStack/Entity/Transient/MastodonMention.swift b/CoreDataStack/Entity/Transient/MastodonMention.swift new file mode 100644 index 00000000..ee53222c --- /dev/null +++ b/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/CoreDataStack/Entity/Transient/MastodonNotificationType.swift b/CoreDataStack/Entity/Transient/MastodonNotificationType.swift new file mode 100644 index 00000000..a982fda9 --- /dev/null +++ b/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/CoreDataStack/Entity/Transient/MastodonTagHistory.swift b/CoreDataStack/Entity/Transient/MastodonTagHistory.swift new file mode 100644 index 00000000..f2d1cf71 --- /dev/null +++ b/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/CoreDataStack/Entity/Transient/MastodonVisibility.swift b/CoreDataStack/Entity/Transient/MastodonVisibility.swift new file mode 100644 index 00000000..798db208 --- /dev/null +++ b/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/NSManagedObjectContext.swift b/CoreDataStack/Extension/NSManagedObjectContext.swift index e3f6600c..a3baf4dc 100644 --- a/CoreDataStack/Extension/NSManagedObjectContext.swift +++ b/CoreDataStack/Extension/NSManagedObjectContext.swift @@ -47,3 +47,66 @@ extension NSManagedObjectContext { } } } + +extension NSManagedObjectContext { + public func perform(block: @escaping () throws -> T) async throws -> T { + if #available(iOSApplicationExtension 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(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/Info.plist b/CoreDataStack/Info.plist index f652792e..697cdf4d 100644 --- a/CoreDataStack/Info.plist +++ b/CoreDataStack/Info.plist @@ -17,6 +17,6 @@ CFBundleShortVersionString 1.3.0 CFBundleVersion - 90 + 91 diff --git a/CoreDataStack/Stack/ManagedObjectContextObjectsDidChange.swift b/CoreDataStack/Stack/ManagedObjectContextObjectsDidChange.swift index 980a2a5e..33cbf08d 100644 --- a/CoreDataStack/Stack/ManagedObjectContextObjectsDidChange.swift +++ b/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/CoreDataStack/Stack/ManagedObjectObserver.swift index 3681fee9..c1fbb5b8 100644 --- a/CoreDataStack/Stack/ManagedObjectObserver.swift +++ b/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 { + + 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 { 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/CoreDataStack/Template/AutoGenerateProperty.swift b/CoreDataStack/Template/AutoGenerateProperty.swift new file mode 100644 index 00000000..e36b9369 --- /dev/null +++ b/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/CoreDataStack/Template/AutoGenerateRelationship.swift b/CoreDataStack/Template/AutoGenerateRelationship.swift new file mode 100644 index 00000000..caeed0de --- /dev/null +++ b/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/CoreDataStack/Template/AutoUpdatableObject.swift b/CoreDataStack/Template/AutoUpdatableObject.swift new file mode 100644 index 00000000..ad031db2 --- /dev/null +++ b/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/CoreDataStack/Template/Stencil/AutoGenerateProperty.stencil b/CoreDataStack/Template/Stencil/AutoGenerateProperty.stencil new file mode 100644 index 00000000..2c14bab2 --- /dev/null +++ b/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/CoreDataStack/Template/Stencil/AutoGenerateRelationship.stencil b/CoreDataStack/Template/Stencil/AutoGenerateRelationship.stencil new file mode 100644 index 00000000..8b549023 --- /dev/null +++ b/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/CoreDataStack/Template/Stencil/AutoUpdatableObject.stencil b/CoreDataStack/Template/Stencil/AutoUpdatableObject.stencil new file mode 100644 index 00000000..4e81c8b4 --- /dev/null +++ b/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/CoreDataStack/Utility/ManagedObjectRecord.swift b/CoreDataStack/Utility/ManagedObjectRecord.swift new file mode 100644 index 00000000..dbdce6c3 --- /dev/null +++ b/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: 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, rhs: ManagedObjectRecord) -> Bool { + return lhs.objectID == rhs.objectID + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(objectID) + } + +} diff --git a/CoreDataStackTests/Info.plist b/CoreDataStackTests/Info.plist index f652792e..697cdf4d 100644 --- a/CoreDataStackTests/Info.plist +++ b/CoreDataStackTests/Info.plist @@ -17,6 +17,6 @@ CFBundleShortVersionString 1.3.0 CFBundleVersion - 90 + 91 diff --git a/Localization/StringsConvertor/Sources/StringsConvertor/main.swift b/Localization/StringsConvertor/Sources/StringsConvertor/main.swift index 6507986b..79ee6b49 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 @@ -56,6 +61,7 @@ private func map(language: String) -> String? { 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/app.json b/Localization/app.json index 0071f6f9..b6da7c4e 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", @@ -412,14 +412,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" } @@ -472,12 +482,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": "followd 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" @@ -564,4 +576,4 @@ "accessibility_hint": "Double tap to dismiss this wizard" } } -} +} \ No newline at end of file diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index b79fb901..16a36e6d 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -11,11 +11,8 @@ 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 */; }; @@ -23,18 +20,13 @@ 0FAA102725E1126A0017CCDE /* MastodonPickServerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA102625E1126A0017CCDE /* MastodonPickServerViewController.swift */; }; 0FB3D2F725E4C24D00AAD544 /* MastodonPickServerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FB3D2F625E4C24D00AAD544 /* MastodonPickServerViewModel.swift */; }; 0FB3D2FE25E4CB6400AAD544 /* OnboardingHeadlineTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FB3D2FD25E4CB6400AAD544 /* OnboardingHeadlineTableViewCell.swift */; }; - 0FB3D30825E524C600AAD544 /* PickServerCategoriesCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FB3D30725E524C600AAD544 /* PickServerCategoriesCell.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 */; }; @@ -42,33 +34,21 @@ 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 */; }; @@ -83,30 +63,20 @@ 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 */; }; @@ -127,16 +97,11 @@ 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 */; }; @@ -168,9 +133,7 @@ 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,6 +146,20 @@ 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 */; }; + 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 */; }; + DB025B84278D6272002F581E /* AutoGenerateProperty.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB025B81278D6271002F581E /* AutoGenerateProperty.swift */; }; + DB025B85278D6272002F581E /* AutoUpdatableObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB025B82278D6272002F581E /* AutoUpdatableObject.swift */; }; + DB025B86278D6272002F581E /* AutoGenerateRelationship.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB025B83278D6272002F581E /* AutoGenerateRelationship.swift */; }; + DB025B89278D6339002F581E /* Feed.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB025B88278D6339002F581E /* Feed.swift */; }; + DB025B8C278D6374002F581E /* Acct.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB025B8B278D6374002F581E /* Acct.swift */; }; + DB025B90278D6489002F581E /* Feed+Kind.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB025B8F278D6489002F581E /* Feed+Kind.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 */; }; @@ -206,23 +183,44 @@ 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 */; }; + DB0FCB6A27950CB3006C02E2 /* MastodonMention.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0FCB6927950CB3006C02E2 /* MastodonMention.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 */; }; @@ -235,8 +233,27 @@ 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 */; }; + DB336F1E278D6C3A0031E64B /* MastodonEmoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB336F1D278D6C3A0031E64B /* MastodonEmoji.swift */; }; + DB336F21278D6D960031E64B /* MastodonEmoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB336F20278D6D960031E64B /* MastodonEmoji.swift */; }; + DB336F23278D6DED0031E64B /* MastodonEmojiContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB336F22278D6DED0031E64B /* MastodonEmojiContainer.swift */; }; + DB336F26278D6E8F0031E64B /* MastodonField.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB336F25278D6E8F0031E64B /* MastodonField.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 */; }; + DB336F30278D723D0031E64B /* MastodonVisibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB336F2F278D723D0031E64B /* MastodonVisibility.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 */; }; + DB336F3A278D7D1F0031E64B /* ManagedObjectRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB336F39278D7D1F0031E64B /* ManagedObjectRecord.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 */; }; + DB336F45278EB1D70031E64B /* MastodonAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB336F44278EB1D70031E64B /* MastodonAttachment.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 */; }; @@ -244,7 +261,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 */; }; @@ -261,11 +277,8 @@ 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 */; }; @@ -273,11 +286,9 @@ DB45FB1D25CA9D23005A8AC7 /* APIService+HomeTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */; }; DB47229725F9EFAD00DA7F53 /* NSManagedObjectContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB47229625F9EFAD00DA7F53 /* NSManagedObjectContext.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 */; }; @@ -289,7 +300,6 @@ 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 */; }; @@ -299,17 +309,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 */; }; @@ -323,11 +330,43 @@ 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 */; }; + DB63F76027995ECE00455B82 /* MastodonTagHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB63F75F27995ECE00455B82 /* MastodonTagHistory.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 */; }; + DB63F76D279A67BD00455B82 /* MastodonNotificationType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB63F76C279A67BD00455B82 /* MastodonNotificationType.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 */; }; @@ -347,18 +386,24 @@ 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 */; }; @@ -378,11 +423,6 @@ 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 */; }; @@ -406,9 +446,7 @@ 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, ); }; }; @@ -431,13 +469,13 @@ 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 */; }; @@ -445,14 +483,10 @@ 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 */; }; DB9A486C26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A486B26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift */; }; DB9A487E2603456B008B817C /* UITextView+Placeholder in Frameworks */ = {isa = PBXBuildFile; productRef = DB9A487D2603456B008B817C /* UITextView+Placeholder */; }; @@ -464,8 +498,6 @@ 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 */; }; @@ -473,7 +505,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 */; }; @@ -487,23 +518,17 @@ 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 */; }; 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 */; }; DBB525082611EAC0002F1F29 /* Tabman in Frameworks */ = {isa = PBXBuildFile; productRef = DBB525072611EAC0002F1F29 /* Tabman */; }; @@ -522,19 +547,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 */; }; @@ -545,12 +563,11 @@ 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 */; }; + DBBC50BF278ED0E700AF0CC6 /* Date.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBC50BE278ED0E700AF0CC6 /* Date.swift */; }; DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */; }; DBBF1DBF2652401B00E5B703 /* AutoCompleteViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBF1DBE2652401B00E5B703 /* AutoCompleteViewModel.swift */; }; DBBF1DC226524D2900E5B703 /* AutoCompleteTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBF1DC126524D2900E5B703 /* AutoCompleteTableViewCell.swift */; }; @@ -562,13 +579,7 @@ 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 */; }; @@ -594,7 +605,6 @@ 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 */; }; @@ -613,6 +623,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 */; }; @@ -625,8 +639,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 */; }; @@ -780,11 +792,8 @@ 0F2021FA2613262F000C64BF /* HashtagTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineViewController.swift; sourceTree = ""; }; 0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineViewModel.swift; sourceTree = ""; }; 0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+Diffable.swift"; sourceTree = ""; }; - 0F20220C26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+LoadLatestState.swift"; sourceTree = ""; }; 0F202212261351F5000C64BF /* APIService+HashtagTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+HashtagTimeline.swift"; sourceTree = ""; }; - 0F202226261411BA000C64BF /* HashtagTimelineViewController+Provider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewController+Provider.swift"; sourceTree = ""; }; 0F20222C261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+LoadOldestState.swift"; sourceTree = ""; }; - 0F20223226145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+LoadMiddleState.swift"; sourceTree = ""; }; 0F20223826146553000C64BF /* Array.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Array.swift; sourceTree = ""; }; 0FAA0FDE25E0B57E0017CCDE /* WelcomeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeViewController.swift; sourceTree = ""; }; 0FAA101125E105390017CCDE /* PrimaryActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryActionButton.swift; sourceTree = ""; }; @@ -792,19 +801,14 @@ 0FAA102625E1126A0017CCDE /* MastodonPickServerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPickServerViewController.swift; sourceTree = ""; }; 0FB3D2F625E4C24D00AAD544 /* MastodonPickServerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPickServerViewModel.swift; sourceTree = ""; }; 0FB3D2FD25E4CB6400AAD544 /* OnboardingHeadlineTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingHeadlineTableViewCell.swift; sourceTree = ""; }; - 0FB3D30725E524C600AAD544 /* PickServerCategoriesCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerCategoriesCell.swift; sourceTree = ""; }; 0FB3D30E25E525CD00AAD544 /* PickServerCategoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerCategoryView.swift; sourceTree = ""; }; 0FB3D31D25E534C700AAD544 /* PickServerCategoryCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerCategoryCollectionViewCell.swift; sourceTree = ""; }; - 0FB3D33125E5F50E00AAD544 /* PickServerSearchCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerSearchCell.swift; sourceTree = ""; }; 0FB3D33725E6401400AAD544 /* PickServerCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerCell.swift; sourceTree = ""; }; 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 = ""; }; 164F0EBB267D4FE400249499 /* BoopSound.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = BoopSound.caf; sourceTree = ""; }; 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 = ""; }; - 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+PublicTimeline.swift"; sourceTree = ""; }; 2D084B8C26258EA3003AA3AF /* NotificationViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationViewModel+Diffable.swift"; sourceTree = ""; }; - 2D084B9226259545003AA3AF /* NotificationViewModel+LoadLatestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationViewModel+LoadLatestState.swift"; sourceTree = ""; }; 2D0B7A1C261D839600B44727 /* SearchHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistory.swift; sourceTree = ""; }; - 2D152A8B25C295CC009AA50C /* StatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusView.swift; sourceTree = ""; }; 2D152A9125C2980C009AA50C /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = ""; }; 2D198642261BF09500F0B013 /* SearchResultItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultItem.swift; sourceTree = ""; }; 2D198648261C0B8500F0B013 /* SearchResultSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultSection.swift; sourceTree = ""; }; @@ -812,33 +816,21 @@ 2D206B8525F5FB0900143C56 /* Double.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Double.swift; sourceTree = ""; }; 2D206B8B25F6015000143C56 /* AudioPlaybackService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlaybackService.swift; sourceTree = ""; }; 2D206B9125F60EA700143C56 /* UIControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIControl.swift; sourceTree = ""; }; - 2D24E11C2626D8B100A59D4F /* NotificationStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationStatusTableViewCell.swift; sourceTree = ""; }; 2D24E1222626ED9D00A59D4F /* UIView+Gesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Gesture.swift"; sourceTree = ""; }; - 2D24E12C2626FD2E00A59D4F /* NotificationViewModel+LoadOldestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationViewModel+LoadOldestState.swift"; sourceTree = ""; }; 2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMiddleLoaderTableViewCell.swift; sourceTree = ""; }; 2D32EAB925CB9B0500C9ED86 /* UIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIView.swift; sourceTree = ""; }; - 2D32EAD925CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewModel+LoadMiddleState.swift"; sourceTree = ""; }; - 2D34D9CA261489930081BFC0 /* SearchViewController+Recommend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewController+Recommend.swift"; sourceTree = ""; }; 2D34D9D026148D9E0081BFC0 /* APIService+Recommend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Recommend.swift"; sourceTree = ""; }; 2D34D9DA261494120081BFC0 /* APIService+Search.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Search.swift"; sourceTree = ""; }; - 2D34D9E126149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRecommendTagsCollectionViewCell.swift; sourceTree = ""; }; 2D35237926256D920031AF25 /* NotificationSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSection.swift; sourceTree = ""; }; 2D364F7125E66D7500204FDC /* MastodonResendEmailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonResendEmailViewController.swift; sourceTree = ""; }; 2D364F7725E66D8300204FDC /* MastodonResendEmailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonResendEmailViewModel.swift; sourceTree = ""; }; 2D38F1C525CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentOffsetAdjustableTimelineViewControllerDelegate.swift; sourceTree = ""; }; 2D38F1D425CD465300561493 /* HomeTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineViewController.swift; sourceTree = ""; }; - 2D38F1DE25CD46A400561493 /* HomeTimelineViewController+Provider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeTimelineViewController+Provider.swift"; sourceTree = ""; }; 2D38F1E425CD46C100561493 /* HomeTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineViewModel.swift; sourceTree = ""; }; 2D38F1EA25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeTimelineViewModel+LoadLatestState.swift"; sourceTree = ""; }; - 2D38F1F025CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeTimelineViewModel+LoadMiddleState.swift"; sourceTree = ""; }; 2D38F1F625CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeTimelineViewModel+LoadOldestState.swift"; sourceTree = ""; }; - 2D38F1FD25CD481700561493 /* StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusProvider.swift; sourceTree = ""; }; 2D38F20725CD491300561493 /* DisposeBagCollectable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisposeBagCollectable.swift; sourceTree = ""; }; 2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITapGestureRecognizer.swift; sourceTree = ""; }; - 2D42FF7D25C82218004A627A /* ActionToolBarContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionToolBarContainer.swift; sourceTree = ""; }; - 2D42FF8425C8224F004A627A /* HitTestExpandedButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HitTestExpandedButton.swift; sourceTree = ""; }; - 2D42FF8E25C8228A004A627A /* UIButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIButton.swift; sourceTree = ""; }; - 2D45E5BE25C9549700A6D639 /* PublicTimelineViewModel+State.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewModel+State.swift"; sourceTree = ""; }; 2D4AD89B263165B500613EFC /* SuggestionAccountCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAccountCollectionViewCell.swift; sourceTree = ""; }; 2D4AD8A126316CD200613EFC /* SelectedAccountSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectedAccountSection.swift; sourceTree = ""; }; 2D4AD8A726316D3500613EFC /* SelectedAccountItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectedAccountItem.swift; sourceTree = ""; }; @@ -852,29 +844,19 @@ 2D607AD726242FC500B70763 /* NotificationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationViewModel.swift; sourceTree = ""; }; 2D6125462625436B00299647 /* Notification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notification.swift; sourceTree = ""; }; 2D61254C262547C200299647 /* APIService+Notification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Notification.swift"; sourceTree = ""; }; - 2D61335725C188A000CAE157 /* APIService+Persist+Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+Status.swift"; sourceTree = ""; }; 2D61335D25C1894B00CAE157 /* APIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIService.swift; sourceTree = ""; }; 2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error+Detail.swift"; sourceTree = ""; }; 2D694A7325F9EB4E0038ADDC /* ContentWarningOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentWarningOverlayView.swift; sourceTree = ""; }; - 2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = ""; }; - 2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Status.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Status.swift"; sourceTree = ""; }; 2D6DE3FF26141DF600A63F6A /* SearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewModel.swift; sourceTree = ""; }; - 2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineViewController.swift; sourceTree = ""; }; - 2D76316A25C14D4C00929FB9 /* PublicTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineViewModel.swift; sourceTree = ""; }; - 2D76317C25C14DF400929FB9 /* PublicTimelineViewController+Provider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewController+Provider.swift"; sourceTree = ""; }; - 2D76318225C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewModel+Diffable.swift"; sourceTree = ""; }; 2D76319E25C1521200929FB9 /* StatusSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusSection.swift; sourceTree = ""; }; 2D7631A725C1535600929FB9 /* StatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableViewCell.swift; sourceTree = ""; }; - 2D7631B225C159F700929FB9 /* Item.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Item.swift; sourceTree = ""; }; 2D7867182625B77500211898 /* NotificationItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationItem.swift; sourceTree = ""; }; - 2D79E700261EA5550011E398 /* APIService+CoreData+Tag.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Tag.swift"; sourceTree = ""; }; 2D82B9FE25E7863200E36F0F /* OnboardingViewControllerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewControllerAppearance.swift; sourceTree = ""; }; 2D82BA0425E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonResendEmailViewModelNavigationDelegateShim.swift; sourceTree = ""; }; 2D8434F425FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineNavigationBarTitleViewModel.swift; sourceTree = ""; }; 2D8434FA25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineNavigationBarTitleView.swift; sourceTree = ""; }; 2D84350425FF858100EECE90 /* UIScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIScrollView.swift; sourceTree = ""; }; 2D8FCA072637EABB00137F46 /* APIService+FollowRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+FollowRequest.swift"; sourceTree = ""; }; - 2D927F0125C7E4F2004F19B8 /* Mention.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mention.swift; sourceTree = ""; }; 2D927F0725C7E9A8004F19B8 /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = ""; }; 2D927F0D25C7E9C9004F19B8 /* History.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = History.swift; sourceTree = ""; }; 2D927F1325C7EDD9004F19B8 /* Emoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Emoji.swift; sourceTree = ""; }; @@ -895,16 +877,11 @@ 2DAC9E45262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAccountTableViewCell.swift; sourceTree = ""; }; 2DB72C8B262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Notification+Type.swift"; sourceTree = ""; }; 2DCB73FC2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRecommendCollectionHeader.swift; sourceTree = ""; }; - 2DE0FAC02615F04D00CDF649 /* RecommendHashTagSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecommendHashTagSection.swift; sourceTree = ""; }; - 2DE0FAC72615F5F000CDF649 /* SearchRecommendAccountsCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRecommendAccountsCollectionViewCell.swift; sourceTree = ""; }; 2DE0FACD2615F7AD00CDF649 /* RecommendAccountSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecommendAccountSection.swift; sourceTree = ""; }; 2DF123A625C3B0210020F248 /* ActiveLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveLabel.swift; sourceTree = ""; }; - 2DF75B9A25D0E27500694EC8 /* StatusProviderFacade.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusProviderFacade.swift; sourceTree = ""; }; - 2DF75BA025D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+StatusTableViewCellDelegate.swift"; sourceTree = ""; }; 2DF75BA625D10E1000694EC8 /* APIService+Favorite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Favorite.swift"; sourceTree = ""; }; 2DF75BB825D1474100694EC8 /* ManagedObjectObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedObjectObserver.swift; sourceTree = ""; }; 2DF75BC625D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedObjectContextObjectsDidChange.swift; sourceTree = ""; }; - 2DFAD5362617010500F9EE7C /* SearchResultTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultTableViewCell.swift; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; @@ -943,9 +920,7 @@ 5DF1054625F8870E00D6C0D4 /* VideoPlayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerViewModel.swift; sourceTree = ""; }; 5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AVPlayer.swift; sourceTree = ""; }; 5DF1057825F88A1D00D6C0D4 /* PlayerContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerContainerView.swift; sourceTree = ""; }; - 5DF1057E25F88A4100D6C0D4 /* TouchBlockingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TouchBlockingView.swift; sourceTree = ""; }; 5DF1058425F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NeedsDependency+AVPlayerViewControllerDelegate.swift"; sourceTree = ""; }; - 5DFC35DE262068D20045711D /* SearchViewController+Follow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewController+Follow.swift"; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; 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 = ""; }; @@ -974,6 +949,24 @@ DB0009A826AEE5DC009B9D2D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.intentdefinition; name = Base; path = Base.lproj/Intents.intentdefinition; sourceTree = ""; }; DB0009AD26AEE5E4009B9D2D /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Intents.strings; sourceTree = ""; }; DB0140CE25C42AEE00F9F3CF /* OSLog.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OSLog.swift; sourceTree = ""; }; + DB023D25279FFB0A005AC798 /* ShareActivityProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareActivityProvider.swift; sourceTree = ""; }; + DB023D2727A0FABD005AC798 /* NotificationTableViewCellDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationTableViewCellDelegate.swift; sourceTree = ""; }; + DB023D2927A0FE5C005AC798 /* DataSourceProvider+NotificationTableViewCellDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceProvider+NotificationTableViewCellDelegate.swift"; sourceTree = ""; }; + DB023D2B27A10464005AC798 /* NotificationTimelineViewController+DataSourceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationTimelineViewController+DataSourceProvider.swift"; sourceTree = ""; }; + DB025B77278D606A002F581E /* StatusItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusItem.swift; sourceTree = ""; }; + DB025B79278D6138002F581E /* CoreData 3.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "CoreData 3.xcdatamodel"; sourceTree = ""; }; + DB025B7D278D6247002F581E /* AutoGenerateProperty.stencil */ = {isa = PBXFileReference; lastKnownFileType = text; path = AutoGenerateProperty.stencil; sourceTree = ""; }; + DB025B7E278D6247002F581E /* AutoUpdatableObject.stencil */ = {isa = PBXFileReference; lastKnownFileType = text; path = AutoUpdatableObject.stencil; sourceTree = ""; }; + DB025B7F278D6247002F581E /* AutoGenerateRelationship.stencil */ = {isa = PBXFileReference; lastKnownFileType = text; path = AutoGenerateRelationship.stencil; sourceTree = ""; }; + DB025B81278D6271002F581E /* AutoGenerateProperty.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AutoGenerateProperty.swift; sourceTree = ""; }; + DB025B82278D6272002F581E /* AutoUpdatableObject.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AutoUpdatableObject.swift; sourceTree = ""; }; + DB025B83278D6272002F581E /* AutoGenerateRelationship.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AutoGenerateRelationship.swift; sourceTree = ""; }; + DB025B88278D6339002F581E /* Feed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Feed.swift; sourceTree = ""; }; + DB025B8B278D6374002F581E /* Acct.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Acct.swift; sourceTree = ""; }; + DB025B8F278D6489002F581E /* Feed+Kind.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Feed+Kind.swift"; sourceTree = ""; }; + DB025B92278D6501002F581E /* Persistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Persistence.swift; sourceTree = ""; }; + DB025B94278D6530002F581E /* Persistence+MastodonUser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Persistence+MastodonUser.swift"; sourceTree = ""; }; + DB025B96278D66D5002F581E /* MastodonUser+Property.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonUser+Property.swift"; sourceTree = ""; }; DB029E94266A20430062874E /* MastodonAuthenticationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonAuthenticationController.swift; sourceTree = ""; }; DB02CDAA26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadReplyLoaderTableViewCell.swift; sourceTree = ""; }; DB02CDBE2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptiveUserInterfaceStyleBarButtonItem.swift; sourceTree = ""; }; @@ -996,24 +989,50 @@ DB0618092785B2AB0030EE79 /* MastodonRegisterAvatarTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterAvatarTableViewCell.swift; sourceTree = ""; }; DB084B5625CBC56C00F898ED /* Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Status.swift; sourceTree = ""; }; DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Instance.swift"; sourceTree = ""; }; - DB0C946A26A700AB0088FB11 /* MastodonUser+Property.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonUser+Property.swift"; sourceTree = ""; }; - DB0C946E26A7D2A80088FB11 /* AvatarImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarImageView.swift; sourceTree = ""; }; - DB0C947126A7D2D70088FB11 /* AvatarButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarButton.swift; sourceTree = ""; }; DB0C947626A7FE840088FB11 /* NotificationAvatarButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationAvatarButton.swift; sourceTree = ""; }; - DB0E91E926A9675100BD2ACC /* MetaLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetaLabel.swift; sourceTree = ""; }; DB0EF72A26FDB1D200347686 /* SidebarListCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarListCollectionViewCell.swift; sourceTree = ""; }; DB0EF72D26FDB24F00347686 /* SidebarListContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarListContentView.swift; sourceTree = ""; }; - DB0F814D264CFFD300F2A12B /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = ""; }; DB0F814E264CFFD300F2A12B /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/InfoPlist.strings; sourceTree = ""; }; DB0F814F264D1E2500F2A12B /* PickServerLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerLoaderTableViewCell.swift; sourceTree = ""; }; + DB0FCB67279507EF006C02E2 /* DataSourceFacade+Meta.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Meta.swift"; sourceTree = ""; }; + DB0FCB6927950CB3006C02E2 /* MastodonMention.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonMention.swift; sourceTree = ""; }; + DB0FCB6B27950E29006C02E2 /* MastodonMentionContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonMentionContainer.swift; sourceTree = ""; }; + DB0FCB6D27950E6B006C02E2 /* MastodonMention.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonMention.swift; sourceTree = ""; }; + DB0FCB6F27951368006C02E2 /* TimelineMiddleLoaderTableViewCell+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TimelineMiddleLoaderTableViewCell+ViewModel.swift"; sourceTree = ""; }; + DB0FCB7127952986006C02E2 /* NamingState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NamingState.swift; sourceTree = ""; }; + DB0FCB7327956939006C02E2 /* DataSourceFacade+Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Status.swift"; sourceTree = ""; }; + DB0FCB75279571C5006C02E2 /* ThreadViewController+DataSourceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ThreadViewController+DataSourceProvider.swift"; sourceTree = ""; }; + DB0FCB7727957678006C02E2 /* DataSourceProvider+UITableViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceProvider+UITableViewDelegate.swift"; sourceTree = ""; }; + DB0FCB79279576A2006C02E2 /* DataSourceFacade+Thread.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Thread.swift"; sourceTree = ""; }; + DB0FCB7B2795821F006C02E2 /* StatusThreadRootTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusThreadRootTableViewCell.swift; sourceTree = ""; }; + DB0FCB7D27958957006C02E2 /* StatusThreadRootTableViewCell+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusThreadRootTableViewCell+ViewModel.swift"; sourceTree = ""; }; + DB0FCB7F27968F70006C02E2 /* MastodonStatusThreadViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonStatusThreadViewModel.swift; sourceTree = ""; }; + DB0FCB812796AC78006C02E2 /* UserTimelineViewController+DataSourceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserTimelineViewController+DataSourceProvider.swift"; sourceTree = ""; }; + DB0FCB832796B2A2006C02E2 /* FavoriteViewController+DataSourceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FavoriteViewController+DataSourceProvider.swift"; sourceTree = ""; }; + DB0FCB852796BDA1006C02E2 /* SearchSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchSection.swift; sourceTree = ""; }; + DB0FCB872796BDA9006C02E2 /* SearchItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchItem.swift; sourceTree = ""; }; + DB0FCB8B2796BF8D006C02E2 /* SearchViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewModel+Diffable.swift"; sourceTree = ""; }; + DB0FCB8D2796C0B7006C02E2 /* TrendCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendCollectionViewCell.swift; sourceTree = ""; }; + DB0FCB8F2796C5EB006C02E2 /* APIService+Trend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Trend.swift"; sourceTree = ""; }; + DB0FCB912796DE19006C02E2 /* TrendSectionHeaderCollectionReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendSectionHeaderCollectionReusableView.swift; sourceTree = ""; }; + DB0FCB932797E2B0006C02E2 /* SearchResultViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchResultViewModel+Diffable.swift"; sourceTree = ""; }; + DB0FCB952797E6C2006C02E2 /* SearchResultViewController+DataSourceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchResultViewController+DataSourceProvider.swift"; sourceTree = ""; }; + DB0FCB972797F6BF006C02E2 /* UserTableViewCell+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserTableViewCell+ViewModel.swift"; sourceTree = ""; }; + DB0FCB992797F7AD006C02E2 /* UserView+Configuration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserView+Configuration.swift"; sourceTree = ""; }; + DB0FCB9B27980AB6006C02E2 /* HashtagTimelineViewController+DataSourceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewController+DataSourceProvider.swift"; sourceTree = ""; }; DB118A8125E4B6E600FAB162 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + DB126A4C278C063F005726EE /* eu-ES */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "eu-ES"; path = "eu-ES.lproj/Intents.strings"; sourceTree = ""; }; + DB126A4F278C063F005726EE /* eu-ES */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "eu-ES"; path = "eu-ES.lproj/InfoPlist.strings"; sourceTree = ""; }; + DB126A50278C063F005726EE /* eu-ES */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "eu-ES"; path = "eu-ES.lproj/Intents.stringsdict"; sourceTree = ""; }; + DB126A56278C088D005726EE /* sv-FI */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "sv-FI"; path = "sv-FI.lproj/Intents.strings"; sourceTree = ""; }; + DB126A59278C088D005726EE /* sv-FI */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "sv-FI"; path = "sv-FI.lproj/InfoPlist.strings"; sourceTree = ""; }; + DB126A5A278C088D005726EE /* sv-FI */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "sv-FI"; path = "sv-FI.lproj/Intents.stringsdict"; sourceTree = ""; }; + DB159C2A27A17BAC0068DC77 /* DataSourceFacade+Media.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Media.swift"; sourceTree = ""; }; DB1D186B25EF5BA7003F1F23 /* PollTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollTableView.swift; sourceTree = ""; }; DB1D61CE26F1B33600DA8662 /* WelcomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeViewModel.swift; sourceTree = ""; }; - DB1D842B26551A1C000346B3 /* StatusProvider+StatusTableViewKeyCommandNavigateable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+StatusTableViewKeyCommandNavigateable.swift"; sourceTree = ""; }; DB1D842D26552C4D000346B3 /* StatusTableViewControllerNavigateable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableViewControllerNavigateable.swift; sourceTree = ""; }; DB1D842F26566512000346B3 /* KeyboardPreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardPreference.swift; sourceTree = ""; }; DB1D843326579931000346B3 /* TableViewControllerNavigateable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewControllerNavigateable.swift; sourceTree = ""; }; - DB1D843526579DB5000346B3 /* StatusProvider+TableViewControllerNavigateable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+TableViewControllerNavigateable.swift"; sourceTree = ""; }; DB1D84372657B275000346B3 /* SegmentedControlNavigateable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentedControlNavigateable.swift; sourceTree = ""; }; DB1E346725F518E20079D7DF /* CategoryPickerSection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CategoryPickerSection.swift; sourceTree = ""; }; DB1E347725F519300079D7DF /* PickServerItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PickServerItem.swift; sourceTree = ""; }; @@ -1027,8 +1046,26 @@ DB2B3ABD25E37E15007045F9 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = ""; }; DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthenticationViewModel.swift; sourceTree = ""; }; DB2FF50F260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollExpiresOptionCollectionViewCell.swift; sourceTree = ""; }; + DB336F1D278D6C3A0031E64B /* MastodonEmoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonEmoji.swift; sourceTree = ""; }; + DB336F20278D6D960031E64B /* MastodonEmoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonEmoji.swift; sourceTree = ""; }; + DB336F22278D6DED0031E64B /* MastodonEmojiContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonEmojiContainer.swift; sourceTree = ""; }; + DB336F25278D6E8F0031E64B /* MastodonField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonField.swift; sourceTree = ""; }; + DB336F27278D6EC70031E64B /* MastodonFieldContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonFieldContainer.swift; sourceTree = ""; }; + DB336F29278D6F2B0031E64B /* MastodonField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonField.swift; sourceTree = ""; }; + DB336F2B278D6FC30031E64B /* Persistence+Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Persistence+Status.swift"; sourceTree = ""; }; + DB336F2D278D71AF0031E64B /* Status+Property.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Status+Property.swift"; sourceTree = ""; }; + DB336F2F278D723D0031E64B /* MastodonVisibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonVisibility.swift; sourceTree = ""; }; + DB336F31278D77330031E64B /* Persistence+Poll.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Persistence+Poll.swift"; sourceTree = ""; }; + DB336F33278D77730031E64B /* Persistence+PollOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Persistence+PollOption.swift"; sourceTree = ""; }; + DB336F35278D77A40031E64B /* PollOption+Property.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PollOption+Property.swift"; sourceTree = ""; }; + DB336F37278D7AAF0031E64B /* Poll+Property.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Poll+Property.swift"; sourceTree = ""; }; + DB336F39278D7D1F0031E64B /* ManagedObjectRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedObjectRecord.swift; sourceTree = ""; }; + DB336F3C278D80040031E64B /* FeedFetchedResultsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedFetchedResultsController.swift; sourceTree = ""; }; + DB336F3E278E668C0031E64B /* StatusTableViewCell+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusTableViewCell+ViewModel.swift"; sourceTree = ""; }; + DB336F40278E68480031E64B /* StatusView+Configuration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusView+Configuration.swift"; sourceTree = ""; }; + DB336F42278EB1680031E64B /* MediaView+Configuration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MediaView+Configuration.swift"; sourceTree = ""; }; + DB336F44278EB1D70031E64B /* MastodonAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonAttachment.swift; sourceTree = ""; }; DB35FC1E2612F1D9006193C9 /* ProfileRelationshipActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileRelationshipActionButton.swift; sourceTree = ""; }; - DB35FC242612FD7A006193C9 /* ProfileFieldView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldView.swift; sourceTree = ""; }; DB36679C268AB91B0027D07F /* ComposeStatusAttachmentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusAttachmentTableViewCell.swift; sourceTree = ""; }; DB36679E268ABAF20027D07F /* ComposeStatusAttachmentSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusAttachmentSection.swift; sourceTree = ""; }; DB3667A0268ABB2E0027D07F /* ComposeStatusAttachmentItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusAttachmentItem.swift; sourceTree = ""; }; @@ -1036,7 +1073,6 @@ DB3667A5268AE2620027D07F /* ComposeStatusPollSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollSection.swift; sourceTree = ""; }; DB3667A7268AE2900027D07F /* ComposeStatusPollItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollItem.swift; sourceTree = ""; }; DB3D0FED25BAA42200EAA174 /* MastodonSDK */ = {isa = PBXFileReference; lastKnownFileType = folder; path = MastodonSDK; sourceTree = ""; }; - DB3D100E25BAA75E00EAA174 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 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 = ""; }; DB427DD725BAA00100D1B89D /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; @@ -1059,11 +1095,8 @@ DB4481AC25EE155900BEFB67 /* Poll.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Poll.swift; sourceTree = ""; }; DB4481B225EE16D000BEFB67 /* PollOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOption.swift; sourceTree = ""; }; DB4481B825EE289600BEFB67 /* UITableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UITableView.swift; sourceTree = ""; }; - DB4481C525EE2ADA00BEFB67 /* PollSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollSection.swift; sourceTree = ""; }; - DB4481CB25EE2AFE00BEFB67 /* PollItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollItem.swift; sourceTree = ""; }; DB45FAB525CA5485005A8AC7 /* UIAlertController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIAlertController.swift; sourceTree = ""; }; DB45FAD625CA6C76005A8AC7 /* UIBarButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIBarButtonItem.swift; sourceTree = ""; }; - DB45FADC25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+MastodonUser.swift"; sourceTree = ""; }; DB45FAE225CA7181005A8AC7 /* MastodonUser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonUser.swift; sourceTree = ""; }; DB45FAEC25CA7A9A005A8AC7 /* MastodonAuthentication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonAuthentication.swift; sourceTree = ""; }; DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+MastodonAuthentication.swift"; sourceTree = ""; }; @@ -1071,11 +1104,9 @@ DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+HomeTimeline.swift"; sourceTree = ""; }; DB47229625F9EFAD00DA7F53 /* NSManagedObjectContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSManagedObjectContext.swift; sourceTree = ""; }; DB482A3E261331E8008AE74C /* UserTimelineViewModel+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserTimelineViewModel+State.swift"; sourceTree = ""; }; - DB482A44261335BA008AE74C /* UserTimelineViewController+Provider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserTimelineViewController+Provider.swift"; sourceTree = ""; }; DB482A4A261340A7008AE74C /* APIService+UserTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+UserTimeline.swift"; sourceTree = ""; }; DB4924E126312AB200E9DB22 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; DB4932B026F1FB5300EF46D4 /* WizardCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WizardCardView.swift; sourceTree = ""; }; - DB4932B226F2054200EF46D4 /* CircleAvatarButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircleAvatarButton.swift; sourceTree = ""; }; DB4932B826F31AD300EF46D4 /* BadgeButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeButton.swift; sourceTree = ""; }; DB49A61325FF2C5600B98345 /* EmojiService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiService.swift; sourceTree = ""; }; DB49A61E25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EmojiService+CustomEmojiViewModel.swift"; sourceTree = ""; }; @@ -1083,13 +1114,9 @@ DB49A62A25FF36C700B98345 /* APIService+CustomEmoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+CustomEmoji.swift"; sourceTree = ""; }; DB49A63C25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PlayerContainerView+MediaTypeIndicotorView.swift"; sourceTree = ""; }; DB4B777F26CA4EFA00B087B3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Intents.strings; sourceTree = ""; }; - DB4B778026CA4EFA00B087B3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ru; path = ru.lproj/Localizable.stringsdict; sourceTree = ""; }; - DB4B778126CA4EFA00B087B3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; DB4B778226CA4EFA00B087B3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/InfoPlist.strings; sourceTree = ""; }; DB4B778326CA4EFA00B087B3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ru; path = ru.lproj/Intents.stringsdict; sourceTree = ""; }; DB4B778426CA500E00B087B3 /* gd-GB */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "gd-GB"; path = "gd-GB.lproj/Intents.strings"; sourceTree = ""; }; - DB4B778526CA500E00B087B3 /* gd-GB */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "gd-GB"; path = "gd-GB.lproj/Localizable.stringsdict"; sourceTree = ""; }; - DB4B778626CA500E00B087B3 /* gd-GB */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "gd-GB"; path = "gd-GB.lproj/Localizable.strings"; sourceTree = ""; }; DB4B778726CA500E00B087B3 /* gd-GB */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "gd-GB"; path = "gd-GB.lproj/InfoPlist.strings"; sourceTree = ""; }; DB4B778826CA500E00B087B3 /* gd-GB */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "gd-GB"; path = "gd-GB.lproj/Intents.stringsdict"; sourceTree = ""; }; DB4B778926CA504100B087B3 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = es; path = es.lproj/Intents.stringsdict; sourceTree = ""; }; @@ -1102,15 +1129,12 @@ DB4B779026CA504900B087B3 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = fr; path = fr.lproj/Intents.stringsdict; sourceTree = ""; }; DB4B779126CA504A00B087B3 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ja; path = ja.lproj/Intents.stringsdict; sourceTree = ""; }; DB4B779226CA50BA00B087B3 /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/Intents.strings; sourceTree = ""; }; - DB4B779326CA50BA00B087B3 /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = th; path = th.lproj/Localizable.stringsdict; sourceTree = ""; }; - DB4B779426CA50BA00B087B3 /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/Localizable.strings; sourceTree = ""; }; DB4B779526CA50BA00B087B3 /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/InfoPlist.strings; sourceTree = ""; }; DB4B779626CA50BA00B087B3 /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = th; path = th.lproj/Intents.stringsdict; sourceTree = ""; }; DB4F0962269ED06300D62E92 /* SearchResultViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultViewController.swift; sourceTree = ""; }; DB4F0965269ED52200D62E92 /* SearchResultViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultViewModel.swift; sourceTree = ""; }; DB4F0967269ED8AD00D62E92 /* SearchHistoryTableHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistoryTableHeaderView.swift; sourceTree = ""; }; DB4F0969269EDAD200D62E92 /* SearchResultViewModel+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchResultViewModel+State.swift"; sourceTree = ""; }; - DB4F096B269EFA2000D62E92 /* SearchResultViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchResultViewController+StatusProvider.swift"; sourceTree = ""; }; DB4F097426A037F500D62E92 /* SearchHistoryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistoryViewModel.swift; sourceTree = ""; }; DB4F097A26A039FF00D62E92 /* SearchHistorySection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistorySection.swift; sourceTree = ""; }; DB4F097C26A03A5B00D62E92 /* SearchHistoryItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistoryItem.swift; sourceTree = ""; }; @@ -1119,18 +1143,14 @@ DB4FFC2A269EC39600D62E92 /* SearchTransitionController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchTransitionController.swift; sourceTree = ""; }; DB51D170262832380062B7A1 /* BlurHashDecode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BlurHashDecode.swift; sourceTree = ""; }; DB51D171262832380062B7A1 /* BlurHashEncode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BlurHashEncode.swift; sourceTree = ""; }; - DB564BCF269F2F83001E39A7 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ar; path = ar.lproj/Localizable.stringsdict; sourceTree = ""; }; - DB564BD1269F2F8A001E39A7 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = en; path = en.lproj/Localizable.stringsdict; sourceTree = ""; }; DB564BD2269F3B35001E39A7 /* StatusFilterService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusFilterService.swift; sourceTree = ""; }; - DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+UITableViewDelegate.swift"; sourceTree = ""; }; - DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewCellHeightCacheableContainer.swift; sourceTree = ""; }; DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Poll.swift"; sourceTree = ""; }; - DB59F11725EFA35B001F1DAB /* StripProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StripProgressView.swift; sourceTree = ""; }; DB5B7294273112B100081888 /* FollowingListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowingListViewController.swift; sourceTree = ""; }; DB5B7297273112C800081888 /* FollowingListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowingListViewModel.swift; sourceTree = ""; }; - DB5B72992731137900081888 /* FollowingListViewController+Provider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FollowingListViewController+Provider.swift"; sourceTree = ""; }; DB5B729B273113C200081888 /* FollowingListViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FollowingListViewModel+Diffable.swift"; sourceTree = ""; }; DB5B729D273113F300081888 /* FollowingListViewModel+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FollowingListViewModel+State.swift"; sourceTree = ""; }; + DB603110279EB38500A935FE /* DataSourceFacade+Mute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Mute.swift"; sourceTree = ""; }; + DB603112279EBEBA00A935FE /* DataSourceFacade+Block.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Block.swift"; sourceTree = ""; }; DB6180DC263918E30018D199 /* MediaPreviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewViewController.swift; sourceTree = ""; }; DB6180DF2639194B0018D199 /* MediaPreviewPagingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewPagingViewController.swift; sourceTree = ""; }; DB6180E226391A4C0018D199 /* ViewControllerAnimatedTransitioning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewControllerAnimatedTransitioning.swift; sourceTree = ""; }; @@ -1144,11 +1164,40 @@ DB6180F526391D580018D199 /* MediaPreviewableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewableViewController.swift; sourceTree = ""; }; DB6180F726391D660018D199 /* MediaPreviewingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewingViewController.swift; sourceTree = ""; }; DB6180F926391F2E0018D199 /* MediaPreviewViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewViewModel.swift; sourceTree = ""; }; - DB63BE7E268DD1070011D3F9 /* NotificationViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationViewController+StatusProvider.swift"; sourceTree = ""; }; + DB63F7442799056400455B82 /* HashtagTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTableViewCell.swift; sourceTree = ""; }; + DB63F74627990B0600455B82 /* DataSourceFacade+Hashtag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Hashtag.swift"; sourceTree = ""; }; + DB63F7482799126300455B82 /* FollowerListViewController+DataSourceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FollowerListViewController+DataSourceProvider.swift"; sourceTree = ""; }; + DB63F74A279914A000455B82 /* FollowingListViewController+DataSourceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FollowingListViewController+DataSourceProvider.swift"; sourceTree = ""; }; + DB63F74C27993F5B00455B82 /* SearchHistoryUserCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistoryUserCollectionViewCell.swift; sourceTree = ""; }; + DB63F74E2799405600455B82 /* SearchHistoryViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchHistoryViewModel+Diffable.swift"; sourceTree = ""; }; + DB63F751279944AA00455B82 /* SearchHistorySectionHeaderCollectionReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistorySectionHeaderCollectionReusableView.swift; sourceTree = ""; }; + DB63F7532799491600455B82 /* DataSourceFacade+SearchHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+SearchHistory.swift"; sourceTree = ""; }; + DB63F755279949BD00455B82 /* Persistence+SearchHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Persistence+SearchHistory.swift"; sourceTree = ""; }; + DB63F759279953F200455B82 /* SearchHistoryUserCollectionViewCell+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchHistoryUserCollectionViewCell+ViewModel.swift"; sourceTree = ""; }; + DB63F75B279956D000455B82 /* Persistence+Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Persistence+Tag.swift"; sourceTree = ""; }; + DB63F75D27995B3B00455B82 /* Tag+Property.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Tag+Property.swift"; sourceTree = ""; }; + DB63F75F27995ECE00455B82 /* MastodonTagHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonTagHistory.swift; sourceTree = ""; }; + DB63F76127996B6600455B82 /* SearchHistoryViewController+DataSourceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchHistoryViewController+DataSourceProvider.swift"; sourceTree = ""; }; + DB63F763279A5E3C00455B82 /* NotificationTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationTimelineViewController.swift; sourceTree = ""; }; + DB63F766279A5EB300455B82 /* NotificationTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationTimelineViewModel.swift; sourceTree = ""; }; + DB63F768279A5EBB00455B82 /* NotificationTimelineViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationTimelineViewModel+Diffable.swift"; sourceTree = ""; }; + DB63F76A279A5ED300455B82 /* NotificationTimelineViewModel+LoadOldestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationTimelineViewModel+LoadOldestState.swift"; sourceTree = ""; }; + DB63F76C279A67BD00455B82 /* MastodonNotificationType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonNotificationType.swift; sourceTree = ""; }; + DB63F76E279A7D1100455B82 /* NotificationTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationTableViewCell.swift; sourceTree = ""; }; + DB63F770279A858500455B82 /* Persistence+Notification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Persistence+Notification.swift"; sourceTree = ""; }; + DB63F772279A87DC00455B82 /* Notification+Property.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Notification+Property.swift"; sourceTree = ""; }; + DB63F774279A997D00455B82 /* NotificationTableViewCell+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationTableViewCell+ViewModel.swift"; sourceTree = ""; }; + DB63F776279A9A2A00455B82 /* NotificationView+Configuration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationView+Configuration.swift"; sourceTree = ""; }; + DB63F778279ABF9C00455B82 /* DataSourceFacade+Reblog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Reblog.swift"; sourceTree = ""; }; + DB63F77A279ACAE500455B82 /* DataSourceFacade+Favorite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Favorite.swift"; sourceTree = ""; }; DB647C5826F1EA2700F7F82C /* WizardPreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WizardPreference.swift; sourceTree = ""; }; + DB65C63627A2AF6C008BAC2E /* ReportItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportItem.swift; sourceTree = ""; }; DB66728B25F9F8DC00D60309 /* ComposeViewModel+DataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ComposeViewModel+DataSource.swift"; sourceTree = ""; }; DB66729525F9F91600D60309 /* ComposeStatusSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusSection.swift; sourceTree = ""; }; DB66729B25F9F91F00D60309 /* ComposeStatusItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusItem.swift; sourceTree = ""; }; + DB6746EA278ED8B0008A6B94 /* PollOptionView+Configuration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PollOptionView+Configuration.swift"; sourceTree = ""; }; + DB6746EC278F45F0008A6B94 /* AutoGenerateProtocolRelayDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoGenerateProtocolRelayDelegate.swift; sourceTree = ""; }; + DB6746EF278F463B008A6B94 /* AutoGenerateProtocolDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoGenerateProtocolDelegate.swift; sourceTree = ""; }; DB67D08327312970006A36CF /* APIService+Following.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Following.swift"; sourceTree = ""; }; DB67D08527312E67006A36CF /* WizardViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WizardViewController.swift; sourceTree = ""; }; DB67D088273256D7006A36CF /* StoreReviewPreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreReviewPreference.swift; sourceTree = ""; }; @@ -1164,18 +1213,24 @@ DB68A04925E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AdaptiveStatusBarStyleNavigationController.swift; sourceTree = ""; }; DB68A05C25E9055900CFDF14 /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = ""; }; DB68A06225E905E000CFDF14 /* UIApplication.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIApplication.swift; sourceTree = ""; }; + DB697DD0278F4871004EF2F7 /* AutoGenerateTableViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoGenerateTableViewDelegate.swift; sourceTree = ""; }; + DB697DD3278F4927004EF2F7 /* StatusTableViewCellDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableViewCellDelegate.swift; sourceTree = ""; }; + DB697DD5278F4C29004EF2F7 /* DataSourceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataSourceProvider.swift; sourceTree = ""; }; + DB697DD8278F4CED004EF2F7 /* HomeTimelineViewController+DataSourceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeTimelineViewController+DataSourceProvider.swift"; sourceTree = ""; }; + DB697DDA278F4DE3004EF2F7 /* DataSourceProvider+StatusTableViewCellDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceProvider+StatusTableViewCellDelegate.swift"; sourceTree = ""; }; + DB697DDC278F521D004EF2F7 /* DataSourceFacade.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataSourceFacade.swift; sourceTree = ""; }; + DB697DDE278F524F004EF2F7 /* DataSourceFacade+Profile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Profile.swift"; sourceTree = ""; }; + DB697DE0278F5296004EF2F7 /* DataSourceFacade+Model.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Model.swift"; sourceTree = ""; }; DB6B35172601FA3400DC1E11 /* MastodonAttachmentService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonAttachmentService.swift; sourceTree = ""; }; DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusAttachmentCollectionViewCell.swift; sourceTree = ""; }; DB6B74EE272FB55000C70B6E /* FollowerListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowerListViewController.swift; sourceTree = ""; }; DB6B74F1272FB67600C70B6E /* FollowerListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowerListViewModel.swift; sourceTree = ""; }; DB6B74F3272FBAE700C70B6E /* FollowerListViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FollowerListViewModel+Diffable.swift"; sourceTree = ""; }; DB6B74F5272FBCDB00C70B6E /* FollowerListViewModel+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FollowerListViewModel+State.swift"; sourceTree = ""; }; - DB6B74F7272FBFB100C70B6E /* FollowerListViewController+Provider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FollowerListViewController+Provider.swift"; sourceTree = ""; }; DB6B74F9272FC2B500C70B6E /* APIService+Follower.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Follower.swift"; sourceTree = ""; }; DB6B74FB272FF55800C70B6E /* UserSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSection.swift; sourceTree = ""; }; DB6B74FD272FF59000C70B6E /* UserItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserItem.swift; sourceTree = ""; }; DB6B74FF272FF73800C70B6E /* UserTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserTableViewCell.swift; sourceTree = ""; }; - DB6B75012730060700C70B6E /* UserProviderFacade+UITableViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserProviderFacade+UITableViewDelegate.swift"; sourceTree = ""; }; DB6B750327300B4000C70B6E /* TimelineFooterTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineFooterTableViewCell.swift; sourceTree = ""; }; DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error.swift"; sourceTree = ""; }; DB6D1B3C2636857500ACB481 /* AppearancePreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearancePreference.swift; sourceTree = ""; }; @@ -1194,11 +1249,6 @@ DB6F5E37264E994A009108F4 /* AutoCompleteTopChevronView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCompleteTopChevronView.swift; sourceTree = ""; }; DB71C7CA271D5A0300BE3819 /* LineChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LineChartView.swift; sourceTree = ""; }; DB71C7CC271D7F4300BE3819 /* CurveAlgorithm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurveAlgorithm.swift; sourceTree = ""; }; - DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarStackContainerButton.swift; sourceTree = ""; }; - DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+PersistMemo.swift"; sourceTree = ""; }; - DB71FD3B25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+PersistCache.swift"; sourceTree = ""; }; - DB71FD4525F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+UITableViewDataSourcePrefetching.swift"; sourceTree = ""; }; - DB71FD4B25F8C80E00512AE1 /* StatusPrefetchingService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusPrefetchingService.swift; sourceTree = ""; }; DB71FD5125F8CCAA00512AE1 /* APIService+Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Status.swift"; sourceTree = ""; }; DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewController.swift; sourceTree = ""; }; DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewModel.swift; sourceTree = ""; }; @@ -1222,9 +1272,7 @@ DB852D1B26FB021500FC9D81 /* RootSplitViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootSplitViewController.swift; sourceTree = ""; }; DB852D1E26FB037800FC9D81 /* SidebarViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarViewModel.swift; sourceTree = ""; }; DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollOptionCollectionViewCell.swift; sourceTree = ""; }; - DB87D44A2609C11900D12C0D /* PollOptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionView.swift; sourceTree = ""; }; DB87D4502609CF1E00D12C0D /* ComposeStatusPollOptionAppendEntryCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollOptionAppendEntryCollectionViewCell.swift; sourceTree = ""; }; - DB87D4562609DD5300D12C0D /* DeleteBackwardResponseTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteBackwardResponseTextField.swift; sourceTree = ""; }; 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 = ""; }; DB89B9F125C10FD0008580ED /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -1249,6 +1297,7 @@ DB8AF54F25C13703002E6C99 /* MainTabBarController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MainTabBarController.swift; sourceTree = ""; }; DB8AF55C25C138B7002E6C99 /* UIViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIViewController.swift; sourceTree = ""; }; DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineIndex.swift; sourceTree = ""; }; + DB8F7075279E954700E1225B /* DataSourceFacade+Follow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Follow.swift"; sourceTree = ""; }; 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; }; @@ -1256,7 +1305,6 @@ DB8FABCB26AEC7B2008E5AF4 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; DB8FABD626AEC864008E5AF4 /* MastodonIntent.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = MastodonIntent.entitlements; sourceTree = ""; }; DB9282B125F3222800823B15 /* PickServerEmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerEmptyStateView.swift; sourceTree = ""; }; - DB92CF7125E7BB98002C1017 /* PollOptionTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionTableViewCell.swift; sourceTree = ""; }; DB938EE52623F50700E5B6C1 /* ThreadViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadViewController.swift; sourceTree = ""; }; DB938EEC2623F79B00E5B6C1 /* ThreadViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadViewModel.swift; sourceTree = ""; }; DB938F0226240EA300E5B6C1 /* CachedThreadViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CachedThreadViewModel.swift; sourceTree = ""; }; @@ -1264,14 +1312,10 @@ DB938F0E2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ThreadViewModel+LoadThreadState.swift"; sourceTree = ""; }; DB938F1426241FDF00E5B6C1 /* APIService+Thread.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Thread.swift"; sourceTree = ""; }; DB938F1E2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ThreadViewModel+Diffable.swift"; sourceTree = ""; }; - DB938F24262438D600E5B6C1 /* ThreadViewController+Provider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ThreadViewController+Provider.swift"; sourceTree = ""; }; DB938F3226243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineTopLoaderTableViewCell.swift; sourceTree = ""; }; - DB97131E2666078B00BD1E90 /* Date.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Date.swift; sourceTree = ""; }; DB98336A25C9420100AD9700 /* APIService+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+App.swift"; sourceTree = ""; }; DB98337025C9443200AD9700 /* APIService+Authentication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Authentication.swift"; sourceTree = ""; }; DB98337E25C9452D00AD9700 /* APIService+APIError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+APIError.swift"; sourceTree = ""; }; - DB98338525C945ED00AD9700 /* Strings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Strings.swift; sourceTree = ""; }; - DB98338625C945ED00AD9700 /* Assets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Assets.swift; sourceTree = ""; }; DB98339B25C96DE600AD9700 /* APIService+Account.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Account.swift"; sourceTree = ""; }; DB9A486B26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AttachmentContainerView+EmptyStateView.swift"; sourceTree = ""; }; DB9A488926034D40008B817C /* ComposeViewModel+PublishState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ComposeViewModel+PublishState.swift"; sourceTree = ""; }; @@ -1282,8 +1326,6 @@ DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewController.swift; sourceTree = ""; }; DB9D6C0D25E4F9780051B173 /* MosaicImageViewContainer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MosaicImageViewContainer.swift; sourceTree = ""; }; DB9D6C2225E502C60051B173 /* MosaicImageViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MosaicImageViewModel.swift; sourceTree = ""; }; - DB9D6C2D25E504AC0051B173 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = ""; }; - DB9D6C3725E508BE0051B173 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = ""; }; DB9D7C20269824B80054B3DF /* APIService+Filter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Filter.swift"; sourceTree = ""; }; DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIInterpolatingMotionEffect.swift; sourceTree = ""; }; DB9F58EB26EF435000E7BBE9 /* AccountViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountViewController.swift; sourceTree = ""; }; @@ -1291,36 +1333,21 @@ DB9F58F026EF512300E7BBE9 /* AccountListTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountListTableViewCell.swift; sourceTree = ""; }; DBA088DE26958164003EB4B2 /* UserFetchedResultsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserFetchedResultsController.swift; sourceTree = ""; }; DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeToolbarView.swift; sourceTree = ""; }; - DBA1DB7F268F84F80052DB59 /* NotificationType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationType.swift; sourceTree = ""; }; DBA465922696B495002B41DB /* APIService+WebFinger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+WebFinger.swift"; sourceTree = ""; }; DBA465942696E387002B41DB /* AppPreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPreference.swift; sourceTree = ""; }; DBA4B0D326BD10AC0077136E /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Intents.strings"; sourceTree = ""; }; - DBA4B0D426BD10AD0077136E /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.stringsdict"; sourceTree = ""; }; - DBA4B0D526BD10AD0077136E /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; DBA4B0D626BD10AD0077136E /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/InfoPlist.strings"; sourceTree = ""; }; DBA4B0D726BD10F40077136E /* ca */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ca; path = ca.lproj/Intents.strings; sourceTree = ""; }; - DBA4B0D826BD10F40077136E /* ca */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ca; path = ca.lproj/Localizable.stringsdict; sourceTree = ""; }; - DBA4B0D926BD10F40077136E /* ca */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ca; path = ca.lproj/Localizable.strings; sourceTree = ""; }; DBA4B0DA26BD10F40077136E /* ca */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ca; path = ca.lproj/InfoPlist.strings; sourceTree = ""; }; DBA4B0DB26BD11130077136E /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Intents.strings; sourceTree = ""; }; - DBA4B0DC26BD11130077136E /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = fr; path = fr.lproj/Localizable.stringsdict; sourceTree = ""; }; - DBA4B0DD26BD11130077136E /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; DBA4B0DE26BD11130077136E /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/InfoPlist.strings; sourceTree = ""; }; DBA4B0DF26BD11C70077136E /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Intents.strings; sourceTree = ""; }; - DBA4B0E026BD11C70077136E /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = es; path = es.lproj/Localizable.stringsdict; sourceTree = ""; }; - DBA4B0E126BD11C80077136E /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; DBA4B0E226BD11C80077136E /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/InfoPlist.strings; sourceTree = ""; }; DBA4B0E326BD11D10077136E /* es-419 */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "es-419"; path = "es-419.lproj/Intents.strings"; sourceTree = ""; }; - DBA4B0E426BD11D10077136E /* es-419 */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "es-419"; path = "es-419.lproj/Localizable.stringsdict"; sourceTree = ""; }; - DBA4B0E526BD11D10077136E /* es-419 */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "es-419"; path = "es-419.lproj/Localizable.strings"; sourceTree = ""; }; DBA4B0E626BD11D10077136E /* es-419 */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "es-419"; path = "es-419.lproj/InfoPlist.strings"; sourceTree = ""; }; DBA4B0E826C153820077136E /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Intents.strings; sourceTree = ""; }; - DBA4B0E926C153820077136E /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = de; path = de.lproj/Localizable.stringsdict; sourceTree = ""; }; - DBA4B0EA26C153820077136E /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; DBA4B0EB26C153820077136E /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/InfoPlist.strings; sourceTree = ""; }; DBA4B0EC26C153B10077136E /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Intents.strings; sourceTree = ""; }; - DBA4B0ED26C153B10077136E /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = nl; path = nl.lproj/Localizable.stringsdict; sourceTree = ""; }; - DBA4B0EE26C153B20077136E /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; DBA4B0EF26C153B20077136E /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/InfoPlist.strings; sourceTree = ""; }; DBA4B0F526C2621D0077136E /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Intents.strings; sourceTree = ""; }; DBA4B0F826C269880077136E /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = en; path = en.lproj/Intents.stringsdict; sourceTree = ""; }; @@ -1332,20 +1359,14 @@ DBA5E7AA263BD3F5004598BB /* TimelineTableViewCellContextMenuConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineTableViewCellContextMenuConfiguration.swift; sourceTree = ""; }; DBA94433265CBB5300C537E1 /* ProfileFieldSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldSection.swift; sourceTree = ""; }; DBA94435265CBB7400C537E1 /* ProfileFieldItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldItem.swift; sourceTree = ""; }; - DBA94437265CBD4D00C537E1 /* ProfileHeaderViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileHeaderViewModel+Diffable.swift"; sourceTree = ""; }; - DBA94439265CC0FC00C537E1 /* Fields.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Fields.swift; sourceTree = ""; }; DBA9443D265CFA6400C537E1 /* ProfileFieldCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldCollectionViewCell.swift; sourceTree = ""; }; DBA9443F265D137600C537E1 /* Mastodon+Entity+Field.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Field.swift"; sourceTree = ""; }; DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeIllustrationView.swift; sourceTree = ""; }; - DBAE3F672615DD60004B8251 /* UserProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProvider.swift; sourceTree = ""; }; - DBAE3F812615DDA3004B8251 /* ProfileViewController+UserProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileViewController+UserProvider.swift"; sourceTree = ""; }; - DBAE3F872615DDF4004B8251 /* UserProviderFacade.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProviderFacade.swift; sourceTree = ""; }; DBAE3F8D2616E0B1004B8251 /* APIService+Block.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Block.swift"; sourceTree = ""; }; DBAE3F932616E28B004B8251 /* APIService+Follow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Follow.swift"; sourceTree = ""; }; DBAE3F9D2616E308004B8251 /* APIService+Mute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Mute.swift"; sourceTree = ""; }; DBAE3FAE26172FC0004B8251 /* RemoteProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteProfileViewModel.swift; sourceTree = ""; }; DBAEDE5B267A058D00D25FF5 /* BlurhashImageCacheService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurhashImageCacheService.swift; sourceTree = ""; }; - DBAFB7342645463500371D5F /* Emojis.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Emojis.swift; sourceTree = ""; }; DBB3BA2926A81C020004F2D4 /* FLAnimatedImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FLAnimatedImageView.swift; sourceTree = ""; }; DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSegmentedViewController.swift; sourceTree = ""; }; DBB525202611EBD6002F1F29 /* ProfilePagingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePagingViewController.swift; sourceTree = ""; }; @@ -1363,8 +1384,6 @@ DBB9759B262462E1004620BD /* ThreadMetaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadMetaView.swift; sourceTree = ""; }; DBBC24A726A52F9000398BB9 /* ComposeToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeToolbarView.swift; sourceTree = ""; }; DBBC24AB26A53D9300398BB9 /* ComposeStatusContentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusContentTableViewCell.swift; sourceTree = ""; }; - DBBC24AD26A53DC100398BB9 /* ReplicaStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplicaStatusView.swift; sourceTree = ""; }; - DBBC24B426A540AE00398BB9 /* AvatarConfigurableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarConfigurableView.swift; sourceTree = ""; }; DBBC24BB26A542F500398BB9 /* ThemeService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeService.swift; sourceTree = ""; }; DBBC24BE26A5443100398BB9 /* MastodonTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonTheme.swift; sourceTree = ""; }; DBBC24BF26A5443100398BB9 /* SystemTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemTheme.swift; sourceTree = ""; }; @@ -1372,6 +1391,8 @@ DBBC24CE26A547AE00398BB9 /* ThemeService+Appearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ThemeService+Appearance.swift"; sourceTree = ""; }; DBBC24D626A54BCB00398BB9 /* MastodonRegex.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MastodonRegex.swift; sourceTree = ""; }; DBBC24D826A54BCB00398BB9 /* MastodonMetricFormatter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MastodonMetricFormatter.swift; sourceTree = ""; }; + DBBC50BE278ED0E700AF0CC6 /* Date.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Date.swift; sourceTree = ""; }; + DBBC50C0278ED49200AF0CC6 /* MastodonAuthenticationBox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonAuthenticationBox.swift; sourceTree = ""; }; DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPickServerAppearance.swift; sourceTree = ""; }; DBBF1DBE2652401B00E5B703 /* AutoCompleteViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCompleteViewModel.swift; sourceTree = ""; }; DBBF1DC126524D2900E5B703 /* AutoCompleteTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCompleteTableViewCell.swift; sourceTree = ""; }; @@ -1400,8 +1421,6 @@ DBD376B1269302A4007FEC24 /* UITableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITableViewCell.swift; sourceTree = ""; }; DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Onboarding.swift"; sourceTree = ""; }; DBDC1CF9272C0FD600055C3D /* ku-TR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "ku-TR"; path = "ku-TR.lproj/Intents.strings"; sourceTree = ""; }; - DBDC1CFA272C0FD600055C3D /* ku-TR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "ku-TR"; path = "ku-TR.lproj/Localizable.stringsdict"; sourceTree = ""; }; - DBDC1CFB272C0FD600055C3D /* ku-TR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "ku-TR"; path = "ku-TR.lproj/Localizable.strings"; sourceTree = ""; }; DBDC1CFC272C0FD600055C3D /* ku-TR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "ku-TR"; path = "ku-TR.lproj/InfoPlist.strings"; sourceTree = ""; }; DBDC1CFD272C0FD600055C3D /* ku-TR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "ku-TR"; path = "ku-TR.lproj/Intents.stringsdict"; sourceTree = ""; }; DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewController.swift; sourceTree = ""; }; @@ -1412,7 +1431,6 @@ DBE3CDFA261C6CA500430CC6 /* FavoriteViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteViewModel.swift; sourceTree = ""; }; DBE3CE00261D623D00430CC6 /* FavoriteViewModel+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FavoriteViewModel+State.swift"; sourceTree = ""; }; DBE3CE06261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FavoriteViewModel+Diffable.swift"; sourceTree = ""; }; - DBE3CE0C261D767100430CC6 /* FavoriteViewController+Provider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FavoriteViewController+Provider.swift"; sourceTree = ""; }; DBE3CE12261D7D4200430CC6 /* StatusTableViewControllerAspect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableViewControllerAspect.swift; sourceTree = ""; }; DBE54AC52636C89F004E7C0B /* NotificationPreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPreference.swift; sourceTree = ""; }; DBF156DD27006F5D00EC00B7 /* CoreData 2.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "CoreData 2.xcdatamodel"; sourceTree = ""; }; @@ -1434,6 +1452,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 = ""; }; DBF9814B265E339500E4BA07 /* ProfileFieldAddEntryCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldAddEntryCollectionViewCell.swift; sourceTree = ""; }; + DBFEEC95279BDC67004F81DD /* ProfileAboutViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileAboutViewController.swift; sourceTree = ""; }; + DBFEEC98279BDCDE004F81DD /* ProfileAboutViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileAboutViewModel.swift; sourceTree = ""; }; + DBFEEC9A279BDDD9004F81DD /* ProfileAboutViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileAboutViewModel+Diffable.swift"; sourceTree = ""; }; + DBFEEC9C279C12C1004F81DD /* ProfileFieldEditCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldEditCollectionViewCell.swift; sourceTree = ""; }; DBFEF05526A576EE006D7ED1 /* StatusEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusEditorView.swift; sourceTree = ""; }; DBFEF05626A576EE006D7ED1 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = ""; }; DBFEF05726A576EE006D7ED1 /* ComposeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewModel.swift; sourceTree = ""; }; @@ -1444,13 +1466,10 @@ DBFEF06726A58D07006D7ED1 /* ShareActionExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareActionExtension.entitlements; sourceTree = ""; }; DBFEF06C26A67FB7006D7ED1 /* StatusAttachmentViewModel+UploadState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusAttachmentViewModel+UploadState.swift"; sourceTree = ""; }; DBFEF07226A6913D006D7ED1 /* APIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIService.swift; sourceTree = ""; }; - DBFEF07626A691FB006D7ED1 /* MastodonAuthenticationBox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonAuthenticationBox.swift; sourceTree = ""; }; DBFEF07A26A6BCE8006D7ED1 /* APIService+Status+Publish.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Status+Publish.swift"; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; E9AABD3D26B64B8C00E237DA /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Intents.strings; sourceTree = ""; }; - E9AABD3E26B64B8D00E237DA /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ja; path = ja.lproj/Localizable.stringsdict; sourceTree = ""; }; - E9AABD3F26B64B8D00E237DA /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; E9AABD4026B64B8D00E237DA /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/InfoPlist.strings; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; @@ -1468,7 +1487,6 @@ 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 */, @@ -1549,7 +1567,6 @@ 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 */, @@ -1563,8 +1580,8 @@ 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 */, B914FC6B0B8AF18573C0B291 /* Pods_NotificationService.framework in Frameworks */, @@ -1578,12 +1595,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 = ""; @@ -1687,10 +1702,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 */, @@ -1698,21 +1716,23 @@ path = Content; sourceTree = ""; }; - 2D34D9E026149C550081BFC0 /* CollectionViewCell */ = { + 2D34D9E026149C550081BFC0 /* Cell */ = { isa = PBXGroup; children = ( - 2D34D9E126149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift */, - 2DE0FAC72615F5F000CDF649 /* SearchRecommendAccountsCollectionViewCell.swift */, + DB0FCB8D2796C0B7006C02E2 /* TrendCollectionViewCell.swift */, + DB0FCB912796DE19006C02E2 /* TrendSectionHeaderCollectionReusableView.swift */, ); - path = CollectionViewCell; + path = Cell; sourceTree = ""; }; - 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 = ""; }; 2D364F7025E66D5B00204FDC /* ResendEmail */ = { @@ -1730,46 +1750,19 @@ children = ( DB1F239626117C360057430E /* View */, 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 = ""; }; - 2D38F1FC25CD47D900561493 /* StatusProvider */ = { - isa = PBXGroup; - children = ( - 2D38F1FD25CD481700561493 /* StatusProvider.swift */, - 2DF75B9A25D0E27500694EC8 /* StatusProviderFacade.swift */, - 2DF75BA025D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift */, - DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift */, - DB71FD4525F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift */, - DB1D843526579DB5000346B3 /* StatusProvider+TableViewControllerNavigateable.swift */, - DB1D842B26551A1C000346B3 /* StatusProvider+StatusTableViewKeyCommandNavigateable.swift */, - ); - path = StatusProvider; - sourceTree = ""; - }; - 2D42FF7C25C82207004A627A /* ToolBar */ = { - isa = PBXGroup; - children = ( - 2D42FF7D25C82218004A627A /* ActionToolBarContainer.swift */, - ); - path = ToolBar; - sourceTree = ""; - }; 2D42FF8325C82245004A627A /* Button */ = { isa = PBXGroup; children = ( - DB0C947126A7D2D70088FB11 /* AvatarButton.swift */, - DB4932B226F2054200EF46D4 /* CircleAvatarButton.swift */, - DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */, - 2D42FF8425C8224F004A627A /* HitTestExpandedButton.swift */, 0FAA101125E105390017CCDE /* PrimaryActionButton.swift */, ); path = Button; @@ -1819,7 +1812,6 @@ 2D206B8B25F6015000143C56 /* AudioPlaybackService.swift */, 2DA6054625F716A2006356F9 /* PlaybackState.swift */, 5DF1054025F886D400D6C0D4 /* VideoPlaybackService.swift */, - DB71FD4B25F8C80E00512AE1 /* StatusPrefetchingService.swift */, DBC7A67B260DFADE00E57475 /* StatusPublishService.swift */, 2D9DB966263A76FB007C1D71 /* BlockDomainService.swift */, DB4924E126312AB200E9DB22 /* NotificationService.swift */, @@ -1833,25 +1825,11 @@ path = Service; sourceTree = ""; }; - 2D61335625C1887F00CAE157 /* Persist */ = { - isa = PBXGroup; - children = ( - 2D61335725C188A000CAE157 /* APIService+Persist+Status.swift */, - DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */, - DB71FD3B25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift */, - ); - path = Persist; - sourceTree = ""; - }; 2D69CFF225CA9E2200C3A1B2 /* Protocol */ = { isa = PBXGroup; children = ( - 2D38F1FC25CD47D900561493 /* StatusProvider */, - DBAE3F742615DD63004B8251 /* UserProvider */, - DBBC24B426A540AE00398BB9 /* AvatarConfigurableView.swift */, - 2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */, + DB697DD7278F4C34004EF2F7 /* Provider */, 2D5A3D3725CF8D9F002347D6 /* ScrollViewContainer.swift */, - DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */, 2D38F1C525CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift */, 2D38F20725CD491300561493 /* DisposeBagCollectable.swift */, 5DF1058425F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift */, @@ -1859,23 +1837,11 @@ DB1D84372657B275000346B3 /* SegmentedControlNavigateable.swift */, DB1D843326579931000346B3 /* TableViewControllerNavigateable.swift */, DB1D842D26552C4D000346B3 /* StatusTableViewControllerNavigateable.swift */, + DB0FCB7127952986006C02E2 /* NamingState.swift */, ); path = Protocol; sourceTree = ""; }; - 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 = ""; - }; 2D76319C25C151DE00929FB9 /* Diffiable */ = { isa = PBXGroup; children = ( @@ -1883,8 +1849,9 @@ DB0617FB27855B740030EE79 /* Account */, DB0617F827855B170030EE79 /* User */, DB0617F927855B460030EE79 /* Profile */, + DB0FCB892796BE1E006C02E2 /* RecommandAccount */, DB4F097926A039C400D62E92 /* Status */, - DB0617F627855AF30030EE79 /* Poll */, + DB65C63527A2AF52008BAC2E /* Report */, DB4F097626A0398000D62E92 /* Compose */, DB0617F727855B010030EE79 /* Notification */, DB4F097726A039A200D62E92 /* Search */, @@ -1911,12 +1878,9 @@ children = ( 2DA504672601ADBA008F4E6C /* Decoration */, 2D42FF8325C82245004A627A /* Button */, - 2D42FF7C25C82207004A627A /* ToolBar */, DB9D6C1325E4F97A0051B173 /* Container */, DBA9B90325F1D4420012E7B6 /* Control */, 2D152A8A25C295B8009AA50C /* Content */, - DB0C947026A7D2AB0088FB11 /* ImageView */, - DB87D45C2609DE6600D12C0D /* TextField */, DB1D187125EF5BBD003F1F23 /* TableView */, 2D7631A625C1533800929FB9 /* TableviewCell */, ); @@ -1926,16 +1890,21 @@ 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 = ""; @@ -1985,12 +1954,12 @@ path = Stack; sourceTree = ""; }; - 2DFAD5212616F8E300F9EE7C /* TableViewCell */ = { + 2DFAD5212616F8E300F9EE7C /* Cell */ = { isa = PBXGroup; children = ( - 2DFAD5362617010500F9EE7C /* SearchResultTableViewCell.swift */, + DB63F7442799056400455B82 /* HashtagTableViewCell.swift */, ); - path = TableViewCell; + path = Cell; sourceTree = ""; }; 3FE14AD363ED19AE7FF210A6 /* Frameworks */ = { @@ -2077,34 +2046,97 @@ path = Onboarding; sourceTree = ""; }; + DB025B7A278D6234002F581E /* Template */ = { + isa = PBXGroup; + children = ( + DB025B80278D6252002F581E /* Stencil */, + DB025B81278D6271002F581E /* AutoGenerateProperty.swift */, + DB025B83278D6272002F581E /* AutoGenerateRelationship.swift */, + DB025B82278D6272002F581E /* AutoUpdatableObject.swift */, + ); + path = Template; + sourceTree = ""; + }; + DB025B80278D6252002F581E /* Stencil */ = { + isa = PBXGroup; + children = ( + DB025B7D278D6247002F581E /* AutoGenerateProperty.stencil */, + DB025B7F278D6247002F581E /* AutoGenerateRelationship.stencil */, + DB025B7E278D6247002F581E /* AutoUpdatableObject.stencil */, + ); + path = Stencil; + sourceTree = ""; + }; + DB025B8A278D6367002F581E /* App */ = { + isa = PBXGroup; + children = ( + DB025B88278D6339002F581E /* Feed.swift */, + DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */, + 5B90C46D26259B2C0002E742 /* Setting.swift */, + ); + path = App; + sourceTree = ""; + }; + DB025B8D278D6377002F581E /* Transient */ = { + isa = PBXGroup; + children = ( + DB025B8B278D6374002F581E /* Acct.swift */, + DB025B8F278D6489002F581E /* Feed+Kind.swift */, + DB336F1D278D6C3A0031E64B /* MastodonEmoji.swift */, + DB336F25278D6E8F0031E64B /* MastodonField.swift */, + DB0FCB6927950CB3006C02E2 /* MastodonMention.swift */, + DB336F2F278D723D0031E64B /* MastodonVisibility.swift */, + DB336F44278EB1D70031E64B /* MastodonAttachment.swift */, + DB63F75F27995ECE00455B82 /* MastodonTagHistory.swift */, + DB63F76C279A67BD00455B82 /* MastodonNotificationType.swift */, + ); + path = Transient; + sourceTree = ""; + }; + DB025B91278D64F0002F581E /* Persistence */ = { + isa = PBXGroup; + children = ( + 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 = Persistence; + sourceTree = ""; + }; + 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 = ""; + }; DB03F7F1268990A2007B274C /* TableViewCell */ = { isa = PBXGroup; children = ( DB03F7F22689AEA3007B274C /* ComposeRepliedToStatusContentTableViewCell.swift */, + DBBC24AB26A53D9300398BB9 /* ComposeStatusContentTableViewCell.swift */, DB36679C268AB91B0027D07F /* ComposeStatusAttachmentTableViewCell.swift */, DB3667A3268AE2370027D07F /* ComposeStatusPollTableViewCell.swift */, ); path = TableViewCell; sourceTree = ""; }; - DB0617F3278436360030EE79 /* Deprecated */ = { - isa = PBXGroup; - children = ( - 0FB3D33125E5F50E00AAD544 /* PickServerSearchCell.swift */, - 0FB3D30725E524C600AAD544 /* PickServerCategoriesCell.swift */, - ); - path = Deprecated; - sourceTree = ""; - }; - DB0617F627855AF30030EE79 /* Poll */ = { - isa = PBXGroup; - children = ( - DB4481C525EE2ADA00BEFB67 /* PollSection.swift */, - DB4481CB25EE2AFE00BEFB67 /* PollItem.swift */, - ); - path = Poll; - sourceTree = ""; - }; DB0617F727855B010030EE79 /* Notification */ = { isa = PBXGroup; children = ( @@ -2173,27 +2205,14 @@ 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 = ""; }; - DB0C947026A7D2AB0088FB11 /* ImageView */ = { - isa = PBXGroup; - children = ( - DB0C946E26A7D2A80088FB11 /* AvatarImageView.swift */, - ); - path = ImageView; - sourceTree = ""; - }; DB0C947826A7FE950088FB11 /* Button */ = { isa = PBXGroup; children = ( @@ -2213,6 +2232,14 @@ path = View; sourceTree = ""; }; + DB0FCB892796BE1E006C02E2 /* RecommandAccount */ = { + isa = PBXGroup; + children = ( + 2DE0FACD2615F7AD00CDF649 /* RecommendAccountSection.swift */, + ); + path = RecommandAccount; + sourceTree = ""; + }; DB1D187125EF5BBD003F1F23 /* TableView */ = { isa = PBXGroup; children = ( @@ -2230,6 +2257,47 @@ path = View; sourceTree = ""; }; + DB336F1F278D6C8F0031E64B /* Mastodon */ = { + isa = PBXGroup; + children = ( + DB89BA2625C110B4008580ED /* Status.swift */, + DB8AF52425C131D1002E6C99 /* MastodonUser.swift */, + 2D9DB968263A833E007C1D71 /* DomainBlock.swift */, + 2D6125462625436B00299647 /* Notification.swift */, + 2D0B7A1C261D839600B44727 /* SearchHistory.swift */, + 2D927F0725C7E9A8004F19B8 /* Tag.swift */, + 2D927F0D25C7E9C9004F19B8 /* History.swift */, + 2D927F1325C7EDD9004F19B8 /* Emoji.swift */, + DB45FAEC25CA7A9A005A8AC7 /* MastodonAuthentication.swift */, + 2DA7D05625CA693F00804E11 /* Application.swift */, + DB4481AC25EE155900BEFB67 /* Poll.swift */, + DB4481B225EE16D000BEFB67 /* PollOption.swift */, + DBCC3B9A2615849F0045B23D /* PrivateNote.swift */, + 5B90C46C26259B2C0002E742 /* Subscription.swift */, + 5B90C47E26259BA90002E742 /* SubscriptionAlerts.swift */, + DB73BF4027118B6D00781945 /* Instance.swift */, + ); + path = Mastodon; + sourceTree = ""; + }; + DB336F24278D6DF40031E64B /* Protocol */ = { + isa = PBXGroup; + children = ( + DB336F22278D6DED0031E64B /* MastodonEmojiContainer.swift */, + DB336F27278D6EC70031E64B /* MastodonFieldContainer.swift */, + DB0FCB6B27950E29006C02E2 /* MastodonMentionContainer.swift */, + ); + path = Protocol; + sourceTree = ""; + }; + DB336F3B278D7D260031E64B /* Utility */ = { + isa = PBXGroup; + children = ( + DB336F39278D7D1F0031E64B /* ManagedObjectRecord.swift */, + ); + path = Utility; + sourceTree = ""; + }; DB3D0FF725BAA68500EAA174 /* Supporting Files */ = { isa = PBXGroup; children = ( @@ -2249,8 +2317,6 @@ DBF3B73E2733EAED00E21627 /* local-codes.json */, DB427DDE25BAA00100D1B89D /* Assets.xcassets */, DB118A8125E4B6E600FAB162 /* Preview Assets.xcassets */, - DB564BCE269F2F83001E39A7 /* Localizable.stringsdict */, - DB3D100F25BAA75E00EAA174 /* Localizable.strings */, DB2B3ABE25E37E15007045F9 /* InfoPlist.strings */, ); path = Resources; @@ -2299,7 +2365,6 @@ children = ( DB427DE325BAA00100D1B89D /* Info.plist */, DB89BA1025C10FF5008580ED /* Mastodon.entitlements */, - DB0617F3278436360030EE79 /* Deprecated */, 2D76319C25C151DE00929FB9 /* Diffiable */, DB8AF52A25C13561002E6C99 /* State */, 2D61335525C1886800CAE157 /* Service */, @@ -2309,9 +2374,10 @@ 2D5A3D0125CF8640002347D6 /* Vender */, DB73B495261F030D002E9E9F /* Activity */, DBBC24D526A54BCB00398BB9 /* Helper */, + DB025B91278D64F0002F581E /* Persistence */, DB5086CB25CC0DB400C2C187 /* Preference */, 2D69CFF225CA9E2200C3A1B2 /* Protocol */, - DB98338425C945ED00AD9700 /* Generated */, + DB6746EE278F45F3008A6B94 /* Template */, DB3D0FF825BAA6B200EAA174 /* Resources */, DB3D0FF725BAA68500EAA174 /* Supporting Files */, ); @@ -2340,7 +2406,6 @@ isa = PBXGroup; children = ( DB45FB0925CA87BC005A8AC7 /* CoreData */, - 2D61335625C1887F00CAE157 /* Persist */, 2D61335D25C1894B00CAE157 /* APIService.swift */, DB98337E25C9452D00AD9700 /* APIService+APIError.swift */, DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */, @@ -2350,7 +2415,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 */, @@ -2363,6 +2427,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 */, @@ -2382,10 +2447,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 */, @@ -2407,9 +2469,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; @@ -2435,8 +2499,8 @@ 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 */, @@ -2464,8 +2528,7 @@ isa = PBXGroup; children = ( 2D76319E25C1521200929FB9 /* StatusSection.swift */, - 2D7631B225C159F700929FB9 /* Item.swift */, - 5BB04FF4262F0E6D0043BFF6 /* ReportSection.swift */, + DB025B77278D606A002F581E /* StatusItem.swift */, ); path = Status; sourceTree = ""; @@ -2505,7 +2568,6 @@ DB55D32225FB4D320002F825 /* View */ = { isa = PBXGroup; children = ( - DBBC24AD26A53DC100398BB9 /* ReplicaStatusView.swift */, DB03F7F42689B782007B274C /* ComposeTableView.swift */, DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */, DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */, @@ -2521,7 +2583,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 */, @@ -2580,6 +2642,47 @@ path = Image; sourceTree = ""; }; + DB63F7502799449300455B82 /* Cell */ = { + isa = PBXGroup; + children = ( + DB63F74C27993F5B00455B82 /* SearchHistoryUserCollectionViewCell.swift */, + DB63F759279953F200455B82 /* SearchHistoryUserCollectionViewCell+ViewModel.swift */, + DB63F751279944AA00455B82 /* SearchHistorySectionHeaderCollectionReusableView.swift */, + ); + path = Cell; + sourceTree = ""; + }; + 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 = ""; + }; + DB65C63527A2AF52008BAC2E /* Report */ = { + isa = PBXGroup; + children = ( + 5BB04FF4262F0E6D0043BFF6 /* ReportSection.swift */, + DB65C63627A2AF6C008BAC2E /* ReportItem.swift */, + ); + path = Report; + sourceTree = ""; + }; + DB6746EE278F45F3008A6B94 /* Template */ = { + isa = PBXGroup; + children = ( + DB6746EC278F45F0008A6B94 /* AutoGenerateProtocolRelayDelegate.swift */, + DB6746EF278F463B008A6B94 /* AutoGenerateProtocolDelegate.swift */, + DB697DD0278F4871004EF2F7 /* AutoGenerateTableViewDelegate.swift */, + ); + path = Template; + sourceTree = ""; + }; DB67D08727312E6A006A36CF /* Wizard */ = { isa = PBXGroup; children = ( @@ -2622,11 +2725,36 @@ path = NavigationController; sourceTree = ""; }; + 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 */, + ); + path = Provider; + sourceTree = ""; + }; 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 */, @@ -2677,6 +2805,7 @@ isa = PBXGroup; children = ( DB73B48F261F030A002E9E9F /* SafariActivity.swift */, + DB023D25279FFB0A005AC798 /* ShareActivityProvider.swift */, ); path = Activity; sourceTree = ""; @@ -2699,7 +2828,6 @@ DB789A2125F9F76D0071ACA0 /* CollectionViewCell */ = { isa = PBXGroup; children = ( - DBBC24AB26A53D9300398BB9 /* ComposeStatusContentTableViewCell.swift */, DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift */, DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */, DB87D4502609CF1E00D12C0D /* ComposeStatusPollOptionAppendEntryCollectionViewCell.swift */, @@ -2732,25 +2860,19 @@ path = Root; sourceTree = ""; }; - DB87D45C2609DE6600D12C0D /* TextField */ = { - isa = PBXGroup; - children = ( - DB87D4562609DD5300D12C0D /* DeleteBackwardResponseTextField.swift */, - ); - path = TextField; - sourceTree = ""; - }; DB89B9EF25C10FD0008580ED /* CoreDataStack */ = { isa = PBXGroup; children = ( + 2DF75BB725D1473400694EC8 /* Stack */, + DB89BA2C25C110B7008580ED /* Entity */, + DB336F3B278D7D260031E64B /* Utility */, + DB89BA1725C1107F008580ED /* Extension */, + DB89BA4025C1165F008580ED /* Protocol */, + DB025B7A278D6234002F581E /* Template */, DB89B9F125C10FD0008580ED /* Info.plist */, DB89B9F025C10FD0008580ED /* CoreDataStack.h */, DB89BA1125C1105C008580ED /* CoreDataStack.swift */, DB89BA3525C1145C008580ED /* CoreData.xcdatamodeld */, - 2DF75BB725D1473400694EC8 /* Stack */, - DB89BA4025C1165F008580ED /* Protocol */, - DB89BA1725C1107F008580ED /* Extension */, - DB89BA2C25C110B7008580ED /* Entity */, ); path = CoreDataStack; sourceTree = ""; @@ -2778,26 +2900,9 @@ 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 */, + DB025B8A278D6367002F581E /* App */, + DB025B8D278D6377002F581E /* Transient */, + DB336F1F278D6C8F0031E64B /* Mastodon */, ); path = Entity; sourceTree = ""; @@ -2848,17 +2953,16 @@ DB67D08727312E6A006A36CF /* Wizard */, DB9F58ED26EF435800E7BBE9 /* Account */, 2D38F1D325CD463600561493 /* HomeTimeline */, - 2D76316325C14BAC00929FB9 /* PublicTimeline */, - 5B24BBD6262DB14800A9381B /* Report */, 0F2021F5261325ED000C64BF /* HashtagTimeline */, - 2DAC9E36262FC20B0062E1A6 /* SuggestionAccount */, - DB9D6BEE25E4F5370051B173 /* Search */, - 5B90C455262599800002E742 /* Settings */, DB9D6BFD25E4F57B0051B173 /* Notification */, - DB9D6C0825E4F5A60051B173 /* Profile */, - DB789A1025F9F29B0071ACA0 /* Compose */, DB938EEB2623F52600E5B6C1 /* Thread */, + 5B24BBD6262DB14800A9381B /* Report */, + DB9D6BEE25E4F5370051B173 /* Search */, + DB789A1025F9F29B0071ACA0 /* Compose */, DB6180DE263919350018D199 /* MediaPreview */, + 2DAC9E36262FC20B0062E1A6 /* SuggestionAccount */, + DB9D6C0825E4F5A60051B173 /* Profile */, + 5B90C455262599800002E742 /* Settings */, ); path = Scene; sourceTree = ""; @@ -2872,16 +2976,13 @@ 5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */, 0F20223826146553000C64BF /* Array.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 */, @@ -2898,6 +2999,7 @@ DBCC3B35261440BA0045B23D /* UINavigationController.swift */, DB73BF4827140BA300781945 /* UICollectionViewDiffableDataSource.swift */, DB73BF4A27140C0800781945 /* UITableViewDiffableDataSource.swift */, + DBBC50BE278ED0E700AF0CC6 /* Date.swift */, ); path = Extension; sourceTree = ""; @@ -2920,12 +3022,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 = ""; @@ -2940,15 +3043,6 @@ name = "Recovered References"; sourceTree = ""; }; - DB98338425C945ED00AD9700 /* Generated */ = { - isa = PBXGroup; - children = ( - DB98338525C945ED00AD9700 /* Strings.swift */, - DB98338625C945ED00AD9700 /* Assets.swift */, - ); - path = Generated; - sourceTree = ""; - }; DB9A489B26036E19008B817C /* MastodonAttachmentService */ = { isa = PBXGroup; children = ( @@ -2970,14 +3064,12 @@ 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 = ""; @@ -2991,8 +3083,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 */, @@ -3008,7 +3100,6 @@ 2D206B7125F5D27F00143C56 /* AudioContainerView.swift */, 5DF1057825F88A1D00D6C0D4 /* PlayerContainerView.swift */, DB49A63C25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift */, - 5DF1057E25F88A4100D6C0D4 /* TouchBlockingView.swift */, ); path = Container; sourceTree = ""; @@ -3074,7 +3165,6 @@ DBA9B90325F1D4420012E7B6 /* Control */ = { isa = PBXGroup; children = ( - DB59F11725EFA35B001F1DAB /* StripProgressView.swift */, DB02CDBE2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift */, ); path = Control; @@ -3090,16 +3180,6 @@ path = View; sourceTree = ""; }; - DBAE3F742615DD63004B8251 /* UserProvider */ = { - isa = PBXGroup; - children = ( - DBAE3F672615DD60004B8251 /* UserProvider.swift */, - DBAE3F872615DDF4004B8251 /* UserProviderFacade.swift */, - DB6B75012730060700C70B6E /* UserProviderFacade+UITableViewDelegate.swift */, - ); - path = UserProvider; - sourceTree = ""; - }; DBB525132611EBB1002F1F29 /* Segmented */ = { isa = PBXGroup; children = ( @@ -3122,7 +3202,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 */, @@ -3136,7 +3216,6 @@ DBB525732612D5A5002F1F29 /* View */, DBB525402611ED54002F1F29 /* ProfileHeaderViewController.swift */, DB7F48442620241000796008 /* ProfileHeaderViewModel.swift */, - DBA94437265CBD4D00C537E1 /* ProfileHeaderViewModel+Diffable.swift */, ); path = Header; sourceTree = ""; @@ -3148,9 +3227,6 @@ DBB5256D2612D5A1002F1F29 /* ProfileStatusDashboardView.swift */, DBB525842612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift */, DB35FC1E2612F1D9006193C9 /* ProfileRelationshipActionButton.swift */, - DB35FC242612FD7A006193C9 /* ProfileFieldView.swift */, - DBA9443D265CFA6400C537E1 /* ProfileFieldCollectionViewCell.swift */, - DBF9814B265E339500E4BA07 /* ProfileFieldAddEntryCollectionViewCell.swift */, DBF98149265E24F500E4BA07 /* ProfileFieldCollectionViewHeaderFooterView.swift */, ); path = View; @@ -3180,8 +3256,8 @@ isa = PBXGroup; children = ( DBBC24D626A54BCB00398BB9 /* MastodonRegex.swift */, + DBBC50C0278ED49200AF0CC6 /* MastodonAuthenticationBox.swift */, DBBC24D826A54BCB00398BB9 /* MastodonMetricFormatter.swift */, - DBFEF07626A691FB006D7ED1 /* MastodonAuthenticationBox.swift */, DBF3B7402733EB9400E21627 /* MastodonLocalCode.swift */, ); path = Helper; @@ -3218,6 +3294,7 @@ DBCBED2226132E1D00B49291 /* FetchedResultsController */ = { isa = PBXGroup; children = ( + DB336F3C278D80040031E64B /* FeedFetchedResultsController.swift */, DBCBED1C26132E1A00B49291 /* StatusFetchedResultsController.swift */, DBA088DE26958164003EB4B2 /* UserFetchedResultsController.swift */, DB6D9F75263587C7008423CD /* SettingFetchedResultController.swift */, @@ -3242,7 +3319,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 */, @@ -3253,7 +3330,6 @@ DBF1D24F269DAF6100C1C08A /* SearchDetail */ = { isa = PBXGroup; children = ( - 2DFAD5212616F8E300F9EE7C /* TableViewCell */, DB4F0964269ED06700D62E92 /* SearchResult */, DBF1D252269DB01700C1C08A /* SearchHistory */, DBF1D24D269DAF5D00C1C08A /* SearchDetailViewController.swift */, @@ -3265,9 +3341,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 = ""; @@ -3275,12 +3354,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 = ""; @@ -3298,6 +3376,27 @@ path = NotificationService; sourceTree = ""; }; + DBFEEC97279BDC6A004F81DD /* About */ = { + isa = PBXGroup; + children = ( + DBFEEC9E279C12CD004F81DD /* Cell */, + DBFEEC95279BDC67004F81DD /* ProfileAboutViewController.swift */, + DBFEEC98279BDCDE004F81DD /* ProfileAboutViewModel.swift */, + DBFEEC9A279BDDD9004F81DD /* ProfileAboutViewModel+Diffable.swift */, + ); + path = About; + sourceTree = ""; + }; + DBFEEC9E279C12CD004F81DD /* Cell */ = { + isa = PBXGroup; + children = ( + DBA9443D265CFA6400C537E1 /* ProfileFieldCollectionViewCell.swift */, + DBFEEC9C279C12C1004F81DD /* ProfileFieldEditCollectionViewCell.swift */, + DBF9814B265E339500E4BA07 /* ProfileFieldAddEntryCollectionViewCell.swift */, + ); + path = Cell; + sourceTree = ""; + }; DBFEF05426A576EE006D7ED1 /* View */ = { isa = PBXGroup; children = ( @@ -3366,6 +3465,8 @@ 5532CB85BBE168B25B20720B /* [CP] Embed Pods Frameworks */, DB89BA0825C10FD0008580ED /* Embed Frameworks */, DBF8AE1B263293E400C9C23C /* Embed App Extensions */, + DB025B8E278D6448002F581E /* ShellScript */, + DB697DD2278F48D5004EF2F7 /* ShellScript */, ); buildRules = ( ); @@ -3391,7 +3492,6 @@ DBAC649D267DFE43007FE9FD /* DiffableDataSources */, DBAC64A0267E6D02007FE9FD /* Fuzi */, DBF7A0FB26830C33004176A2 /* FPSIndicator */, - DBC6462A26A1738900B0E31B /* MastodonUI */, DB01E23226A98F0900C3965B /* MastodonMeta */, DB01E23426A98F0900C3965B /* MetaTextKit */, DB552D4E26BBD10C00E481F6 /* OrderedCollections */, @@ -3540,7 +3640,6 @@ ); name = ShareActionExtension; packageProductDependencies = ( - DBC6462426A1720B00B0E31B /* MastodonUI */, DBBC24A926A5301B00398BB9 /* MastodonSDK */, DBBC24B726A5421800398BB9 /* CommonOSLog */, DBBC24D026A5484F00398BB9 /* UITextView+Placeholder */, @@ -3568,7 +3667,7 @@ packageProductDependencies = ( DB00CA962632DDB600A54956 /* CommonOSLog */, DB6D9F41263527CE008423CD /* AlamofireImage */, - DBBC24CC26A5471E00398BB9 /* MastodonExtension */, + DB179266278D5A4A00B71DEB /* MastodonSDK */, ); productName = NotificationService; productReference = DBF8AE13263293E400C9C23C /* NotificationService.appex */; @@ -3638,6 +3737,8 @@ "gd-GB", th, "ku-TR", + "eu-ES", + "sv-FI", ); mainGroup = DB427DC925BAA00100D1B89D; packageReferences = ( @@ -3682,8 +3783,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 */, @@ -3735,8 +3834,6 @@ files = ( DBA4B0F726C269880077136E /* Intents.stringsdict in Resources */, DBB8AB4F26AED13F00F6D281 /* Assets.xcassets in Resources */, - DBB8AB5126AED14600F6D281 /* Localizable.strings in Resources */, - DBB8AB5026AED14400F6D281 /* Localizable.stringsdict in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -3745,9 +3842,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; }; @@ -3910,6 +4005,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 ./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; @@ -3927,6 +4039,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; @@ -3975,38 +4104,42 @@ 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 */, 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 */, + DBBC50BF278ED0E700AF0CC6 /* Date.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 */, + DB0FCB7427956939006C02E2 /* DataSourceFacade+Status.swift in Sources */, 5B8E055826319E47006E3C53 /* ReportFooterView.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 */, @@ -4014,6 +4147,8 @@ 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 */, @@ -4022,103 +4157,104 @@ 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 */, + 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 */, 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 */, 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 */, DBB3BA2A26A81C020004F2D4 /* FLAnimatedImageView.swift in Sources */, + DB6746ED278F45F0008A6B94 /* AutoGenerateProtocolRelayDelegate.swift in Sources */, DB0618032785A7100030EE79 /* RegisterSection.swift in Sources */, - 2DE0FAC82615F5F000CDF649 /* SearchRecommendAccountsCollectionViewCell.swift in Sources */, + DB63F76B279A5ED300455B82 /* NotificationTimelineViewModel+LoadOldestState.swift in Sources */, DBF1D251269DB01200C1C08A /* SearchHistoryViewController.swift in Sources */, - 2D42FF8525C8224F004A627A /* HitTestExpandedButton.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 */, DB0617ED277F02C50030EE79 /* OnboardingNavigationController.swift in Sources */, DB0617F527855AB90030EE79 /* ServerRuleSection.swift in Sources */, - DBBC24AE26A53DC100398BB9 /* ReplicaStatusView.swift in Sources */, DB75BF1E263C1C1B00EDBF1F /* CustomScheduler.swift in Sources */, 0FAA102725E1126A0017CCDE /* MastodonPickServerViewController.swift in Sources */, - DB59F0FE25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift in Sources */, DB6B74FE272FF59000C70B6E /* UserItem.swift in Sources */, DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */, DBE3CE07261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift in Sources */, - 2D61335825C188A000CAE157 /* APIService+Persist+Status.swift in Sources */, 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 */, + DB159C2B27A17BAC0068DC77 /* DataSourceFacade+Media.swift in Sources */, 0FAA101C25E10E760017CCDE /* UIFont.swift in Sources */, 2D38F1D525CD465300561493 /* HomeTimelineViewController.swift in Sources */, - DB97131F2666078B00BD1E90 /* Date.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 */, @@ -4135,10 +4271,10 @@ 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 */, @@ -4148,12 +4284,12 @@ 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 */, @@ -4161,36 +4297,35 @@ 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 */, + 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 /* 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 */, @@ -4198,12 +4333,11 @@ 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 */, DB6D9F76263587C7008423CD /* SettingFetchedResultController.swift in Sources */, DB9A486C26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift in Sources */, 5D0393902612D259007FE196 /* WebViewController.swift in Sources */, - DB4481CC25EE2AFE00BEFB67 /* PollItem.swift in Sources */, DB6B74FA272FC2B500C70B6E /* APIService+Follower.swift in Sources */, DB6B74F4272FBAE700C70B6E /* FollowerListViewModel+Diffable.swift in Sources */, DB6B74F2272FB67600C70B6E /* FollowerListViewModel.swift in Sources */, @@ -4212,21 +4346,23 @@ 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 */, DBC7A672260C897100E57475 /* StatusContentWarningEditorView.swift in Sources */, + DB025B95278D6530002F581E /* Persistence+MastodonUser.swift in Sources */, DB3667A6268AE2620027D07F /* ComposeStatusPollSection.swift in Sources */, DB6B750427300B4000C70B6E /* TimelineFooterTableViewCell.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 */, DB0617EF277F12720030EE79 /* NavigationActionView.swift in Sources */, @@ -4236,21 +4372,27 @@ 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 */, 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 */, 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 */, @@ -4260,57 +4402,57 @@ DBB525642612C988002F1F29 /* MeProfileViewModel.swift in Sources */, DB6B74EF272FB55000C70B6E /* FollowerListViewController.swift in Sources */, 5BB04FE9262EFC300043BFF6 /* ReportedStatusTableviewCell.swift in Sources */, - DBAE3F822615DDA3004B8251 /* ProfileViewController+UserProvider.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 */, 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 */, 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 */, 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 */, - 2D34D9CB261489930081BFC0 /* SearchViewController+Recommend.swift in Sources */, DB71C7CB271D5A0300BE3819 /* LineChartView.swift in Sources */, DB938F1526241FDF00E5B6C1 /* APIService+Thread.swift in Sources */, - DB482A45261335BA008AE74C /* UserTimelineViewController+Provider.swift in Sources */, 2D206B8625F5FB0900143C56 /* Double.swift in Sources */, DB9F58F126EF512300E7BBE9 /* AccountListTableViewCell.swift in Sources */, 2D76319F25C1521200929FB9 /* StatusSection.swift in Sources */, - DB35FC252612FD7A006193C9 /* ProfileFieldView.swift in Sources */, DB938F0326240EA300E5B6C1 /* CachedThreadViewModel.swift in Sources */, DB6D9F6326357848008423CD /* SettingService.swift in Sources */, 2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */, - 2D24E12D2626FD2E00A59D4F /* NotificationViewModel+LoadOldestState.swift in Sources */, DBA5A53526F0A36A00CACBAA /* AddAccountTableViewCell.swift in Sources */, 2DB72C8C262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift in Sources */, 2D35237A26256D920031AF25 /* NotificationSection.swift in Sources */, @@ -4318,6 +4460,7 @@ 2D4AD89C263165B500613EFC /* SuggestionAccountCollectionViewCell.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 */, @@ -4325,37 +4468,37 @@ DBAE3F9E2616E308004B8251 /* APIService+Mute.swift in Sources */, DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */, DB6D9F57263577D2008423CD /* APIService+CoreData+Setting.swift in Sources */, + DB0FCB822796AC78006C02E2 /* UserTimelineViewController+DataSourceProvider.swift in Sources */, + DB63F773279A87DC00455B82 /* Notification+Property.swift in Sources */, DB9D6C0E25E4F9780051B173 /* MosaicImageViewContainer.swift in Sources */, DBCBCC0D2680B908000F5B51 /* HomeTimelinePreference.swift in Sources */, - DB71FD3625F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift in Sources */, DB0EF72E26FDB24F00347686 /* SidebarListContentView.swift in Sources */, DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */, - DB98338725C945ED00AD9700 /* Strings.swift in Sources */, 2D7867192625B77500211898 /* NotificationItem.swift in Sources */, 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 */, DBFEF07B26A6BCE8006D7ED1 /* APIService+Status+Publish.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 */, @@ -4364,39 +4507,37 @@ 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 */, - 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 */, 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 */, 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 */, @@ -4407,38 +4548,39 @@ 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 */, 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 */, DB3667A4268AE2370027D07F /* ComposeStatusPollTableViewCell.swift in Sources */, DBBF1DC226524D2900E5B703 /* AutoCompleteTableViewCell.swift in Sources */, - 2D38F1FE25CD481700561493 /* StatusProvider.swift in Sources */, 5B24BBE2262DB19100A9381B /* APIService+Report.swift in Sources */, DBF3B7412733EB9400E21627 /* MastodonLocalCode.swift in Sources */, DB4F096A269EDAD200D62E92 /* SearchResultViewModel+State.swift in Sources */, 5BB04FF5262F0E6D0043BFF6 /* ReportSection.swift in Sources */, DBA94436265CBB7400C537E1 /* ProfileFieldItem.swift in Sources */, - DB938F25262438D600E5B6C1 /* ThreadViewController+Provider.swift in Sources */, + DB023D2A27A0FE5C005AC798 /* DataSourceProvider+NotificationTableViewCellDelegate.swift in Sources */, + DB0FCB962797E6C2006C02E2 /* SearchResultViewController+DataSourceProvider.swift in Sources */, DB66729C25F9F91F00D60309 /* ComposeStatusItem.swift in Sources */, DB6180E326391A4C0018D199 /* ViewControllerAnimatedTransitioning.swift in Sources */, 0FB3D31E25E534C700AAD544 /* PickServerCategoryCollectionViewCell.swift in Sources */, - DB1D842C26551A1C000346B3 /* StatusProvider+StatusTableViewKeyCommandNavigateable.swift in Sources */, + DB0FCB882796BDA9006C02E2 /* SearchItem.swift in Sources */, 5DF1057925F88A1D00D6C0D4 /* PlayerContainerView.swift in Sources */, - DB1D843626579DB5000346B3 /* StatusProvider+TableViewControllerNavigateable.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 */, @@ -4479,36 +4621,48 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + DB025B8C278D6374002F581E /* Acct.swift in Sources */, 2DA7D05725CA693F00804E11 /* Application.swift in Sources */, 2D927F1425C7EDD9004F19B8 /* Emoji.swift in Sources */, + DB025B84278D6272002F581E /* AutoGenerateProperty.swift in Sources */, 2DF75BC725D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift in Sources */, + DB336F26278D6E8F0031E64B /* MastodonField.swift in Sources */, DB89BA1225C1105C008580ED /* CoreDataStack.swift in Sources */, + DB63F76027995ECE00455B82 /* MastodonTagHistory.swift in Sources */, + DB336F3A278D7D1F0031E64B /* ManagedObjectRecord.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 */, + DB336F30278D723D0031E64B /* MastodonVisibility.swift in Sources */, DB89BA1B25C1107F008580ED /* Collection.swift in Sources */, DB4481AD25EE155900BEFB67 /* Poll.swift in Sources */, DB89BA2725C110B4008580ED /* Status.swift in Sources */, + DB025B86278D6272002F581E /* AutoGenerateRelationship.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 */, + DB025B85278D6272002F581E /* AutoUpdatableObject.swift in Sources */, DB45FAED25CA7A9A005A8AC7 /* MastodonAuthentication.swift in Sources */, + DB025B89278D6339002F581E /* Feed.swift in Sources */, 2DF75BB925D1474100694EC8 /* ManagedObjectObserver.swift in Sources */, - 2D927F0225C7E4F2004F19B8 /* Mention.swift in Sources */, + DB0FCB6A27950CB3006C02E2 /* MastodonMention.swift in Sources */, DB89BA1D25C1107F008580ED /* URL.swift in Sources */, + DB63F76D279A67BD00455B82 /* MastodonNotificationType.swift in Sources */, 2D9DB969263A833E007C1D71 /* DomainBlock.swift in Sources */, + DB025B90278D6489002F581E /* Feed+Kind.swift in Sources */, 2D0B7A1D261D839600B44727 /* SearchHistory.swift in Sources */, + DB336F1E278D6C3A0031E64B /* MastodonEmoji.swift in Sources */, 2D927F0825C7E9A8004F19B8 /* Tag.swift in Sources */, 5B90C46E26259B2C0002E742 /* Subscription.swift in Sources */, 5B90C47F26259BA90002E742 /* SubscriptionAlerts.swift in Sources */, + DB336F45278EB1D70031E64B /* MastodonAttachment.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4525,12 +4679,10 @@ 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 */, ); @@ -4547,25 +4699,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; @@ -4687,6 +4836,8 @@ DB4B778426CA500E00B087B3 /* gd-GB */, DB4B779226CA50BA00B087B3 /* th */, DBDC1CF9272C0FD600055C3D /* ku-TR */, + DB126A4C278C063F005726EE /* eu-ES */, + DB126A56278C088D005726EE /* sv-FI */, ); name = Intents.intentdefinition; sourceTree = ""; @@ -4708,31 +4859,12 @@ DB4B778726CA500E00B087B3 /* gd-GB */, DB4B779526CA50BA00B087B3 /* th */, DBDC1CFC272C0FD600055C3D /* ku-TR */, + DB126A4F278C063F005726EE /* eu-ES */, + DB126A59278C088D005726EE /* sv-FI */, ); name = InfoPlist.strings; sourceTree = ""; }; - 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 = ""; - }; DB427DDB25BAA00100D1B89D /* Main.storyboard */ = { isa = PBXVariantGroup; children = ( @@ -4749,27 +4881,6 @@ name = LaunchScreen.storyboard; sourceTree = ""; }; - 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 = ""; - }; DBA4B0F926C269880077136E /* Intents.stringsdict */ = { isa = PBXVariantGroup; children = ( @@ -4787,6 +4898,8 @@ DB4B779126CA504A00B087B3 /* ja */, DB4B779626CA50BA00B087B3 /* th */, DBDC1CFD272C0FD600055C3D /* ku-TR */, + DB126A50278C063F005726EE /* eu-ES */, + DB126A5A278C088D005726EE /* sv-FI */, ); name = Intents.stringsdict; sourceTree = ""; @@ -4931,7 +5044,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 90; + CURRENT_PROJECT_VERSION = 91; DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets"; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = Mastodon/Info.plist; @@ -4960,7 +5073,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 90; + CURRENT_PROJECT_VERSION = 91; DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets"; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = Mastodon/Info.plist; @@ -5068,11 +5181,11 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 90; + CURRENT_PROJECT_VERSION = 91; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5Z4GVSS33P; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 90; + DYLIB_CURRENT_VERSION = 91; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = AppShared/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -5099,11 +5212,11 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 90; + CURRENT_PROJECT_VERSION = 91; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5Z4GVSS33P; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 90; + DYLIB_CURRENT_VERSION = 91; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = AppShared/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -5128,11 +5241,11 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 90; + CURRENT_PROJECT_VERSION = 91; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5Z4GVSS33P; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 90; + DYLIB_CURRENT_VERSION = 91; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = CoreDataStack/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -5158,11 +5271,11 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 90; + CURRENT_PROJECT_VERSION = 91; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5Z4GVSS33P; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 90; + DYLIB_CURRENT_VERSION = 91; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = CoreDataStack/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -5225,7 +5338,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = MastodonIntent/MastodonIntent.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 90; + CURRENT_PROJECT_VERSION = 91; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = MastodonIntent/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -5250,7 +5363,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = MastodonIntent/MastodonIntent.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 90; + CURRENT_PROJECT_VERSION = 91; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = MastodonIntent/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -5275,7 +5388,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = ShareActionExtension/ShareActionExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 90; + CURRENT_PROJECT_VERSION = 91; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = ShareActionExtension/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -5300,7 +5413,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = ShareActionExtension/ShareActionExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 90; + CURRENT_PROJECT_VERSION = 91; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = ShareActionExtension/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -5325,7 +5438,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 90; + CURRENT_PROJECT_VERSION = 91; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = NotificationService/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -5349,7 +5462,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 90; + CURRENT_PROJECT_VERSION = 91; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = NotificationService/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -5638,6 +5751,10 @@ package = DB3D0FF125BAA61700EAA174 /* XCRemoteSwiftPackageReference "AlamofireImage" */; productName = AlamofireImage; }; + DB179266278D5A4A00B71DEB /* MastodonSDK */ = { + isa = XCSwiftPackageProductDependency; + productName = MastodonSDK; + }; DB3D0FF225BAA61700EAA174 /* AlamofireImage */ = { isa = XCSwiftPackageProductDependency; package = DB3D0FF125BAA61700EAA174 /* XCRemoteSwiftPackageReference "AlamofireImage" */; @@ -5701,23 +5818,11 @@ 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" */; @@ -5729,10 +5834,11 @@ DB89BA3525C1145C008580ED /* CoreData.xcdatamodeld */ = { isa = XCVersionGroup; children = ( + DB025B79278D6138002F581E /* CoreData 3.xcdatamodel */, DBF156DD27006F5D00EC00B7 /* CoreData 2.xcdatamodel */, DB89BA3625C1145C008580ED /* CoreData.xcdatamodel */, ); - currentVersion = DBF156DD27006F5D00EC00B7 /* CoreData 2.xcdatamodel */; + currentVersion = DB025B79278D6138002F581E /* CoreData 3.xcdatamodel */; path = CoreData.xcdatamodeld; sourceTree = ""; versionGroupType = wrapper.xcdatamodel; diff --git a/Mastodon.xcodeproj/xcshareddata/xcschemes/Mastodon - ASDK.xcscheme b/Mastodon.xcodeproj/xcshareddata/xcschemes/Mastodon - ASDK.xcscheme deleted file mode 100644 index 4ce52bd5..00000000 --- a/Mastodon.xcodeproj/xcshareddata/xcschemes/Mastodon - ASDK.xcscheme +++ /dev/null @@ -1,108 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index 56e26925..e8b961ab 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,18 +7,13 @@ AppShared.xcscheme_^#shared#^_ orderHint - 26 + 28 CoreDataStack.xcscheme_^#shared#^_ orderHint 27 - Mastodon - ASDK.xcscheme_^#shared#^_ - - orderHint - 2 - Mastodon - RTL.xcscheme_^#shared#^_ orderHint @@ -102,7 +97,7 @@ MastodonIntent.xcscheme_^#shared#^_ orderHint - 25 + 26 MastodonIntents.xcscheme_^#shared#^_ @@ -117,15 +112,36 @@ NotificationService.xcscheme_^#shared#^_ orderHint - 3 + 2 ShareActionExtension.xcscheme_^#shared#^_ orderHint - 24 + 25 SuppressBuildableAutocreation - + + DB427DD125BAA00100D1B89D + + primary + + + DB427DE725BAA00100D1B89D + + primary + + + DB427DF225BAA00100D1B89D + + primary + + + DB89B9F525C10FD0008580ED + + primary + + + diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved index e52bb1d9..f4928516 100644 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -55,15 +55,6 @@ "version": "1.2.0" } }, - { - "package": "FLAnimatedImage", - "repositoryURL": "https://github.com/Flipboard/FLAnimatedImage", - "state": { - "branch": null, - "revision": "e7f9fd4681ae41bf6f3056db08af4f401d61da52", - "version": "1.0.16" - } - }, { "package": "FPSIndicator", "repositoryURL": "https://github.com/MainasuK/FPSIndicator.git", diff --git a/Mastodon/.sourcery.yml b/Mastodon/.sourcery.yml new file mode 100644 index 00000000..391430e5 --- /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 a43e34f9..d2c7b924 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 { @@ -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 00000000..524a0427 --- /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 49504fd1..d6833947 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 { @@ -194,10 +196,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, @@ -211,7 +209,7 @@ extension SceneCoordinator { return false } } - } + } // end enum Scene { } } extension SceneCoordinator { @@ -266,6 +264,7 @@ extension SceneCoordinator { } @discardableResult + @MainActor func present(scene: Scene, from sender: UIViewController?, transition: Transition) -> UIViewController? { guard let viewController = get(scene: scene) else { return nil @@ -481,12 +480,6 @@ private extension SceneCoordinator { 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/Deprecated/PickServerCategoriesCell.swift b/Mastodon/Deprecated/PickServerCategoriesCell.swift deleted file mode 100644 index b2ca1cc7..00000000 --- a/Mastodon/Deprecated/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? -// -// 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/Deprecated/PickServerSearchCell.swift b/Mastodon/Deprecated/PickServerSearchCell.swift deleted file mode 100644 index 465e7ae2..00000000 --- a/Mastodon/Deprecated/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/Diffiable/Compose/AutoCompleteSection.swift b/Mastodon/Diffiable/Compose/AutoCompleteSection.swift index ed205b13..1a2bf45f 100644 --- a/Mastodon/Diffiable/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/Compose/ComposeStatusItem.swift b/Mastodon/Diffiable/Compose/ComposeStatusItem.swift index c2c3f46d..65650dcd 100644 --- a/Mastodon/Diffiable/Compose/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) + case input(replyTo: ManagedObjectRecord?, 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(nil) - let displayName = CurrentValueSubject(nil) - let emojiMeta = CurrentValueSubject([:]) - let username = CurrentValueSubject(nil) - let composeContent = CurrentValueSubject(nil) - let isContentWarningComposing = CurrentValueSubject(false) - let contentWarningContent = CurrentValueSubject("") + @Published var author: ManagedObjectRecord? + + @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/Compose/ComposeStatusPollItem.swift b/Mastodon/Diffiable/Compose/ComposeStatusPollItem.swift index 2e45484c..0a315454 100644 --- a/Mastodon/Diffiable/Compose/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/Compose/ComposeStatusSection.swift b/Mastodon/Diffiable/Compose/ComposeStatusSection.swift index 45b0656f..45ed8678 100644 --- a/Mastodon/Diffiable/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) + case reply(status: ManagedObjectRecord) } } 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/FetchedResultsController/FeedFetchedResultsController.swift b/Mastodon/Diffiable/FetchedResultsController/FeedFetchedResultsController.swift new file mode 100644 index 00000000..ab555c1c --- /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() + + public let fetchedResultsController: NSFetchedResultsController + + // input + @Published public var predicate = Feed.predicate(kind: .none, acct: .none) + + // output + private let _objectIDs = PassthroughSubject<[NSManagedObjectID], Never>() + @Published public var records: [ManagedObjectRecord] = [] + + 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, + didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference + ) { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + let snapshot = snapshot as NSDiffableDataSourceSnapshot + self._objectIDs.send(snapshot.itemIdentifiers) + } +} + diff --git a/Mastodon/Diffiable/FetchedResultsController/SearchHistoryFetchedResultController.swift b/Mastodon/Diffiable/FetchedResultsController/SearchHistoryFetchedResultController.swift index 6d4461ea..c3521c6f 100644 --- a/Mastodon/Diffiable/FetchedResultsController/SearchHistoryFetchedResultController.swift +++ b/Mastodon/Diffiable/FetchedResultsController/SearchHistoryFetchedResultController.swift @@ -21,8 +21,9 @@ final class SearchHistoryFetchedResultController: NSObject { let userID = CurrentValueSubject(nil) // output - let objectIDs = CurrentValueSubject<[NSManagedObjectID], Never>([]) - + let _objectIDs = CurrentValueSubject<[NSManagedObjectID], Never>([]) + @Published var records: [ManagedObjectRecord] = [] + 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 dd373b29..10da3f3f 100644 --- a/Mastodon/Diffiable/FetchedResultsController/StatusFetchedResultsController.swift +++ b/Mastodon/Diffiable/FetchedResultsController/StatusFetchedResultsController.swift @@ -23,7 +23,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] = [] init(managedObjectContext: NSManagedObjectContext, domain: String?, additionalTweetPredicate: NSPredicate?) { self.domain.value = domain ?? "" @@ -43,11 +44,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 +75,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, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { @@ -82,6 +101,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 f46ee978..9ff4c5e5 100644 --- a/Mastodon/Diffiable/FetchedResultsController/UserFetchedResultsController.swift +++ b/Mastodon/Diffiable/FetchedResultsController/UserFetchedResultsController.swift @@ -23,7 +23,8 @@ final class UserFetchedResultsController: NSObject { let userIDs = CurrentValueSubject<[Mastodon.Entity.Account.ID], Never>([]) // output - let objectIDs = CurrentValueSubject<[NSManagedObjectID], Never>([]) + let _objectIDs = CurrentValueSubject<[NSManagedObjectID], Never>([]) + @Published var records: [ManagedObjectRecord] = [] init(managedObjectContext: NSManagedObjectContext, domain: String?, additionalTweetPredicate: NSPredicate?) { self.domain.value = domain ?? "" @@ -42,12 +43,18 @@ final class UserFetchedResultsController: 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().eraseToAnyPublisher(), - self.userIDs.removeDuplicates().eraseToAnyPublisher() + self.domain.removeDuplicates(), + self.userIDs.removeDuplicates() ) .receive(on: DispatchQueue.main) .sink { [weak self] domain, ids in @@ -68,6 +75,18 @@ final class UserFetchedResultsController: NSObject { } +extension UserFetchedResultsController { + + public func append(userIDs: [Mastodon.Entity.Account.ID]) { + var result = self.userIDs.value + for userID in userIDs where !result.contains(userID) { + result.append(userID) + } + self.userIDs.value = result + } + +} + // MARK: - NSFetchedResultsControllerDelegate extension UserFetchedResultsController: NSFetchedResultsControllerDelegate { func controller(_ controller: NSFetchedResultsController, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { @@ -82,6 +101,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/Notification/NotificationItem.swift b/Mastodon/Diffiable/Notification/NotificationItem.swift index fc7d0e0d..b0fdddb7 100644 --- a/Mastodon/Diffiable/Notification/NotificationItem.swift +++ b/Mastodon/Diffiable/Notification/NotificationItem.swift @@ -7,50 +7,10 @@ import CoreData import Foundation +import CoreDataStack -enum NotificationItem { - case notification(objectID: NSManagedObjectID, attribute: Item.StatusAttribute) - case notificationStatus(objectID: NSManagedObjectID, attribute: Item.StatusAttribute) // display notification status without card wrapper +enum NotificationItem: Hashable { + case feed(record: ManagedObjectRecord) + case feedLoader(record: ManagedObjectRecord) 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/Notification/NotificationSection.swift b/Mastodon/Diffiable/Notification/NotificationSection.swift index 86478500..210d7ea1 100644 --- a/Mastodon/Diffiable/Notification/NotificationSection.swift +++ b/Mastodon/Diffiable/Notification/NotificationSection.swift @@ -13,234 +13,290 @@ import MastodonSDK import UIKit import MetaTextKit import MastodonMeta +import MastodonAsset +import MastodonLocalization enum NotificationSection: Equatable, Hashable { case main } extension NotificationSection { - static func tableViewDiffableDataSource( - for tableView: UITableView, - dependency: NeedsDependency, - managedObjectContext: NSManagedObjectContext, - delegate: NotificationTableViewCellDelegate, - statusTableViewCellDelegate: StatusTableViewCellDelegate + + struct Configuration { + weak var notificationTableViewCellDelegate: NotificationTableViewCellDelegate? + } + + static func diffableDataSource( + tableView: UITableView, + context: AppContext, + configuration: Configuration ) -> UITableViewDiffableDataSource { - 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) + 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 .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 .feedLoader(let record): + return UITableViewCell() case .bottomLoader: - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) as! TimelineBottomLoaderTableViewCell - cell.startAnimating() + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell + cell.activityIndicatorView.startAnimating() return cell } +// 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( + context: AppContext, tableView: UITableView, - cell: NotificationStatusTableViewCell, - notification: MastodonNotification, - dependency: NeedsDependency, - attribute: Item.StatusAttribute + cell: NotificationTableViewCell, + viewModel: NotificationTableViewCell.ViewModel, + configuration: Configuration ) { - // configure author - cell.configure( - with: AvatarConfigurableViewConfiguration( - avatarImageURL: notification.account.avatarImageURL() - ) + StatusSection.setupStatusPollDataSource( + context: context, + statusView: cell.notificationView.statusView ) - 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)) - } + StatusSection.setupStatusPollDataSource( + context: context, + statusView: cell.notificationView.quoteStatusView + ) - 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() - } + context.authenticationService.activeMastodonAuthenticationBox + .map { $0 as UserIdentifier? } + .assign(to: \.userIdentifier, on: cell.notificationView.viewModel) .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 - } + cell.configure( + tableView: tableView, + viewModel: viewModel, + delegate: configuration.notificationTableViewCellDelegate + ) } - 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: " ") - }() - } +// 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/Onboarding/CategoryPickerItem.swift b/Mastodon/Diffiable/Onboarding/CategoryPickerItem.swift index 53f9c9ab..f6f36410 100644 --- a/Mastodon/Diffiable/Onboarding/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 { diff --git a/Mastodon/Diffiable/Onboarding/CategoryPickerSection.swift b/Mastodon/Diffiable/Onboarding/CategoryPickerSection.swift index 525d7720..b53b378d 100644 --- a/Mastodon/Diffiable/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 diff --git a/Mastodon/Diffiable/Onboarding/ServerRuleSection.swift b/Mastodon/Diffiable/Onboarding/ServerRuleSection.swift index 66abec44..c13e4ab2 100644 --- a/Mastodon/Diffiable/Onboarding/ServerRuleSection.swift +++ b/Mastodon/Diffiable/Onboarding/ServerRuleSection.swift @@ -6,6 +6,8 @@ // import UIKit +import MastodonAsset +import MastodonLocalization enum ServerRuleSection: Hashable { case header diff --git a/Mastodon/Diffiable/Poll/PollItem.swift b/Mastodon/Diffiable/Poll/PollItem.swift deleted file mode 100644 index 0622e1d3..00000000 --- a/Mastodon/Diffiable/Poll/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/Poll/PollSection.swift b/Mastodon/Diffiable/Poll/PollSection.swift deleted file mode 100644 index 682a2abc..00000000 --- a/Mastodon/Diffiable/Poll/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 { - return UITableViewDiffableDataSource(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/Profile/ProfileFieldItem.swift b/Mastodon/Diffiable/Profile/ProfileFieldItem.swift index 781da185..60651d72 100644 --- a/Mastodon/Diffiable/Profile/ProfileFieldItem.swift +++ b/Mastodon/Diffiable/Profile/ProfileFieldItem.swift @@ -10,24 +10,10 @@ 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 - } - } +enum ProfileFieldItem: Hashable { + case field(field: FieldValue) + case editField(field: FieldValue) + case addEntry } extension ProfileFieldItem { @@ -36,17 +22,29 @@ extension ProfileFieldItem { var name: CurrentValueSubject var value: CurrentValueSubject + + let emojiMeta: MastodonContent.Emojis - init(id: UUID = UUID(), name: String, value: String) { + 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 { + 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) { @@ -54,50 +52,3 @@ extension ProfileFieldItem { } } } - -extension ProfileFieldItem { - class FieldItemAttribute: Equatable, ProfileFieldListSeparatorLineConfigurable { - let emojiMeta = CurrentValueSubject([:]) - - 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/Profile/ProfileFieldSection.swift b/Mastodon/Diffiable/Profile/ProfileFieldSection.swift index e96a16e9..fc2dee15 100644 --- a/Mastodon/Diffiable/Profile/ProfileFieldSection.swift +++ b/Mastodon/Diffiable/Profile/ProfileFieldSection.swift @@ -9,125 +9,124 @@ import os import UIKit import Combine import MastodonMeta +import MastodonLocalization enum ProfileFieldSection: Equatable, Hashable { case main } extension ProfileFieldSection { - static func collectionViewDiffableDataSource( - for collectionView: UICollectionView, - profileFieldCollectionViewCellDelegate: ProfileFieldCollectionViewCellDelegate, - profileFieldAddEntryCollectionViewCellDelegate: ProfileFieldAddEntryCollectionViewCellDelegate + + struct Configuration { + weak var profileFieldCollectionViewCellDelegate: ProfileFieldCollectionViewCellDelegate? + weak var profileFieldEditCollectionViewCellDelegate: ProfileFieldEditCollectionViewCellDelegate? + } + + static func diffableDataSource( + collectionView: UICollectionView, + context: AppContext, + configuration: Configuration ) -> UICollectionViewDiffableDataSource { - let dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { - [ - weak profileFieldCollectionViewCellDelegate, - weak profileFieldAddEntryCollectionViewCellDelegate - ] collectionView, indexPath, item in + collectionView.register(ProfileFieldCollectionViewHeaderFooterView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: ProfileFieldCollectionViewHeaderFooterView.headerReuseIdentifer) + collectionView.register(ProfileFieldCollectionViewHeaderFooterView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter, withReuseIdentifier: ProfileFieldCollectionViewHeaderFooterView.footerReuseIdentifer) + + let fieldCellRegistration = UICollectionView.CellRegistration { 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 { 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 { 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 dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { 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() + case .field: + return collectionView.dequeueConfiguredReusableCell( + using: fieldCellRegistration, + for: indexPath, + item: item ) - .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() + case .editField: + return collectionView.dequeueConfiguredReusableCell( + using: editFieldCellRegistration, + for: indexPath, + item: item + ) + case .addEntry: + return collectionView.dequeueConfiguredReusableCell( + using: addEntryCellRegistration, + for: indexPath, + item: item ) - .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 } } @@ -135,6 +134,7 @@ extension ProfileFieldSection { 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 diff --git a/Mastodon/Diffiable/RecommandAccount/RecommendAccountSection.swift b/Mastodon/Diffiable/RecommandAccount/RecommendAccountSection.swift new file mode 100644 index 00000000..d4943d32 --- /dev/null +++ b/Mastodon/Diffiable/RecommandAccount/RecommendAccountSection.swift @@ -0,0 +1,150 @@ +// +// 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 { +// 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 { +// +// static func tableViewDiffableDataSource( +// for tableView: UITableView, +// managedObjectContext: NSManagedObjectContext, +// viewModel: SuggestionAccountViewModel, +// delegate: SuggestionAccountTableViewCellDelegate +// ) -> UITableViewDiffableDataSource { +// 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/Report/ReportItem.swift b/Mastodon/Diffiable/Report/ReportItem.swift new file mode 100644 index 00000000..3f6b5b75 --- /dev/null +++ b/Mastodon/Diffiable/Report/ReportItem.swift @@ -0,0 +1,12 @@ +// +// ReportItem.swift +// Mastodon +// +// Created by MainasuK on 2022-1-27. +// + +import Foundation + +enum ReportItem: Hashable { + +} diff --git a/Mastodon/Diffiable/Report/ReportSection.swift b/Mastodon/Diffiable/Report/ReportSection.swift new file mode 100644 index 00000000..9f2a1a09 --- /dev/null +++ b/Mastodon/Diffiable/Report/ReportSection.swift @@ -0,0 +1,70 @@ +// +// 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 +import MastodonAsset +import MastodonLocalization + +enum ReportSection: Equatable, Hashable { + case main +} + +extension ReportSection { + static func tableViewDiffableDataSource( + for tableView: UITableView, + dependency: ReportViewController, + managedObjectContext: NSManagedObjectContext, + timestampUpdatePublisher: AnyPublisher + ) -> UITableViewDiffableDataSource { + UITableViewDiffableDataSource(tableView: tableView) {[ + weak dependency + ] tableView, indexPath, item -> UITableViewCell? in + return UITableViewCell() + 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/Search/RecommendAccountSection.swift b/Mastodon/Diffiable/Search/RecommendAccountSection.swift deleted file mode 100644 index 3d6cff19..00000000 --- a/Mastodon/Diffiable/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 { - 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 { - 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/Search/RecommendHashTagSection.swift b/Mastodon/Diffiable/Search/RecommendHashTagSection.swift deleted file mode 100644 index 50208691..00000000 --- a/Mastodon/Diffiable/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 { - 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/Search/SearchHistoryItem.swift b/Mastodon/Diffiable/Search/SearchHistoryItem.swift index de97eae3..ae156a81 100644 --- a/Mastodon/Diffiable/Search/SearchHistoryItem.swift +++ b/Mastodon/Diffiable/Search/SearchHistoryItem.swift @@ -7,35 +7,9 @@ import Foundation import CoreData +import CoreDataStack -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) - } - } +enum SearchHistoryItem: Hashable { + case hashtag(ManagedObjectRecord) + case user(ManagedObjectRecord) } diff --git a/Mastodon/Diffiable/Search/SearchHistorySection.swift b/Mastodon/Diffiable/Search/SearchHistorySection.swift index b5c5cd8c..dba1dc18 100644 --- a/Mastodon/Diffiable/Search/SearchHistorySection.swift +++ b/Mastodon/Diffiable/Search/SearchHistorySection.swift @@ -13,28 +13,80 @@ enum SearchHistorySection: Hashable { } extension SearchHistorySection { - static func tableViewDiffableDataSource( - for tableView: UITableView, - dependency: NeedsDependency - ) -> UITableViewDiffableDataSource { - UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item -> UITableViewCell? in + + struct Configuration { + weak var searchHistorySectionHeaderCollectionReusableViewDelegate: SearchHistorySectionHeaderCollectionReusableViewDelegate? + } + + static func diffableDataSource( + collectionView: UICollectionView, + context: AppContext, + configuration: Configuration + ) -> UICollectionViewDiffableDataSource { + + let userCellRegister = UICollectionView.CellRegistration> { 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> { 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(collectionView: collectionView) { collectionView, indexPath, item 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 + 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(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 00000000..35d95113 --- /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 index 7f57c435..81383692 100644 --- a/Mastodon/Diffiable/Search/SearchResultItem.swift +++ b/Mastodon/Diffiable/Search/SearchResultItem.swift @@ -5,14 +5,15 @@ // Created by sxiaojian on 2021/4/6. // -import CoreData import Foundation +import CoreData +import CoreDataStack import MastodonSDK -enum SearchResultItem { +enum SearchResultItem: Hashable { + case user(ManagedObjectRecord) + case status(ManagedObjectRecord) case hashtag(tag: Mastodon.Entity.Tag) - case account(account: Mastodon.Entity.Account) - case status(statusObjectID: NSManagedObjectID, attribute: Item.StatusAttribute) case bottomLoader(attribute: BottomLoaderAttribute) } @@ -26,7 +27,10 @@ extension SearchResultItem { self.isNoResult = isEmptyResult } - static func == (lhs: SearchResultItem.BottomLoaderAttribute, rhs: SearchResultItem.BottomLoaderAttribute) -> Bool { + static func == ( + lhs: SearchResultItem.BottomLoaderAttribute, + rhs: SearchResultItem.BottomLoaderAttribute + ) -> Bool { return lhs.id == rhs.id } @@ -35,60 +39,3 @@ extension SearchResultItem { } } } - -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/Search/SearchResultSection.swift b/Mastodon/Diffiable/Search/SearchResultSection.swift index dcc52e15..1b1ac3ec 100644 --- a/Mastodon/Diffiable/Search/SearchResultSection.swift +++ b/Mastodon/Diffiable/Search/SearchResultSection.swift @@ -5,51 +5,70 @@ // 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: Equatable, Hashable { +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( - for tableView: UITableView, - dependency: NeedsDependency, - statusTableViewCellDelegate: StatusTableViewCellDelegate + tableView: UITableView, + context: AppContext, + configuration: Configuration ) -> UITableViewDiffableDataSource { - UITableViewDiffableDataSource(tableView: tableView) { [ - weak statusTableViewCellDelegate - ] tableView, indexPath, item -> UITableViewCell? in + 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 .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, + 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, - timelineContext: .search, - dependency: dependency, - readableLayoutFrame: tableView.readableContentGuide.layoutFrame, - status: status, - requestUserID: requestUserID, - statusItemAttribute: attribute + cell: cell, + viewModel: .init(value: .user(user)), + configuration: configuration ) } - cell.delegate = statusTableViewCellDelegate + 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 @@ -63,7 +82,49 @@ extension SearchResultSection { cell.loadMoreLabel.isHidden = true } return cell - } // end switch + } } // 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 00000000..38c87a76 --- /dev/null +++ b/Mastodon/Diffiable/Search/SearchSection.swift @@ -0,0 +1,76 @@ +// +// 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 { + + let trendCellRegister = UICollectionView.CellRegistration { cell, indexPath, item in + cell.primaryLabel.text = "#" + item.name + cell.secondaryLabel.text = L10n.Scene.Search.Recommend.HashTag.peopleTalking(item.talkingPeopleCount ?? 0) + + 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) + } + } + + let dataSource = UICollectionViewDiffableDataSource( + 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(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/Settings/SettingsItem.swift b/Mastodon/Diffiable/Settings/SettingsItem.swift index ed472808..99c956e7 100644 --- a/Mastodon/Diffiable/Settings/SettingsItem.swift +++ b/Mastodon/Diffiable/Settings/SettingsItem.swift @@ -7,6 +7,8 @@ import UIKit import CoreData +import MastodonAsset +import MastodonLocalization enum SettingsItem { case appearance(settingObjectID: NSManagedObjectID) diff --git a/Mastodon/Diffiable/Settings/SettingsSection.swift b/Mastodon/Diffiable/Settings/SettingsSection.swift index f59c1358..ab0ec4e8 100644 --- a/Mastodon/Diffiable/Settings/SettingsSection.swift +++ b/Mastodon/Diffiable/Settings/SettingsSection.swift @@ -8,6 +8,8 @@ import UIKit import CoreData import CoreDataStack +import MastodonAsset +import MastodonLocalization enum SettingsSection: Hashable { case appearance diff --git a/Mastodon/Diffiable/Status/Item.swift b/Mastodon/Diffiable/Status/Item.swift deleted file mode 100644 index 220a7fdb..00000000 --- a/Mastodon/Diffiable/Status/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(false) - - /// flag for current sensitive content reveal state - /// - /// - true: displaying sensitive content - /// - false: displaying content warning overlay - let isRevealing = CurrentValueSubject(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/Status/ReportSection.swift b/Mastodon/Diffiable/Status/ReportSection.swift deleted file mode 100644 index 5da10c39..00000000 --- a/Mastodon/Diffiable/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 - ) -> UITableViewDiffableDataSource { - 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/Status/StatusItem.swift b/Mastodon/Diffiable/Status/StatusItem.swift new file mode 100644 index 00000000..c10e410d --- /dev/null +++ b/Mastodon/Diffiable/Status/StatusItem.swift @@ -0,0 +1,84 @@ +// +// StatusItem.swift +// Mastodon +// +// Created by MainasuK on 2022-1-11. +// + +import Foundation +import CoreDataStack + +enum StatusItem: Hashable { + case feed(record: ManagedObjectRecord) + case feedLoader(record: ManagedObjectRecord) + case status(record: ManagedObjectRecord) + // case statusLoader(record: ManagedObjectRecord, context: StatusLoaderContext) + case thread(Thread) + case topLoader + case bottomLoader +} + +//extension StatusItem { +// final class StatusLoaderContext: Hashable { +// let id = UUID() +// @Published var isFetching = false +// +// static func == ( +// lhs: StatusItem.StatusLoaderContext, +// rhs: StatusItem.StatusLoaderContext +// ) -> Bool { +// return lhs.id == rhs.id +// } +// +// func hash(into hasher: inout Hasher) { +// hasher.combine(id) +// } +// } +//} + +extension StatusItem { + enum Thread: Hashable { + case root(context: Context) + case reply(context: Context) + case leaf(context: Context) + + public var record: ManagedObjectRecord { + switch self { + case .root(let threadContext), + .reply(let threadContext), + .leaf(let threadContext): + return threadContext.status + } + } + } +} + +extension StatusItem.Thread { + class Context: Hashable { + let status: ManagedObjectRecord + var displayUpperConversationLink: Bool + var displayBottomConversationLink: Bool + + init( + status: ManagedObjectRecord, + 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 index 918b8b45..d3253ea7 100644 --- a/Mastodon/Diffiable/Status/StatusSection.swift +++ b/Mastodon/Diffiable/Status/StatusSection.swift @@ -15,13 +15,14 @@ import AlamofireImage import MastodonMeta import MastodonSDK import NaturalLanguage +import MastodonUI // import LinkPresentation -protocol StatusCell: DisposeBagCollectable { - var statusView: StatusView { get } - var isFiltered: Bool { get set } -} +//protocol StatusCell: DisposeBagCollectable { +// var statusView: StatusView { get } +// var isFiltered: Bool { get set } +//} enum StatusSection: Equatable, Hashable { case main @@ -30,157 +31,277 @@ enum StatusSection: Equatable, Hashable { extension StatusSection { static let logger = Logger(subsystem: "StatusSection", category: "logic") + + struct Configuration { + weak var statusTableViewCellDelegate: StatusTableViewCellDelegate? + weak var timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate? + } - static func tableViewDiffableDataSource( - for tableView: UITableView, - timelineContext: TimelineContext, - dependency: NeedsDependency, - managedObjectContext: NSManagedObjectContext, - statusTableViewCellDelegate: StatusTableViewCellDelegate, - timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate?, - threadReplyLoaderTableViewCellDelegate: ThreadReplyLoaderTableViewCellDelegate? - ) -> UITableViewDiffableDataSource { - 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() } + static func diffableDataSource( + tableView: UITableView, + context: AppContext, + configuration: Configuration + ) -> UITableViewDiffableDataSource { + 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 .homeTimelineIndex(objectID: let objectID, let attribute): + case .feed(let record): 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 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, + context.managedObjectContext.performAndWait { + guard let feed = record.object(in: context.managedObjectContext) else { return } + configure( + context: context, tableView: tableView, - timelineContext: timelineContext, - dependency: dependency, - readableLayoutFrame: tableView.readableContentGuide.layoutFrame, - status: status, - requestUserID: requestUserID, - statusItemAttribute: attribute + cell: cell, + viewModel: StatusTableViewCell.ViewModel(value: .feed(feed)), + configuration: configuration ) - - 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): + case .feedLoader(let record): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineMiddleLoaderTableViewCell.self), for: indexPath) as! TimelineMiddleLoaderTableViewCell - cell.delegate = timelineMiddleLoaderTableViewCellDelegate - timelineMiddleLoaderTableViewCellDelegate?.configure(cell: cell, upperTimelineStatusID: upperTimelineStatusID, timelineIndexobjectID: nil) + context.managedObjectContext.performAndWait { + guard let feed = record.object(in: context.managedObjectContext) else { return } + configure( + cell: cell, + feed: feed, + configuration: configuration + ) + } 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) + 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.startAnimating() + cell.activityIndicatorView.startAnimating() return cell case .bottomLoader: let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell - cell.startAnimating() + cell.activityIndicatorView.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() } } + } // 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(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 = .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() + _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 + ) + } + + 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 + ) + } + + static func configure( + cell: TimelineMiddleLoaderTableViewCell, + feed: Feed, + configuration: Configuration + ) { + cell.configure( + feed: feed, + delegate: configuration.timelineMiddleLoaderTableViewCellDelegate + ) + } + } extension StatusSection { @@ -272,900 +393,6 @@ extension StatusSection { } -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 ?? "") - } - } - .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() - 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") diff --git a/Mastodon/Diffiable/User/UserItem.swift b/Mastodon/Diffiable/User/UserItem.swift index bd15f35e..ff533d89 100644 --- a/Mastodon/Diffiable/User/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) case bottomLoader case bottomHeader(text: String) } diff --git a/Mastodon/Diffiable/User/UserSection.swift b/Mastodon/Diffiable/User/UserSection.swift index 9c7e2f21..a42110d7 100644 --- a/Mastodon/Diffiable/User/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 { - 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 e17f9bfe..00000000 --- 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 c318e8ed..00000000 --- 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 5674c08b..00000000 --- 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 1e4e542f..00000000 --- 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 f914c864..02a98368 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 d954563a..00000000 --- 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 1c329c85..2e0cf516 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 { + return .init(objectID: self.objectID) } } diff --git a/Mastodon/Extension/Date.swift b/Mastodon/Extension/Date.swift index 51d70cc0..ea4202a7 100644 --- a/Mastodon/Extension/Date.swift +++ b/Mastodon/Extension/Date.swift @@ -2,30 +2,31 @@ // Date.swift // Mastodon // -// Created by MainasuK Cirno on 2021-6-1. +// Created by MainasuK on 2022-1-12. // 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 @@ -41,54 +42,3 @@ extension Date { } } - -extension Date { - - func localizedShortTimeAgo(since date: Date) -> String { - let earlierDate = date < self ? date : self - let latestDate = earlierDate == date ? self : date - - let components = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute, .second], from: earlierDate, to: latestDate) - - if components.year! > 0 { - return L10n.Date.Year.Ago.abbr(components.year!) - } else if components.month! > 0 { - return L10n.Date.Month.Ago.abbr(components.month!) - } else if components.day! > 0 { - return L10n.Date.Day.Ago.abbr(components.day!) - } else if components.hour! > 0 { - return L10n.Date.Hour.Ago.abbr(components.hour!) - } else if components.minute! > 0 { - return L10n.Date.Minute.Ago.abbr(components.minute!) - } else if components.second! > 0 { - return L10n.Date.Year.Ago.abbr(components.second!) - } else { - return "" - } - } - - func localizedTimeLeft() -> String { - let date = Date() - let earlierDate = date < self ? date : self - let latestDate = earlierDate == date ? self : date - - let components = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute, .second], from: earlierDate, to: latestDate) - - if components.year! > 0 { - return L10n.Date.Year.left(components.year!) - } else if components.month! > 0 { - return L10n.Date.Month.left(components.month!) - } else if components.day! > 0 { - return L10n.Date.Day.left(components.day!) - } else if components.hour! > 0 { - return L10n.Date.Hour.left(components.hour!) - } else if components.minute! > 0 { - return L10n.Date.Minute.left(components.minute!) - } else if components.second! > 0 { - return L10n.Date.Year.left(components.second!) - } else { - return "" - } - } - -} diff --git a/Mastodon/Extension/FLAnimatedImageView.swift b/Mastodon/Extension/FLAnimatedImageView.swift index 1e6e62ad..c913cd2a 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 24bbfdac..e85c8263 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 312e4e3f..b3771632 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 2bddd9e9..2c5a2e46 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 caf819b3..2d0be696 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 3d96f97c..74bdd2ed 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 0f43dced..d4814b7e 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 410d81a2..00000000 --- a/Mastodon/Generated/Assets.swift +++ /dev/null @@ -1,272 +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 primaryReverse = ColorAsset(name: "Colors/Label/primary.reverse") - 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 Onboarding { - internal static let avatarPlaceholder = ImageAsset(name: "Scene/Onboarding/avatar.placeholder") - internal static let navigationBackButtonBackground = ColorAsset(name: "Scene/Onboarding/navigation.back.button.background") - internal static let navigationBackButtonBackgroundHighlighted = ColorAsset(name: "Scene/Onboarding/navigation.back.button.background.highlighted") - internal static let navigationNextButtonBackground = ColorAsset(name: "Scene/Onboarding/navigation.next.button.background") - internal static let navigationNextButtonBackgroundHighlighted = ColorAsset(name: "Scene/Onboarding/navigation.next.button.background.highlighted") - internal static let onboardingBackground = ColorAsset(name: "Scene/Onboarding/onboarding.background") - internal static let searchBarBackground = ColorAsset(name: "Scene/Onboarding/search.bar.background") - internal static let textFieldBackground = ColorAsset(name: "Scene/Onboarding/textField.background") - } - 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 cloudBaseExtend = ImageAsset(name: "Scene/Welcome/illustration/cloud.base.extend") - 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 elephantThreeOnGrassExtend = ImageAsset(name: "Scene/Welcome/illustration/elephant.three.on.grass.extend") - 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 static let signInButtonBackground = ColorAsset(name: "Scene/Welcome/sign.in.button.background") - } - } - 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 00000000..a771d346 --- /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 00000000..ae7cb25a --- /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 00000000..b8c092f6 --- /dev/null +++ b/Mastodon/Generated/AutoGenerateTableViewDelegate.generated.swift @@ -0,0 +1,20 @@ +// Generated using Sourcery 1.6.1 — https://github.com/krzysztofzablocki/Sourcery +// DO NOT EDIT + + +// sourcery:inline:FollowingListViewController.AutoGenerateTableViewDelegate + +// Generated using Sourcery +// DO NOT EDIT +func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + aspectTableView(tableView, didSelectRowAt: indexPath) +} + +// sourcery:end + + + + + + + diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift deleted file mode 100644 index ebf9869c..00000000 --- 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 71ba50b5..31c9649c 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 let domain: String let userID: MastodonUser.ID let appAuthorization: Mastodon.API.OAuth.Authorization diff --git a/Mastodon/Info.plist b/Mastodon/Info.plist index 8f0b7211..693c5c6d 100644 --- a/Mastodon/Info.plist +++ b/Mastodon/Info.plist @@ -30,7 +30,7 @@ CFBundleVersion - 90 + 91 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/Mastodon/Persistence/Extension/MastodonEmoji.swift b/Mastodon/Persistence/Extension/MastodonEmoji.swift new file mode 100644 index 00000000..e9274a24 --- /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 00000000..4fa2ef97 --- /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 00000000..6c3df37a --- /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 00000000..cebe7f8a --- /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 00000000..4d125bd5 --- /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 00000000..f703d8e5 --- /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 00000000..4fa62979 --- /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 00000000..c4508a99 --- /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 00000000..633f7bdd --- /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 00000000..1406f75a --- /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? + public let networkDate: Date + public let log = OSLog.api + + public init( + domain: String, + entity: Mastodon.Entity.Account, + cache: Persistence.PersistCache?, + 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 00000000..b8c2f27f --- /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 00000000..1d6802aa --- /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 00000000..1e284ac7 --- /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 00000000..58d4c8fb --- /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 00000000..b20df149 --- /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? + public let userCache: Persistence.PersistCache? + public let networkDate: Date + public let log = OSLog.api + + public init( + domain: String, + entity: Mastodon.Entity.Status, + me: MastodonUser?, + statusCache: Persistence.PersistCache?, + userCache: Persistence.PersistCache?, + 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 00000000..7092a52c --- /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 00000000..350b603c --- /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 { + 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 00000000..e3bb62f6 --- /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 00000000..fe1d2994 --- /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 00000000..75cae757 --- /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 d771fa5a..00000000 --- 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/LoadMoreConfigurableTableViewContainer.swift b/Mastodon/Protocol/LoadMoreConfigurableTableViewContainer.swift deleted file mode 100644 index 4f32be54..00000000 --- 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 00000000..edf6265e --- /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/Provider/DataSourceFacade+Block.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Block.swift new file mode 100644 index 00000000..a1bf3136 --- /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, + 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 00000000..a248ed42 --- /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, + 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 00000000..b4f2362c --- /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, + 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 00000000..7abde62f --- /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 + ) 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 00000000..329a7f39 --- /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, + 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, + 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 00000000..bf54f70a --- /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, + 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, + 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 00000000..66110764 --- /dev/null +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Model.swift @@ -0,0 +1,53 @@ +// +// DataSourceFacade+Model.swift +// Mastodon +// +// Created by MainasuK on 2022-1-13. +// + +import Foundation +import CoreData +import CoreDataStack + +extension DataSourceFacade { + static func status( + managedObjectContext: NSManagedObjectContext, + status: ManagedObjectRecord, + target: StatusTarget + ) async -> ManagedObjectRecord? { + return try? await managedObjectContext.perform { + guard let object = status.object(in: managedObjectContext) else { return nil } + return DataSourceFacade.status(status: object, target: target) + .flatMap { ManagedObjectRecord(objectID: $0.objectID) } + } + } +} + +extension DataSourceFacade { + static func author( + managedObjectContext: NSManagedObjectContext, + status: ManagedObjectRecord, + target: StatusTarget + ) async -> ManagedObjectRecord? { + 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(objectID: $0.objectID) } + } + } +} + +extension DataSourceFacade { + static func status( + status: Status, + target: StatusTarget + ) -> Status? { + switch target { + case .status: + return status.reblog ?? status + case .repost: + return status + } + } +} diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Mute.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Mute.swift new file mode 100644 index 00000000..421d5046 --- /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, + 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 00000000..36eaab62 --- /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 + ) 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 + ) 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, + 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 + ) -> 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 + ) 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 00000000..359b285d --- /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, + 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 00000000..cbc6bf34 --- /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 00000000..5d3b0695 --- /dev/null +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift @@ -0,0 +1,259 @@ +// +// DataSourceFacade+Status.swift +// Mastodon +// +// Created by MainasuK on 2022-1-17. +// + +import UIKit +import CoreDataStack +import MastodonUI +import MastodonLocalization + +extension DataSourceFacade { + + static func responseToDeleteStatus( + dependency: NeedsDependency, + status: ManagedObjectRecord, + authenticationBox: MastodonAuthenticationBox + ) async throws { + _ = try await dependency.context.apiService.deleteStatus( + status: status, + authenticationBox: authenticationBox + ) + } + +} + +extension DataSourceFacade { + + @MainActor + public static func responseToStatusShareAction( + provider: DataSourceProvider, + status: ManagedObjectRecord, + 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 + ) 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 + } +} + +extension DataSourceFacade { + @MainActor + static func responseToActionToolbar( + provider: DataSourceProvider, + status: ManagedObjectRecord, + action: ActionToolbarContainer.Action, + authenticationBox: MastodonAuthenticationBox, + sender: UIButton + ) async throws { + let managedObjectContext = provider.context.managedObjectContext + let _status: ManagedObjectRecord? = 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 + +} + +extension DataSourceFacade { + + struct MenuContext { + let author: ManagedObjectRecord? + let status: ManagedObjectRecord? + 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? = try? await managedObjectContext.perform { + guard let user = menuContext.author?.object(in: managedObjectContext) else { return nil } + return ManagedObjectRecord(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? = try? await managedObjectContext.perform { + guard let user = menuContext.author?.object(in: managedObjectContext) else { return nil } + return ManagedObjectRecord(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: + assertionFailure() + 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 +} diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Thread.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Thread.swift new file mode 100644 index 00000000..26950421 --- /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 + ) 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 00000000..809aab42 --- /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 repost wrapper + case repost // keep repost wrapper + } +} diff --git a/Mastodon/Protocol/Provider/DataSourceProvider+NotificationTableViewCellDelegate.swift b/Mastodon/Protocol/Provider/DataSourceProvider+NotificationTableViewCellDelegate.swift new file mode 100644 index 00000000..dff1e5f0 --- /dev/null +++ b/Mastodon/Protocol/Provider/DataSourceProvider+NotificationTableViewCellDelegate.swift @@ -0,0 +1,225 @@ +// +// 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? = 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? = 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 + } +} + +// 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? = 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? = 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 status data provider") + return + } + let _status: ManagedObjectRecord? = 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 + ) + } + +} diff --git a/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewCellDelegate.swift new file mode 100644 index 00000000..9c5d3912 --- /dev/null +++ b/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewCellDelegate.swift @@ -0,0 +1,344 @@ +// +// DataSourceProvider+StatusTableViewCellDelegate.swift +// Mastodon +// +// Created by MainasuK on 2022-1-13. +// + +import UIKit +import MetaTextKit +import MastodonUI +import CoreDataStack + +// 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 + } + await DataSourceFacade.coordinateToProfileScene( + provider: self, + target: .status, // without reblog header + 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 + } + 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? + 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? + 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 + } + +} + +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? = 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 + } + +} diff --git a/Mastodon/Protocol/Provider/DataSourceProvider+UITableViewDelegate.swift b/Mastodon/Protocol/Provider/DataSourceProvider+UITableViewDelegate.swift new file mode 100644 index 00000000..3968df11 --- /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? = 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? = 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 00000000..425e4041 --- /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) + case user(record: ManagedObjectRecord) + case hashtag(tag: TagKind) + case notification(record: ManagedObjectRecord) +} + +extension DataSourceItem { + enum TagKind: Hashable { + case entity(Mastodon.Entity.Tag) + case record(ManagedObjectRecord) + } +} + +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/SegmentedControlNavigateable.swift b/Mastodon/Protocol/SegmentedControlNavigateable.swift index ed76de21..097e0b3a 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+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift deleted file mode 100644 index 5dab0529..00000000 --- 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, 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, 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 4503057a..00000000 --- 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+TableViewControllerNavigateable.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+TableViewControllerNavigateable.swift deleted file mode 100644 index 8be4acd5..00000000 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+TableViewControllerNavigateable.swift +++ /dev/null @@ -1,153 +0,0 @@ -// -// StatusProvider+TableViewControllerNavigateable.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-5-21. -// - -import os.log -import UIKit - -extension TableViewControllerNavigateableCore where Self: TableViewControllerNavigateableRelay { - var navigationKeyCommands: [UIKeyCommand] { - TableViewNavigation.allCases.map { navigation in - UIKeyCommand( - title: navigation.title, - image: nil, - action: #selector(Self.navigateKeyCommandHandlerRelay(_:)), - input: navigation.input, - modifierFlags: navigation.modifierFlags, - propertyList: navigation.propertyList, - alternates: [], - discoverabilityTitle: nil, - attributes: [], - state: .off - ) - } - } -} - -extension TableViewControllerNavigateableCore { - - func navigateKeyCommandHandler(_ sender: UIKeyCommand) { - guard let rawValue = sender.propertyList as? String, - let navigation = TableViewNavigation(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 .up: navigate(direction: .up) - case .down: navigate(direction: .down) - case .back: back() - case .open: open() - } - } - -} - - -// navigate status up/down -extension TableViewControllerNavigateableCore where Self: StatusProvider { - - 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 = 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.. 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: Item) -> Bool { - switch item { - case .homeTimelineIndex, - .status, - .root, .leaf, .reply: - return true - default: - return false - } - } - -} - -extension TableViewControllerNavigateableCore { - // check is visible and not the first and last - static func navigateScrollPosition(tableView: UITableView, indexPath: IndexPath) -> UITableView.ScrollPosition { - let middleVisibleIndexPaths = (tableView.indexPathsForVisibleRows ?? []) - .sorted() - .dropFirst() - .dropLast() - guard middleVisibleIndexPaths.contains(indexPath) else { - return .top - } - guard middleVisibleIndexPaths.count > 2 else { - return .middle - } - return .none - } - -} - -extension TableViewControllerNavigateableCore where Self: StatusProvider { - func open() { - guard let indexPathForSelectedRow = tableView.indexPathForSelectedRow else { return } - StatusProviderFacade.coordinateToStatusThreadScene(for: .primary, provider: self, indexPath: indexPathForSelectedRow) - } -} - -extension TableViewControllerNavigateableCore where Self: UIViewController { - func back() { - UserDefaults.shared.backKeyCommandPressDate = Date() - navigationController?.popViewController(animated: true) - } -} diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDataSourcePrefetching.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDataSourcePrefetching.swift deleted file mode 100644 index 537f10c8..00000000 --- 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 1abfcf70..00000000 --- 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, 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, - 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? 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? 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, index: Int) -> AnyPublisher { - 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 2f13b8d5..00000000 --- a/Mastodon/Protocol/StatusProvider/StatusProvider.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// StatusProvider.swift -// Mastodon -// -// Created by sxiaojian on 2021/2/5. -// - -import UIKit -import Combine -import CoreData -import CoreDataStack - -protocol StatusProvider: NeedsDependency & DisposeBagCollectable & UIViewController { - // async - func status() -> Future - func status(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future - func status(for cell: UICollectionViewCell) -> Future - - // sync - var managedObjectContext: NSManagedObjectContext { get } - - @available(*, deprecated) - var tableViewDiffableDataSource: UITableViewDiffableDataSource? { get } - @available(*, deprecated) - func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item? - @available(*, deprecated) - func items(indexPaths: [IndexPath]) -> [Item] - - func statusObjectItems(indexPaths: [IndexPath]) -> [StatusObjectItem] -} - -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 68987c30..00000000 --- a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift +++ /dev/null @@ -1,636 +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 - -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 - .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 - .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 - } - } - - 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) { - // 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) { - // 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 - .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 - .compactMap { [weak dependency] status -> AnyPublisher? 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 { 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 index fbaf7665..e0e9a8fd 100644 --- a/Mastodon/Protocol/StatusTableViewControllerAspect.swift +++ b/Mastodon/Protocol/StatusTableViewControllerAspect.swift @@ -1,182 +1,182 @@ +//// +//// StatusTableViewControllerAspect.swift +//// Mastodon +//// +//// Created by MainasuK Cirno on 2021-4-7. +//// // -// StatusTableViewControllerAspect.swift -// Mastodon +//import UIKit +//import AVKit +//import GameController // -// Created by MainasuK Cirno on 2021-4-7. +//// 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) +// } +//} // - -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 ad869fbd..a35fae7b 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 8ae7398c..00000000 --- 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 { 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 a70ab701..4189d0cf 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 f9939c74..00000000 --- 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 - - func mastodonUser(for cell: UITableViewCell?) -> Future -} - -extension UserProvider where Self: StatusProvider { - func mastodonUser(for cell: UITableViewCell?) -> Future { - 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 { - 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 a6e3cf21..00000000 --- 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 edbe311c..00000000 --- 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, 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, 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 - ) -> AnyPublisher, Error> { - mastodonUser - .compactMap { mastodonUser -> AnyPublisher, 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, 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, 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 - ) -> AnyPublisher, Error> { - mastodonUser - .compactMap { mastodonUser -> AnyPublisher, 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, 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, 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 - ) -> AnyPublisher, Error> { - mastodonUser - .compactMap { mastodonUser -> AnyPublisher, 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) { - 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 a85c0e37..00000000 --- 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 6464e2d9..00000000 --- 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 b77cb3c7..00000000 --- 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 303021b9..00000000 --- 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 ea5d9760..00000000 --- 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 0b0fa36c..00000000 --- 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/Onboarding/navigation.back.button.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Onboarding/navigation.back.button.background.colorset/Contents.json deleted file mode 100644 index b7d63ece..00000000 --- a/Mastodon/Resources/Assets.xcassets/Scene/Onboarding/navigation.back.button.background.colorset/Contents.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0xFF", - "green" : "0xFF", - "red" : "0xFF" - } - }, - "idiom" : "universal" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "0.200", - "blue" : "0x80", - "green" : "0x78", - "red" : "0x78" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Onboarding/navigation.next.button.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Onboarding/navigation.next.button.background.colorset/Contents.json deleted file mode 100644 index 17ed9364..00000000 --- a/Mastodon/Resources/Assets.xcassets/Scene/Onboarding/navigation.next.button.background.colorset/Contents.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0x37", - "green" : "0x2C", - "red" : "0x28" - } - }, - "idiom" : "universal" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0xEE", - "green" : "0xEE", - "red" : "0xEE" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Onboarding/navigation.next.button.background.highlighted.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Onboarding/navigation.next.button.background.highlighted.colorset/Contents.json deleted file mode 100644 index 706cd755..00000000 --- a/Mastodon/Resources/Assets.xcassets/Scene/Onboarding/navigation.next.button.background.highlighted.colorset/Contents.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0x1B", - "green" : "0x15", - "red" : "0x13" - } - }, - "idiom" : "universal" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0xBA", - "green" : "0xBA", - "red" : "0xBA" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Onboarding/onboarding.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Onboarding/onboarding.background.colorset/Contents.json deleted file mode 100644 index 0b219c90..00000000 --- a/Mastodon/Resources/Assets.xcassets/Scene/Onboarding/onboarding.background.colorset/Contents.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0xF7", - "green" : "0xF2", - "red" : "0xF2" - } - }, - "idiom" : "universal" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0x21", - "green" : "0x1B", - "red" : "0x19" - } - }, - "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 cd6391d8..00000000 --- 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/Scene/Welcome/sign.in.button.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/sign.in.button.background.colorset/Contents.json deleted file mode 100644 index 7bf1f1e4..00000000 --- a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/sign.in.button.background.colorset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0x81", - "green" : "0xAC", - "red" : "0x58" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} 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 91b8281d..00000000 --- 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 bfc2a11b..00000000 --- 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/content.warning.overlay.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Theme/system/Background/content.warning.overlay.background.colorset/Contents.json deleted file mode 100644 index c8aa45b5..00000000 --- a/Mastodon/Resources/Assets.xcassets/Theme/system/Background/content.warning.overlay.background.colorset/Contents.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0xEB", - "green" : "0xE4", - "red" : "0xDD" - } - }, - "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/Background/secondary.system.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Theme/system/Background/secondary.system.background.colorset/Contents.json deleted file mode 100644 index 14441ef0..00000000 --- a/Mastodon/Resources/Assets.xcassets/Theme/system/Background/secondary.system.background.colorset/Contents.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0xE8", - "green" : "0xE0", - "red" : "0xD9" - } - }, - "idiom" : "universal" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0x2E", - "green" : "0x2C", - "red" : "0x2C" - } - }, - "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 bfc2a11b..00000000 --- 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/eu-ES.lproj/InfoPlist.strings b/Mastodon/Resources/eu-ES.lproj/InfoPlist.strings new file mode 100644 index 00000000..71086557 --- /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/sv-FI.lproj/InfoPlist.strings b/Mastodon/Resources/sv-FI.lproj/InfoPlist.strings new file mode 100644 index 00000000..71086557 --- /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 1977b90e..83d0240f 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 fce9c732..42c9e1d6 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 f6ab7587..2b480464 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 0873c139..c641434e 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 6d92a847..a0101ef5 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 { diff --git a/Mastodon/Scene/Account/View/DragIndicatorView.swift b/Mastodon/Scene/Account/View/DragIndicatorView.swift index 5efa141b..9e0ab77d 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 4e59ce08..ebda78a1 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 ?? "")") + } + + @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 c1e7ab6a..b7c8fcec 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 fee6ce75..76f01112 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 e4569356..7d976bfd 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 7c8a6135..e2702e7c 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 ae90cd7b..7ea43f15 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 30d5986a..a43a5770 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 5968df42..8bff75b9 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,28 @@ 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 = { + configurePublishButtonApperance() let barButtonItem = UIBarButtonItem(customView: publishButton) 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 +123,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 +161,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 +235,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 +268,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 +345,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 +370,13 @@ extension ComposeViewController { .store(in: &disposeBag) // bind publish bar button state - viewModel.isPublishBarButtonItemEnabled + viewModel.$isPublishBarButtonItemEnabled .receive(on: DispatchQueue.main) .assign(to: \.isEnabled, on: publishBarButtonItem) .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 +386,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 +396,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 +415,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 +427,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 +439,7 @@ extension ComposeViewController { // bind visibility toolbar UI Publishers.CombineLatest( - viewModel.selectedStatusVisibility, + viewModel.$selectedStatusVisibility, viewModel.traitCollectionDidChangePublisher ) .receive(on: DispatchQueue.main) @@ -446,7 +452,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 +483,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 +497,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 +530,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 +546,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 +571,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 +648,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) } @@ -668,6 +665,7 @@ extension ComposeViewController { composeToolbarBackgroundView.backgroundColor = theme.composeToolbarBackgroundColor } + // keyboard shortcutBar private func setupInputAssistantItem(item: UITextInputAssistantItem) { let groups = [UIBarButtonItemGroup(barButtonItems: [ composeToolbarView.mediaBarButtonItem, @@ -705,7 +703,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 +738,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 +752,29 @@ 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 textViewDidBeginEditing(_ textView: UITextView) { + switch textView { + case textEditorView.textView: + setupInputAssistantItem(item: textView.inputAssistantItem) + default: + assertionFailure() + } + } 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 +795,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 +806,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 +817,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 +826,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 +884,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 } } @@ -916,17 +919,17 @@ extension ComposeViewController: ComposeToolbarViewDelegate { 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 +940,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 +974,7 @@ extension ComposeViewController { func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { 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 +987,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 +995,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 +1010,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 +1060,7 @@ extension ComposeViewController: UIAdaptivePresentationControllerDelegate { } func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool { - return viewModel.shouldDismiss.value + return viewModel.shouldDismiss } func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) { @@ -1081,11 +1084,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 +1103,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 +1122,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 +1137,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 +1171,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 +1204,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 +1263,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 +1281,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 +1418,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 7fd07bf8..c638eb76 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() + 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() - 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() - 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() - 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() + 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 8f739315..76139181 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, Error>] = { var subscriptions: [AnyPublisher, 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, Error> in + .asyncMap { attachments -> Mastodon.Response.Content 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 8cb54d88..16204306 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() @@ -23,17 +28,19 @@ final class ComposeViewModel: NSObject { // input let context: AppContext let composeKind: ComposeStatusSection.ComposeKind - let composeStatusAttribute = ComposeStatusItem.ComposeStatusAttribute() - let isPollComposing = CurrentValueSubject(false) - let isCustomEmojiComposing = CurrentValueSubject(false) - let isContentWarningComposing = CurrentValueSubject(false) - let selectedStatusVisibility: CurrentValueSubject - let activeAuthentication: CurrentValueSubject - let activeAuthenticationBox: CurrentValueSubject + 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()) // use CurrentValueSubject to make initial event emit - let repliedToCellFrame = CurrentValueSubject(.zero) - let autoCompleteRetryLayoutTimes = CurrentValueSubject(0) - let autoCompleteInfo = CurrentValueSubject(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! - var customEmojiPickerDiffableDataSource: UICollectionViewDiffableDataSource! + // var dataSource: UITableViewDiffableDataSource? + var customEmojiPickerDiffableDataSource: UICollectionViewDiffableDataSource? 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(UUID().uuidString) // UI & UX - let title: CurrentValueSubject - let shouldDismiss = CurrentValueSubject(true) - let isPublishBarButtonItemEnabled = CurrentValueSubject(false) - let isMediaToolbarButtonEnabled = CurrentValueSubject(true) - let isPollToolbarButtonEnabled = CurrentValueSubject(true) - let characterCount = CurrentValueSubject(0) - let collectionViewState = CurrentValueSubject(.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: "# " // for mention: "@ " - private(set) var preInsertedContent: String? + var preInsertedContent: String? // custom emojis - var customEmojiViewModelSubscription: AnyCancellable? - let customEmojiViewModel = CurrentValueSubject(nil) + let customEmojiViewModel: EmojiService.CustomEmojiViewModel? let customEmojiPickerInputViewModel = CustomEmojiPickerInputViewModel() - let isLoadingCustomEmoji = CurrentValueSubject(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 4ba68ced..f15675b2 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() - let statusView = ReplicaStatusView() + let statusView = StatusView() let framePublisher = PassthroughSubject() @@ -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 6d2bbe93..85c36fae 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 93% rename from Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentTableViewCell.swift rename to Mastodon/Scene/Compose/TableViewCell/ComposeStatusContentTableViewCell.swift index f44d29a6..34f34f02 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() 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 @@ -131,10 +135,10 @@ extension ComposeStatusContentTableViewCell { ]) statusContentWarningEditorView.textView.delegate = self - statusView.nameTrialingDotLabel.isHidden = true - statusView.dateLabel.isHidden = true - statusContentWarningEditorView.isHidden = true - statusView.statusContainerStackView.isHidden = true +// statusView.nameTrialingDotLabel.isHidden = true +// statusView.dateLabel.isHidden = true +// statusContentWarningEditorView.isHidden = true +// statusView.statusContainerStackView.isHidden = true } } diff --git a/Mastodon/Scene/Compose/TableViewCell/ComposeStatusPollTableViewCell.swift b/Mastodon/Scene/Compose/TableViewCell/ComposeStatusPollTableViewCell.swift index ac8d5094..f33a35c3 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 b441fa25..1d32931a 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 faa08559..4743c952 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 6b06973a..66bde158 100644 --- a/Mastodon/Scene/Compose/View/ComposeToolbarView.swift +++ b/Mastodon/Scene/Compose/View/ComposeToolbarView.swift @@ -9,6 +9,8 @@ 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) diff --git a/Mastodon/Scene/Compose/View/ReplicaStatusView.swift b/Mastodon/Scene/Compose/View/ReplicaStatusView.swift deleted file mode 100644 index 6f0527d5..00000000 --- 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 1ce274a5..83900c76 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 00000000..6cd97fcc --- /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 d7beaca6..00000000 --- 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 { - return Future { promise in promise(.success(nil)) } - } - - func status(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future { - 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 { - return Future { promise in promise(.success(nil)) } - } - - var managedObjectContext: NSManagedObjectContext { - return viewModel.context.managedObjectContext - } - - var tableViewDiffableDataSource: UITableViewDiffableDataSource? { - 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 72f084fa..73fc9678 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() - - var viewModel: HashtagTimelineViewModel! - let mediaPreviewTransitionController = MediaPreviewTransitionController() - + + var disposeBag = Set() + 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 { - return viewModel.cellFrameCache - } -} +//extension HashtagTimelineViewController: TableViewCellHeightCacheableContainer { +// var cellFrameCache: NSCache { +// 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:endz + +// 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 a601eb92..43add2d2 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift @@ -7,122 +7,57 @@ 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 + ) ) - var snapshot = NSDiffableDataSourceSnapshot() + var snapshot = NSDiffableDataSourceSnapshot() 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 - - 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() - 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() + 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 { - let targetIndexPath: IndexPath - let offset: CGFloat - } - - private func calculateReloadSnapshotDifference( - navigationBar: UINavigationBar, - tableView: UITableView, - oldSnapshot: NSDiffableDataSourceSnapshot, - newSnapshot: NSDiffableDataSourceSnapshot - ) -> Difference? { - 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 b2d121d5..00000000 --- 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 f458b86a..00000000 --- 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 13737364..eba85657 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 ?? "")") + 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 1bb76493..d63fad80 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(false) let timelinePredicate = CurrentValueSubject(nil) let hashtagEntity = CurrentValueSubject(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(nil) + var diffableDataSource: UITableViewDiffableDataSource? + let didLoadLatest = PassthroughSubject() + // 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(nil) - // middle loader - let loadMiddleSateMachineList = CurrentValueSubject<[NSManagedObjectID: GKStateMachine], Never>([:]) // TimelineIndex.objectID : middle loading state machine - var diffableDataSource: UITableViewDiffableDataSource? - var cellFrameCache = NSCache() - 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/HomeTimelineViewController+DataSourceProvider.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DataSourceProvider.swift new file mode 100644 index 00000000..b141d386 --- /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 6e75a17e..eb3e6fc0 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift @@ -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) @@ -87,45 +83,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…", @@ -206,190 +163,271 @@ 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 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 .feedLoader: 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 .feed(let record): +// guard let feed = record.object(in: context.managedObjectContext) else { return false } +// guard let status = feed.status?.reblog ?? feed.status else { return false } +// return status.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 snapshot = diffableDataSource.snapshot() - let droppingObjectIDs = snapshotTransitioning.itemIdentifiers.prefix(count).compactMap { item -> NSManagedObjectID? in + let feedRecords = snapshot.itemIdentifiers.prefix(count).compactMap { item -> ManagedObjectRecord? 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 +443,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() diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+Provider.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+Provider.swift deleted file mode 100644 index 83022f5d..00000000 --- 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 { - return Future { promise in promise(.success(nil)) } - } - - func status(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future { - 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 { - return Future { promise in promise(.success(nil)) } - } - - var managedObjectContext: NSManagedObjectContext { - return viewModel.fetchedResultsController.managedObjectContext - } - - var tableViewDiffableDataSource: UITableViewDiffableDataSource? { - 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 62695f21..e408ab8d 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 { @@ -183,25 +185,22 @@ extension HomeTimelineViewController { viewModel.tableView = tableView viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self tableView.delegate = self - tableView.prefetchDataSource = self +// tableView.prefetchDataSource = self viewModel.setupDiffableDataSource( - for: tableView, - dependency: self, + tableView: tableView, statusTableViewCellDelegate: self, timelineMiddleLoaderTableViewCellDelegate: self ) // bind refresh control - viewModel.isFetchingLatestTimeline + viewModel.didLoadLatest .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 } - } + UIView.animate(withDuration: 0.5) { [weak self] in + guard let self = self else { return } + self.refreshControl.endRefreshing() + } completion: { _ in } } .store(in: &disposeBag) @@ -272,10 +271,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) { @@ -295,12 +295,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 +373,10 @@ extension HomeTimelineViewController { extension HomeTimelineViewController { @objc private func findPeopleButtonPressed(_ sender: PrimaryActionButton) { + // TODO: let viewModel = SuggestionAccountViewModel(context: context) - viewModel.delegate = self.viewModel - coordinator.present(scene: .suggestionAccount(viewModel: viewModel), from: self, transition: .modal(animated: true, completion: nil)) +// viewModel.delegate = self.viewModel +// coordinator.present(scene: .suggestionAccount(viewModel: viewModel), from: self, transition: .modal(animated: true, completion: nil)) } @objc private func manuallySearchButtonPressed(_ sender: UIButton) { @@ -399,7 +394,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)) } @@ -436,20 +436,11 @@ extension HomeTimelineViewController { } } - -// MARK: - StatusTableViewControllerAspect -extension HomeTimelineViewController: StatusTableViewControllerAspect { } - -extension HomeTimelineViewController: TableViewCellHeightCacheableContainer { - var cellFrameCache: NSCache { 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 +469,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 +489,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 +505,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,23 +535,57 @@ 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) } + + // 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) { +// 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 HomeTimelineViewController: UITableViewDataSourcePrefetching { - func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { - aspectTableView(tableView, prefetchRowsAt: indexPaths) - } - - func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) { - aspectTableView(tableView, cancelPrefetchingForRowsAt: indexPaths) - } -} +//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 { @@ -587,63 +596,13 @@ extension HomeTimelineViewController: ContentOffsetAdjustableTimelineViewControl // 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) } } } @@ -654,45 +613,43 @@ extension HomeTimelineViewController: 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 } - // save position - savePositionBeforeScrollToTop() - tableView.scrollToRow(at: indexPath, at: .top, animated: true) - } + // TODO: +// 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 } +// // save position +// savePositionBeforeScrollToTop() +// tableView.scrollToRow(at: indexPath, at: .top, animated: true) +// } } } // 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) - } - -} +//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 { @@ -725,19 +682,19 @@ extension HomeTimelineViewController: HomeTimelineNavigationBarTitleViewDelegate } } -extension HomeTimelineViewController { - override var keyCommands: [UIKeyCommand]? { - return navigationKeyCommands + statusNavigationKeyCommands - } -} - -// MARK: - StatusTableViewControllerNavigateable -extension HomeTimelineViewController: StatusTableViewControllerNavigateable { - @objc func navigateKeyCommandHandlerRelay(_ sender: UIKeyCommand) { - navigateKeyCommandHandler(sender) - } - - @objc func statusKeyCommandHandlerRelay(_ sender: UIKeyCommand) { - statusKeyCommandHandler(sender) - } -} +//extension HomeTimelineViewController { +// override var keyCommands: [UIKeyCommand]? { +// return navigationKeyCommands + statusNavigationKeyCommands +// } +//} +// +//// MARK: - StatusTableViewControllerNavigateable +//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 e87cab1c..67f9e5b5 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift @@ -13,155 +13,301 @@ 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 + ) ) // make initial snapshot animation smooth - var snapshot = NSDiffableDataSourceSnapshot() + var snapshot = NSDiffableDataSourceSnapshot() 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 = { + let newItems = records.map { record in + StatusItem.feed(record: record) + } + var snapshot = NSDiffableDataSourceSnapshot() + 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) { - os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - } - - func controller(_ controller: NSFetchedResultsController, 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() - 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, + animatingDifferences: Bool + ) async { + diffableDataSource?.apply(snapshot, animatingDifferences: animatingDifferences) } - private struct Difference { + @MainActor func updateSnapshotUsingReloadData( + snapshot: NSDiffableDataSourceSnapshot + ) async { + if #available(iOS 15.0, *) { + await self.diffableDataSource?.applySnapshotUsingReloadData(snapshot) + } else { + diffableDataSource?.applySnapshot(snapshot, animated: false, completion: nil) + } + } + + struct Difference { let item: T let sourceIndexPath: IndexPath + let sourceDistanceToTableViewTopEdge: CGFloat let targetIndexPath: IndexPath - let offset: CGFloat } - - private func calculateReloadSnapshotDifference( - navigationBar: UINavigationBar, + + @MainActor private func calculateReloadSnapshotDifference( tableView: UITableView, - oldSnapshot: NSDiffableDataSourceSnapshot, - newSnapshot: NSDiffableDataSourceSnapshot + oldSnapshot: NSDiffableDataSourceSnapshot, + newSnapshot: NSDiffableDataSourceSnapshot ) -> Difference? { - 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) { +// os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) +// } +// +// func controller(_ controller: NSFetchedResultsController, 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() +// 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 { +// let item: T +// let sourceIndexPath: IndexPath +// let targetIndexPath: IndexPath +// let offset: CGFloat +// } +// +// private func calculateReloadSnapshotDifference( +// navigationBar: UINavigationBar, +// tableView: UITableView, +// oldSnapshot: NSDiffableDataSourceSnapshot, +// newSnapshot: NSDiffableDataSourceSnapshot +// ) -> Difference? { +// 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 425eb9aa..3e46c2af 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 ?? "")") 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 b5b9e4ce..00000000 --- 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 a74d03a5..1986ac36 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 ?? "")") + 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 c4681b40..5e8e7703 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift @@ -18,18 +18,20 @@ import DateToolsSwift final class HomeTimelineViewModel: NSObject { + let logger = Logger(subsystem: "HomeTimelineViewModel", category: "ViewModel") + var disposeBag = Set() var observations = Set() // input let context: AppContext + let fetchedResultsController: FeedFetchedResultsController let timelinePredicate = CurrentValueSubject(nil) - let fetchedResultsController: NSFetchedResultsController - let isFetchingLatestTimeline = CurrentValueSubject(false) + //let isFetchingLatestTimeline = CurrentValueSubject(false) let viewDidAppear = PassthroughSubject() let homeTimelineNavigationBarTitleViewModel: HomeTimelineNavigationBarTitleViewModel let lastAutomaticFetchTimestamp = CurrentValueSubject(nil) - let scrollPositionRecord = CurrentValueSubject(nil) + @Published var scrollPositionRecord: ScrollPositionRecord? = nil let displaySettingBarButtonItem = CurrentValueSubject(true) let displayComposeBarButtonItem = CurrentValueSubject(true) @@ -41,6 +43,9 @@ final class HomeTimelineViewModel: NSObject { let homeTimelineNeedRefresh = PassthroughSubject() // output + var diffableDataSource: UITableViewDiffableDataSource? + let didLoadLatest = PassthroughSubject() + // top loader private(set) lazy var loadLatestStateMachine: GKStateMachine = { // exclude timeline middle fetcher state @@ -54,6 +59,7 @@ final class HomeTimelineViewModel: NSObject { return stateMachine }() lazy var loadLatestStateMachinePublisher = CurrentValueSubject(nil) + // bottom loader private(set) lazy var loadOldestStateMachine: GKStateMachine = { // exclude timeline middle fetcher state @@ -68,68 +74,48 @@ final class HomeTimelineViewModel: NSObject { return stateMachine }() lazy var loadOldestStateMachinePublisher = CurrentValueSubject(nil) - // middle loader - let loadMiddleSateMachineList = CurrentValueSubject<[NSManagedObjectID: GKStateMachine], Never>([:]) // TimelineIndex.objectID : middle loading state machine - var diffableDataSource: UITableViewDiffableDataSource? + var cellFrameCache = NSCache() 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 +// fetchedResultsController.delegate = self - timelinePredicate - .receive(on: DispatchQueue.main) - .compactMap { $0 } - .first() // set once - .sink { [weak self] predicate in +// 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 { +// 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) + + 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 +141,81 @@ final class HomeTimelineViewModel: NSObject { } -extension HomeTimelineViewModel: SuggestionAccountViewModelDelegate { } - +//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) + } + +} diff --git a/Mastodon/Scene/HomeTimeline/View/HomeTimelineNavigationBarTitleView.swift b/Mastodon/Scene/HomeTimeline/View/HomeTimelineNavigationBarTitleView.swift index 1e9c020c..a1940640 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) diff --git a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift index 88beda0f..543ba8d6 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,15 +100,17 @@ 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 @@ -178,7 +182,7 @@ extension MediaPreviewViewController: PageboyViewControllerDelegate { ) { // update page control // pageControl.currentPage = index - viewModel.currentPage.value = index + viewModel.currentPage = index } func pageboyViewController( @@ -203,17 +207,24 @@ extension MediaPreviewViewController: MediaPreviewImageViewControllerDelegate { // 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 = { + let _savePublisher: AnyPublisher? = { 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 +232,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 +249,19 @@ extension MediaPreviewViewController: MediaPreviewImageViewControllerDelegate { } .store(in: &context.disposeBag) case .copyPhoto: - let copyPublisher: AnyPublisher = { + let _copyPublisher: AnyPublisher? = { 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 +279,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 cd019fc9..2de19b26 100644 --- a/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift +++ b/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift @@ -13,115 +13,165 @@ import Pageboy final class MediaPreviewViewModel: NSObject { + weak var mediaPreviewImageViewControllerDelegate: MediaPreviewImageViewControllerDelegate? + // input let context: AppContext - let initialItem: PreviewItem - weak var mediaPreviewImageViewControllerDelegate: MediaPreviewImageViewControllerDelegate? - let currentPage: CurrentValueSubject + 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 { - 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 - } - } - } + switch item { + case .attachment(let previewContext): + currentPage = previewContext.initialIndex + for (i, attachment) in previewContext.attachments.enumerated() { + 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) + } // 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 +// let status = managedObjectContext.object(with: meta.statusObjectID) as! Status +// for (entity, image) in zip(status.attachments, meta.preloadThumbnailImages) { +// let thumbnail: UIImage? = image.flatMap { $0.size != CGSize(width: 1, height: 1) ? $0 : nil } +// switch entity.kind { +// case .image: +// guard let url = URL(string: entity.assetURL ?? "") else { continue } +// let meta = MediaPreviewImageViewModel.RemoteImagePreviewMeta(url: url, thumbnail: thumbnail, altText: entity.altDescription) +// let mediaPreviewImageModel = MediaPreviewImageViewModel(meta: meta) +// let mediaPreviewImageViewController = MediaPreviewImageViewController() +// mediaPreviewImageViewController.viewModel = mediaPreviewImageModel +// viewControllers.append(mediaPreviewImageViewController) +// default: +// continue +// } +// } +// } 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() - } +// init(context: AppContext, meta: ProfileBannerImagePreviewMeta, pushTransitionItem: MediaPreviewTransitionItem) { +// self.context = context +// self.item = .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.transitionItem = pushTransitionItem +// super.init() +// } +// +// init(context: AppContext, meta: ProfileAvatarImagePreviewMeta, pushTransitionItem: MediaPreviewTransitionItem) { +// self.context = context +// self.item = .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.transitionItem = 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 +191,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/MediaPreviewImageViewController.swift b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewController.swift index 03004028..27712b9a 100644 --- a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewController.swift +++ b/Mastodon/Scene/MediaPreview/Paging/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) + + } } } diff --git a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift index f44a6a18..1a141c72 100644 --- a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift +++ b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift @@ -17,70 +17,31 @@ class MediaPreviewImageViewModel { var disposeBag = Set() // input + let context: AppContext 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 + init(context: AppContext, item: ImagePreviewItem) { + self.context = context + self.item = item } } 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 - } + case remote(RemoteImageContext) + case local(LocalImageContext) } - struct RemoteImagePreviewMeta { - let url: URL + struct RemoteImageContext { + let assetURL: URL? let thumbnail: UIImage? let altText: String? } - struct LocalImagePreviewMeta { + struct LocalImageContext { let image: UIImage } - + } diff --git a/Mastodon/Scene/Notification/Button/NotificationAvatarButton.swift b/Mastodon/Scene/Notification/Button/NotificationAvatarButton.swift index 6eafdd1d..26abfbd2 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 00000000..99c04042 --- /dev/null +++ b/Mastodon/Scene/Notification/Cell/NotificationTableViewCell+ViewModel.swift @@ -0,0 +1,49 @@ +// +// NotificationView+ViewModel.swift +// Mastodon +// +// Created by MainasuK on 2022-1-21. +// + +import UIKit +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 + 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 + notificationView.quoteStatusView.frame.size.width = tableView.frame.width - StatusView.containerLayoutMargin.left - StatusView.containerLayoutMargin.right + } + + switch viewModel.value { + case .feed(let feed): + notificationView.configure(feed: feed) + } + + self.delegate = delegate + } + +} diff --git a/Mastodon/Scene/Notification/Cell/NotificationTableViewCell.swift b/Mastodon/Scene/Notification/Cell/NotificationTableViewCell.swift new file mode 100644 index 00000000..fa49824f --- /dev/null +++ b/Mastodon/Scene/Notification/Cell/NotificationTableViewCell.swift @@ -0,0 +1,73 @@ +// +// 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() + + let notificationView = NotificationView() + + let separatorLine = UIView.separatorLine + + 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) + NSLayoutConstraint.activate([ + notificationView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 16), + notificationView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + notificationView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: notificationView.bottomAnchor), + ]) + + 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.delegate = self + } + +} + +// 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 00000000..1f98d4fb --- /dev/null +++ b/Mastodon/Scene/Notification/Cell/NotificationTableViewCellDelegate.swift @@ -0,0 +1,63 @@ +// +// 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, 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) + // 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, 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) + } + // 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 00000000..c058ee92 --- /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 00000000..25e17ef5 --- /dev/null +++ b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController.swift @@ -0,0 +1,141 @@ +// +// NotificationTimelineViewController.swift +// Mastodon +// +// Created by MainasuK on 2022-1-21. +// + +import os.log +import UIKit +import Combine + +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() + var observations = Set() + + 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) + } + +} + +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 +} + +// MARK: - NotificationTableViewCellDelegate +extension NotificationTimelineViewController: NotificationTableViewCellDelegate { } diff --git a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+Diffable.swift b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+Diffable.swift new file mode 100644 index 00000000..1476ef2e --- /dev/null +++ b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+Diffable.swift @@ -0,0 +1,124 @@ +// +// 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 + ) + ) + + var snapshot = NSDiffableDataSourceSnapshot() + 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 = { + let newItems = records.map { record in + NotificationItem.feed(record: record) + } + var snapshot = NSDiffableDataSourceSnapshot() + 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, + animatingDifferences: Bool + ) async { + diffableDataSource?.apply(snapshot, animatingDifferences: animatingDifferences) + } + + @MainActor func updateSnapshotUsingReloadData( + snapshot: NSDiffableDataSourceSnapshot + ) 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 00000000..bc67a630 --- /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 ?? "")") + } + + @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 00000000..e62ec474 --- /dev/null +++ b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel.swift @@ -0,0 +1,159 @@ +// +// 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() + + // input + let context: AppContext + let scope: Scope + let feedFetchedResultsController: FeedFetchedResultsController + let listBatchFetchViewModel = ListBatchFetchViewModel() + + // output + var diffableDataSource: UITableViewDiffableDataSource? + var didLoadLatest = PassthroughSubject() + + // 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 } + 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)") + } + } + +} diff --git a/Mastodon/Scene/Notification/NotificationViewController+StatusProvider.swift b/Mastodon/Scene/Notification/NotificationViewController+StatusProvider.swift deleted file mode 100644 index 57272404..00000000 --- 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 { - return Future { promise in - promise(.success(nil)) - } - } - - func status(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future { - 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 .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 { - return Future { promise in - promise(.success(nil)) - } - } - - var managedObjectContext: NSManagedObjectContext { - viewModel.fetchedResultsController.managedObjectContext - } - - var tableViewDiffableDataSource: UITableViewDiffableDataSource? { - 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 0567d04d..85e534cb 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,539 +60,572 @@ 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) + +// 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) +// +// 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() +// .receive(on: DispatchQueue.main) +// .sink { [weak self] segment in +// guard let self = self else { return } +// self.segmentControl.selectedSegmentIndex = segment.rawValue +// +// // trigger scroll-to-top after data reload +// self.viewModel.needsScrollToTopAfterDataSourceUpdate = true +// +// 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) +// +// 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 - setNeedsStatusBarAppearanceUpdate() +// setNeedsStatusBarAppearanceUpdate() } 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() +// 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() } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) - // reset notification count - context.notificationService.clearNotificationCountForActiveUser() +// // reset notification count +// context.notificationService.clearNotificationCountForActiveUser() } override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) - aspectViewDidDisappear(animated) - } - - override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { - 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) } + + // set initial selection + guard !pageSegmentedControl.isSelected else { return } + if viewModel.currentPageIndex < pageSegmentedControl.numberOfSegments { + pageSegmentedControl.selectedSegmentIndex = viewModel.currentPageIndex + } else { + pageSegmentedControl.selectedSegmentIndex = 0 + } + } + + 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: - TableViewCellHeightCacheableContainer -extension NotificationViewController: TableViewCellHeightCacheableContainer { - var cellFrameCache: NSCache { return viewModel.cellFrameCache } - - func cacheTableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { - guard let diffableDataSource = viewModel.diffableDataSource else { return } - guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } - switch item { - case .notification(let objectID, _), - .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 - } - } - - 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 - } +extension NotificationViewController { + @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: - StatusTableViewControllerAspect -extension NotificationViewController: StatusTableViewControllerAspect { } +//// MARK: - TableViewCellHeightCacheableContainer +//extension NotificationViewController: TableViewCellHeightCacheableContainer { +// var cellFrameCache: NSCache { return viewModel.cellFrameCache } +// +// func cacheTableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { +// guard let diffableDataSource = viewModel.diffableDataSource else { return } +// guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } +// switch item { +// case .notification(let objectID, _), +// .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 +// } +// } +// +// 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 +// } +// } +//} // 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) - } +// 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 - } -} +//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, +// optionalRoot: .root(context: .init(status: status.asRecord)) +// ) +// 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: - 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) - } -} +//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) - } -} +//extension NotificationViewController { +// func scrollViewDidScroll(_ scrollView: UIScrollView) { +// handleScrollViewDidScroll(scrollView) +// } +//} // 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) - } -} - -// MARK: - LoadMoreConfigurableTableViewContainer -extension NotificationViewController: LoadMoreConfigurableTableViewContainer { - typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell - typealias LoadingState = NotificationViewModel.LoadOldestState.Loading - var loadMoreConfigurableTableView: UITableView { tableView } - var loadMoreConfigurableStateMachine: GKStateMachine { viewModel.loadOldestStateMachine } -} +//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) +// } +//} // 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) - } -} +//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 - } -} +//// MARK: - statusTableViewCellDelegate +//extension NotificationViewController: StatusTableViewCellDelegate { +// var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { +// return self +// } +//} -extension NotificationViewController { - - enum CategorySwitch: String, CaseIterable { - case showEverything - case showMentions - - var title: String { - switch self { - case .showEverything: return L10n.Scene.Notification.Keyobard.showEverything - case .showMentions: return L10n.Scene.Notification.Keyobard.showMentions - } - } - - // UIKeyCommand input - var input: String { - switch self { - case .showEverything: return "[" // + shift + command - case .showMentions: return "]" // + shift + command - } - } - - var modifierFlags: UIKeyModifierFlags { - switch self { - case .showEverything: return [.shift, .command] - case .showMentions: return [.shift, .command] - } - } - - var propertyList: Any { - return rawValue - } - } - - var categorySwitchKeyCommands: [UIKeyCommand] { - CategorySwitch.allCases.map { category in - UIKeyCommand( - title: category.title, - image: nil, - action: #selector(NotificationViewController.showCategory(_:)), - input: category.input, - modifierFlags: category.modifierFlags, - propertyList: category.propertyList, - alternates: [], - discoverabilityTitle: nil, - attributes: [], - state: .off - ) - } - } +//extension NotificationViewController { +// +// enum CategorySwitch: String, CaseIterable { +// case showEverything +// case showMentions +// +// var title: String { +// switch self { +// case .showEverything: return L10n.Scene.Notification.Keyobard.showEverything +// case .showMentions: return L10n.Scene.Notification.Keyobard.showMentions +// } +// } +// +// // UIKeyCommand input +// var input: String { +// switch self { +// case .showEverything: return "[" // + shift + command +// case .showMentions: return "]" // + shift + command +// } +// } +// +// var modifierFlags: UIKeyModifierFlags { +// switch self { +// case .showEverything: return [.shift, .command] +// case .showMentions: return [.shift, .command] +// } +// } +// +// var propertyList: Any { +// return rawValue +// } +// } +// +// var categorySwitchKeyCommands: [UIKeyCommand] { +// CategorySwitch.allCases.map { category in +// UIKeyCommand( +// title: category.title, +// image: nil, +// action: #selector(NotificationViewController.showCategory(_:)), +// input: category.input, +// modifierFlags: category.modifierFlags, +// propertyList: category.propertyList, +// alternates: [], +// discoverabilityTitle: nil, +// attributes: [], +// state: .off +// ) +// } +// } +// +// @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 } +// +// switch category { +// case .showEverything: +// viewModel.selectedIndex.value = .everyThing +// case .showMentions: +// viewModel.selectedIndex.value = .mentions +// } +// } +// +// override var keyCommands: [UIKeyCommand]? { +// return categorySwitchKeyCommands + navigationKeyCommands +// } +//} - @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 } - - switch category { - case .showEverything: - viewModel.selectedIndex.value = .everyThing - case .showMentions: - viewModel.selectedIndex.value = .mentions - } - } - - override var keyCommands: [UIKeyCommand]? { - return categorySwitchKeyCommands + navigationKeyCommands - } -} - -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.. 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) - } - -} +//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.. 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 index 6c7a70e4..943db00b 100644 --- a/Mastodon/Scene/Notification/NotificationViewModel+Diffable.swift +++ b/Mastodon/Scene/Notification/NotificationViewModel+Diffable.swift @@ -11,107 +11,84 @@ 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() - 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) { - os_log("%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function) - } - - func controller(_ controller: NSFetchedResultsController, 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() - 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() - } - } - } - } - -} +//extension NotificationViewModel: NSFetchedResultsControllerDelegate { +// func controllerWillChangeContent(_ controller: NSFetchedResultsController) { +// os_log("%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function) +// } +// +// func controller(_ controller: NSFetchedResultsController, 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() +// 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 dac7bb7d..00000000 --- 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 bf2c0317..00000000 --- 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 98b7deec..46891ef8 100644 --- a/Mastodon/Scene/Notification/NotificationViewModel.swift +++ b/Mastodon/Scene/Notification/NotificationViewModel.swift @@ -5,163 +5,158 @@ // 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() // input let context: AppContext - weak var tableView: UITableView? - weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate? - let viewDidLoad = PassthroughSubject() - let selectedIndex = CurrentValueSubject(.everyThing) - let noMoreNotification = CurrentValueSubject(false) +// let selectedIndex = CurrentValueSubject(.everyThing) +// let noMoreNotification = CurrentValueSubject(false) - let activeMastodonAuthenticationBox: CurrentValueSubject - let fetchedResultsController: NSFetchedResultsController! - let notificationPredicate = CurrentValueSubject(nil) - let cellFrameCache = NSCache() +// let activeMastodonAuthenticationBox: CurrentValueSubject +// let fetchedResultsController: NSFetchedResultsController! +// let notificationPredicate = CurrentValueSubject(nil) +// let cellFrameCache = NSCache() + +// var needsScrollToTopAfterDataSourceUpdate = false +// let dataSourceDidUpdated = PassthroughSubject() +// let isFetchingLatestNotification = CurrentValueSubject(false) - var needsScrollToTopAfterDataSourceUpdate = false - let dataSourceDidUpdated = PassthroughSubject() - let isFetchingLatestNotification = CurrentValueSubject(false) // output - var diffableDataSource: UITableViewDiffableDataSource! - - // 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(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(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 - }() +// 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 +// }() + // end init - 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) +// 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) +// 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) +// 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) } +} - 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 1712468a..00000000 --- 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() - var pollCountdownSubscription: AnyCancellable? - var delegate: NotificationTableViewCellDelegate? - - var containerStackViewBottomLayoutConstraint: NSLayoutConstraint! - let containerStackView = UIStackView() - - let avatarButton = NotificationAvatarButton() - let traitCollectionDidChange = PassthroughSubject() - - 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 585dcb31..44a90fd9 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 { @@ -102,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() diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift index c4bbd5bc..b97fc9e3 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift @@ -10,6 +10,8 @@ import UIKit import Combine import GameController import AuthenticationServices +import MastodonAsset +import MastodonLocalization final class MastodonPickServerViewController: UIViewController, NeedsDependency { diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift index 6dd0f197..cf92778b 100644 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift @@ -11,6 +11,8 @@ import Combine import MastodonSDK import AlamofireImage import Kanna +import MastodonAsset +import MastodonLocalization protocol PickServerCellDelegate: AnyObject { // func pickServerCell(_ cell: PickServerCell, expandButtonPressed button: UIButton) diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerLoaderTableViewCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerLoaderTableViewCell.swift index eb0b619d..5649fe57 100644 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerLoaderTableViewCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerLoaderTableViewCell.swift @@ -7,6 +7,8 @@ import UIKit import Combine +import MastodonAsset +import MastodonLocalization final class PickServerLoaderTableViewCell: TimelineLoaderTableViewCell { diff --git a/Mastodon/Scene/Onboarding/PickServer/View/PickServerCategoryView.swift b/Mastodon/Scene/Onboarding/PickServer/View/PickServerCategoryView.swift index f3bc3994..82208586 100644 --- a/Mastodon/Scene/Onboarding/PickServer/View/PickServerCategoryView.swift +++ b/Mastodon/Scene/Onboarding/PickServer/View/PickServerCategoryView.swift @@ -7,6 +7,8 @@ import UIKit import MastodonSDK +import MastodonAsset +import MastodonLocalization class PickServerCategoryView: UIView { diff --git a/Mastodon/Scene/Onboarding/PickServer/View/PickServerEmptyStateView.swift b/Mastodon/Scene/Onboarding/PickServer/View/PickServerEmptyStateView.swift index c5682143..a7557008 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 { diff --git a/Mastodon/Scene/Onboarding/PickServer/View/PickServerServerSectionTableHeaderView.swift b/Mastodon/Scene/Onboarding/PickServer/View/PickServerServerSectionTableHeaderView.swift index 4afa31aa..f26f79b0 100644 --- a/Mastodon/Scene/Onboarding/PickServer/View/PickServerServerSectionTableHeaderView.swift +++ b/Mastodon/Scene/Onboarding/PickServer/View/PickServerServerSectionTableHeaderView.swift @@ -8,6 +8,8 @@ import os.log import UIKit import Tabman +import MastodonAsset +import MastodonLocalization protocol PickServerServerSectionTableHeaderViewDelegate: AnyObject { func pickServerServerSectionTableHeaderView(_ headerView: PickServerServerSectionTableHeaderView, collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) diff --git a/Mastodon/Scene/Onboarding/Register/Cell/MastodonRegisterAvatarTableViewCell.swift b/Mastodon/Scene/Onboarding/Register/Cell/MastodonRegisterAvatarTableViewCell.swift index 304bd02d..154385e6 100644 --- a/Mastodon/Scene/Onboarding/Register/Cell/MastodonRegisterAvatarTableViewCell.swift +++ b/Mastodon/Scene/Onboarding/Register/Cell/MastodonRegisterAvatarTableViewCell.swift @@ -7,6 +7,8 @@ import UIKit import Combine +import MastodonAsset +import MastodonLocalization final class MastodonRegisterAvatarTableViewCell: UITableViewCell { diff --git a/Mastodon/Scene/Onboarding/Register/Cell/MastodonRegisterPasswordHintTableViewCell.swift b/Mastodon/Scene/Onboarding/Register/Cell/MastodonRegisterPasswordHintTableViewCell.swift index 829c70a7..1324c282 100644 --- a/Mastodon/Scene/Onboarding/Register/Cell/MastodonRegisterPasswordHintTableViewCell.swift +++ b/Mastodon/Scene/Onboarding/Register/Cell/MastodonRegisterPasswordHintTableViewCell.swift @@ -6,6 +6,8 @@ // import UIKit +import MastodonAsset +import MastodonLocalization final class MastodonRegisterPasswordHintTableViewCell: UITableViewCell { diff --git a/Mastodon/Scene/Onboarding/Register/Cell/MastodonRegisterTextFieldTableViewCell.swift b/Mastodon/Scene/Onboarding/Register/Cell/MastodonRegisterTextFieldTableViewCell.swift index 8e54d1ff..8659e150 100644 --- a/Mastodon/Scene/Onboarding/Register/Cell/MastodonRegisterTextFieldTableViewCell.swift +++ b/Mastodon/Scene/Onboarding/Register/Cell/MastodonRegisterTextFieldTableViewCell.swift @@ -8,6 +8,8 @@ import UIKit import Combine import MastodonUI +import MastodonAsset +import MastodonLocalization final class MastodonRegisterTextFieldTableViewCell: UITableViewCell { diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift index 0add10dc..9260f9e2 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift @@ -10,6 +10,8 @@ import Foundation import OSLog import PhotosUI import UIKit +import MastodonAsset +import MastodonLocalization extension MastodonRegisterViewController { private func cropImage(image: UIImage, pickerViewController: UIViewController) { diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift index a1fd9742..f4edf8e8 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift @@ -11,6 +11,8 @@ import MastodonSDK import os.log import PhotosUI import UIKit +import MastodonAsset +import MastodonLocalization final class MastodonRegisterViewController: UIViewController, NeedsDependency, OnboardingViewControllerAppearance { diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel+Diffable.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel+Diffable.swift index e075f47c..dbf7c5f1 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel+Diffable.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel+Diffable.swift @@ -7,6 +7,8 @@ import UIKit import Combine +import MastodonAsset +import MastodonLocalization extension MastodonRegisterViewModel { func setupDiffableDataSource( diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift index 5971cc74..1ef9cf47 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift @@ -9,6 +9,8 @@ import Combine import Foundation import MastodonSDK import UIKit +import MastodonAsset +import MastodonLocalization final class MastodonRegisterViewModel { var disposeBag = Set() diff --git a/Mastodon/Scene/Onboarding/ServerRules/Cell/ServerRulesTableViewCell.swift b/Mastodon/Scene/Onboarding/ServerRules/Cell/ServerRulesTableViewCell.swift index 83378b99..a6fc25a4 100644 --- a/Mastodon/Scene/Onboarding/ServerRules/Cell/ServerRulesTableViewCell.swift +++ b/Mastodon/Scene/Onboarding/ServerRules/Cell/ServerRulesTableViewCell.swift @@ -6,6 +6,8 @@ // import UIKit +import MastodonAsset +import MastodonLocalization final class ServerRulesTableViewCell: UITableViewCell { diff --git a/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift b/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift index f6369282..74649bc2 100644 --- a/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift +++ b/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift @@ -11,6 +11,8 @@ import Combine import MastodonSDK import SafariServices import MetaTextKit +import MastodonAsset +import MastodonLocalization final class MastodonServerRulesViewController: UIViewController, NeedsDependency { diff --git a/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewModel.swift b/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewModel.swift index f2664e0e..29869be0 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 { diff --git a/Mastodon/Scene/Onboarding/Share/NavigationActionView.swift b/Mastodon/Scene/Onboarding/Share/NavigationActionView.swift index dc30227c..06a92b43 100644 --- a/Mastodon/Scene/Onboarding/Share/NavigationActionView.swift +++ b/Mastodon/Scene/Onboarding/Share/NavigationActionView.swift @@ -7,6 +7,8 @@ import UIKit import MastodonUI +import MastodonAsset +import MastodonLocalization final class NavigationActionView: UIView { diff --git a/Mastodon/Scene/Onboarding/Share/OnboardingHeadlineTableViewCell.swift b/Mastodon/Scene/Onboarding/Share/OnboardingHeadlineTableViewCell.swift index f8090734..a6c603c2 100644 --- a/Mastodon/Scene/Onboarding/Share/OnboardingHeadlineTableViewCell.swift +++ b/Mastodon/Scene/Onboarding/Share/OnboardingHeadlineTableViewCell.swift @@ -6,6 +6,8 @@ // import UIKit +import MastodonAsset +import MastodonLocalization final class OnboardingHeadlineTableViewCell: UITableViewCell { diff --git a/Mastodon/Scene/Onboarding/Share/OnboardingViewControllerAppearance.swift b/Mastodon/Scene/Onboarding/Share/OnboardingViewControllerAppearance.swift index aef6a8ab..5c51fb55 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 } diff --git a/Mastodon/Scene/Onboarding/Welcome/View/WelcomeIllustrationView.swift b/Mastodon/Scene/Onboarding/Welcome/View/WelcomeIllustrationView.swift index 23fa1505..9a5d6c13 100644 --- a/Mastodon/Scene/Onboarding/Welcome/View/WelcomeIllustrationView.swift +++ b/Mastodon/Scene/Onboarding/Welcome/View/WelcomeIllustrationView.swift @@ -6,6 +6,8 @@ // import UIKit +import MastodonAsset +import MastodonLocalization final class WelcomeIllustrationView: UIView { diff --git a/Mastodon/Scene/Onboarding/Welcome/View/WizardCardView.swift b/Mastodon/Scene/Onboarding/Welcome/View/WizardCardView.swift index 6f18afc9..2ed58137 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 1dff6965..f10bfc42 100644 --- a/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift +++ b/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift @@ -8,6 +8,8 @@ import os.log import UIKit import Combine +import MastodonAsset +import MastodonLocalization final class WelcomeViewController: UIViewController, NeedsDependency { @@ -307,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), -// ]) -// } } } diff --git a/Mastodon/Scene/Profile/About/Cell/ProfileFieldAddEntryCollectionViewCell.swift b/Mastodon/Scene/Profile/About/Cell/ProfileFieldAddEntryCollectionViewCell.swift new file mode 100644 index 00000000..9f22886e --- /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 00000000..ed6f68fe --- /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() + + 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 00000000..43c47f1e --- /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() + + 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 00000000..9b386847 --- /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() + 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 00000000..66b7d25c --- /dev/null +++ b/Mastodon/Scene/Profile/About/ProfileAboutViewModel+Diffable.swift @@ -0,0 +1,84 @@ +// +// 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() + 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) + } + + 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 00000000..c7ef895d --- /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() + + // input + let context: AppContext + @Published var isEditing = false + @Published var accountForEdit: Mastodon.Entity.Account? + @Published var emojiMeta: MastodonContent.Emojis = [:] + + // output + var diffableDataSource: UICollectionViewDiffableDataSource? + + let displayProfileInfo = ProfileInfo() + let editProfileInfo = ProfileInfo() + let editProfileInfoDidInitialized = CurrentValueSubject(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/Favorite/FavoriteViewController+DataSourceProvider.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewController+DataSourceProvider.swift new file mode 100644 index 00000000..8fe8d1bd --- /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 f4631b6e..00000000 --- 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 { - return Future { promise in promise(.success(nil)) } - } - - func status(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future { - 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 { - return Future { promise in promise(.success(nil)) } - } - - var managedObjectContext: NSManagedObjectContext { - return viewModel.statusFetchedResultsController.fetchedResultsController.managedObjectContext - } - - var tableViewDiffableDataSource: UITableViewDiffableDataSource? { - 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 c9890c24..d061826c 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,65 @@ extension FavoriteViewController { ]) tableView.delegate = self - tableView.prefetchDataSource = 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 { - return viewModel.cellFrameCache - } -} +//// MARK: - TableViewCellHeightCacheableContainer +//extension FavoriteViewController: TableViewCellHeightCacheableContainer { +// var cellFrameCache: NSCache { +// return viewModel.cellFrameCache +// } +//} // MARK: - UIScrollViewDelegate -extension FavoriteViewController { - func scrollViewDidScroll(_ scrollView: UIScrollView) { - aspectScrollViewDidScroll(scrollView) - } -} +//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,62 +139,90 @@ 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) } - + + + // 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) { +// 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 FavoriteViewController: UITableViewDataSourcePrefetching { - func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { - aspectTableView(tableView, prefetchRowsAt: indexPaths) - } -} +//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) - } - -} +//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 } -} +//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 } -} +//extension FavoriteViewController { +// override var keyCommands: [UIKeyCommand]? { +// return navigationKeyCommands + statusNavigationKeyCommands +// } +//} +// +//// MARK: - StatusTableViewControllerNavigateable +//extension FavoriteViewController: StatusTableViewControllerNavigateable { +// @objc func navigateKeyCommandHandlerRelay(_ sender: UIKeyCommand) { +// navigateKeyCommandHandler(sender) +// } +// +// @objc func statusKeyCommandHandlerRelay(_ sender: UIKeyCommand) { +// statusKeyCommandHandler(sender) +// } +//} - -extension FavoriteViewController { - override var keyCommands: [UIKeyCommand]? { - return navigationKeyCommands + statusNavigationKeyCommands - } -} - -// MARK: - StatusTableViewControllerNavigateable -extension FavoriteViewController: StatusTableViewControllerNavigateable { - @objc func navigateKeyCommandHandlerRelay(_ sender: UIKeyCommand) { - navigateKeyCommandHandler(sender) - } - - @objc func statusKeyCommandHandlerRelay(_ sender: UIKeyCommand) { - statusKeyCommandHandler(sender) - } -} +// MARK: - StatusTableViewCellDelegate +extension FavoriteViewController: StatusTableViewCellDelegate { } diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+Diffable.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+Diffable.swift index 31472141..f74d3de7 100644 --- a/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+Diffable.swift +++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+Diffable.swift @@ -10,26 +10,54 @@ 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 + ) ) - // set empty section to make update animation top-to-bottom style - var snapshot = NSDiffableDataSourceSnapshot() + var snapshot = NSDiffableDataSourceSnapshot() 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() + 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 c4420e88..6c539450 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 ?? "")") + } + + @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 6b4c1b8c..150c8f81 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 let statusFetchedResultsController: StatusFetchedResultsController - let cellFrameCache = NSCache() - + let listBatchFetchViewModel = ListBatchFetchViewModel() + // output - var diffableDataSource: UITableViewDiffableDataSource? + var diffableDataSource: UITableViewDiffableDataSource? 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() - 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 00000000..956cb070 --- /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 25e10284..00000000 --- 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 { - Future { promise in - promise(.success(nil)) - } - } - - func mastodonUser(for cell: UITableViewCell?) -> Future { - Future { [weak self] promise in - guard let self = self else { return } - guard let diffableDataSource = self.viewModel.diffableDataSource else { - assertionFailure() - promise(.success(nil)) - return - } - guard let cell = cell, - let indexPath = self.tableView.indexPath(for: cell), - let item = diffableDataSource.itemIdentifier(for: indexPath) else { - promise(.success(nil)) - return - } - - let managedObjectContext = self.viewModel.userFetchedResultsController.fetchedResultsController.managedObjectContext - - switch item { - case .follower(let objectID), - .following(let objectID): - managedObjectContext.perform { - let user = managedObjectContext.object(with: objectID) as? MastodonUser - promise(.success(user)) - } - case .bottomLoader, .bottomHeader: - promise(.success(nil)) - } - } - } -} diff --git a/Mastodon/Scene/Profile/Follower/FollowerListViewController.swift b/Mastodon/Scene/Profile/Follower/FollowerListViewController.swift index 97e62ea8..68f1d0de 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() + let logger = Logger(subsystem: "FollowerListViewController", category: "ViewController") weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + var disposeBag = Set() 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 fc9f3177..15cc1be1 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() 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 43e53267..c6af90d5 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 ?? "")") + } + + @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)") } } } @@ -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.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 { + await enter(state: Idle.self) + } else { + await enter(state: NoMore.self) + } + + self.maxID = maxID + viewModel.userFetchedResultsController.userIDs.value = 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 f62441cf..a212c95b 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 let userID: CurrentValueSubject let userFetchedResultsController: UserFetchedResultsController - + let listBatchFetchViewModel = ListBatchFetchViewModel() + // output var diffableDataSource: UITableViewDiffableDataSource? private(set) lazy var stateMachine: GKStateMachine = { diff --git a/Mastodon/Scene/Profile/Following/FollowingListViewController+DataSourceProvider.swift b/Mastodon/Scene/Profile/Following/FollowingListViewController+DataSourceProvider.swift new file mode 100644 index 00000000..3ea2a74c --- /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 aaeb5232..00000000 --- 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 { - Future { promise in - promise(.success(nil)) - } - } - - func mastodonUser(for cell: UITableViewCell?) -> Future { - Future { [weak self] promise in - guard let self = self else { return } - guard let diffableDataSource = self.viewModel.diffableDataSource else { - assertionFailure() - promise(.success(nil)) - return - } - guard let cell = cell, - let indexPath = self.tableView.indexPath(for: cell), - let item = diffableDataSource.itemIdentifier(for: indexPath) else { - promise(.success(nil)) - return - } - - let managedObjectContext = self.viewModel.userFetchedResultsController.fetchedResultsController.managedObjectContext - - switch item { - case .follower(let objectID), - .following(let objectID): - managedObjectContext.perform { - let user = managedObjectContext.object(with: objectID) as? MastodonUser - promise(.success(user)) - } - case .bottomLoader, .bottomHeader: - promise(.success(nil)) - } - } - } -} diff --git a/Mastodon/Scene/Profile/Following/FollowingListViewController.swift b/Mastodon/Scene/Profile/Following/FollowingListViewController.swift index 35691b82..7272a2db 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() + + let logger = Logger(subsystem: "FollowingListViewController", category: "ViewController") weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + var disposeBag = Set() 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 dc6f1f6f..116e7567 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() 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 0ec3d626..560c62d0 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 ?? "")") + } + + @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)") } } } @@ -123,27 +143,20 @@ 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 @@ -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 + + } 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 0677f6cb..22658a0e 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 let userID: CurrentValueSubject let userFetchedResultsController: UserFetchedResultsController + let listBatchFetchViewModel = ListBatchFetchViewModel() // output var diffableDataSource: UITableViewDiffableDataSource? diff --git a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift index 34716dde..20cedc49 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() weak var delegate: ProfileHeaderViewControllerDelegate? @@ -43,12 +43,19 @@ 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.buttons.customize { button in + button.selectedTintColor = Asset.Colors.Label.primary.color + button.tintColor = Asset.Colors.Label.secondary.color + button.backgroundColor = .clear + } + buttonBar.indicator.backgroundColor = Asset.Colors.Label.primary.color + buttonBar.backgroundView.style = .clear + buttonBar.layout.contentInset = .zero + return buttonBar }() - var pageSegmentedControlLeadingLayoutConstraint: NSLayoutConstraint! private var isBannerPinned = false private var bottomShadowAlpha: CGFloat = 0.0 @@ -89,12 +96,12 @@ extension ProfileHeaderViewController { override func viewDidLoad() { super.viewDidLoad() - 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 +113,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 +135,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 +176,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 +190,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 +223,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,13 +247,6 @@ extension ProfileHeaderViewController { setupBottomShadow() } - override func viewLayoutMarginsDidChange() { - super.viewLayoutMarginsDidChange() - - let margin = view.frame.maxX - view.readableContentGuide.layoutFrame.maxX - pageSegmentedControlLeadingLayoutConstraint.constant = margin - } - } extension ProfileHeaderViewController { @@ -335,57 +290,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 +358,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 +386,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 +459,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 b02eaa61..00000000 --- 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 40883120..7f7b0dd0 100644 --- a/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift +++ b/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift @@ -21,126 +21,69 @@ final class ProfileHeaderViewModel { // input let context: AppContext - let isEditing = CurrentValueSubject(false) + @Published var isEditing = false + @Published var accountForEdit: Mastodon.Entity.Account? + @Published var emojiMeta: MastodonContent.Emojis = [:] + let viewDidAppear = CurrentValueSubject(false) let needsSetupBottomShadow = CurrentValueSubject(true) let needsFiledCollectionViewHidden = CurrentValueSubject(false) let isTitleViewContentOffsetSet = CurrentValueSubject(false) - let emojiMeta = CurrentValueSubject([:]) - let accountForEdit = CurrentValueSubject(nil) // output + let isTitleViewDisplaying = CurrentValueSubject(false) let displayProfileInfo = ProfileInfo() let editProfileInfo = ProfileInfo() let editProfileInfoDidInitialized = CurrentValueSubject(Void()) // needs trigger initial event - let isTitleViewDisplaying = CurrentValueSubject(false) - var fieldDiffableDataSource: UICollectionViewDiffableDataSource! - + 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() - 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(nil) - let avatarImageResource = CurrentValueSubject(nil) - let note = CurrentValueSubject(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 } } @@ -154,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, 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 <= ProfileHeaderViewModel.avatarImageMaxSizeInPixel.width else { - return image.af.imageScaled(to: ProfileHeaderViewModel.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 cafe0eda..00000000 --- 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() - - 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 9106b0e4..00000000 --- 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() - - 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 83fec9bc..8a0d3c6d 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 ee17d7e4..00000000 --- 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() - - // output - let name = PassthroughSubject() - let value = PassthroughSubject() - - // 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 016b31a1..eddfc648 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,10 @@ 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))) + return button }() func setupAvatarOverlayViews() { @@ -123,34 +123,32 @@ 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 }() @@ -198,36 +196,36 @@ 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? +// 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) @@ -240,21 +238,19 @@ final class ProfileHeaderView: UIView { } deinit { - fieldCollectionViewHeightObservation = nil +// 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 +280,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), + // 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 +307,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 +324,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: leadingAnchor), + container.trailingAnchor.constraint(equalTo: 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 | relationshipActionButton ] + 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,94 +391,38 @@ 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) relationshipActionButton.translatesAutoresizingMaskIntoConstraints = false - dashboardContainerView.addSubview(relationshipActionButton) + authorContainer.addArrangedSubview(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.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) @@ -514,9 +484,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 +524,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/ProfileStatusDashboardMeterView.swift b/Mastodon/Scene/Profile/Header/View/ProfileStatusDashboardMeterView.swift index 53cd21f6..9176d7a3 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 c21703c0..5bb23b0b 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) diff --git a/Mastodon/Scene/Profile/ProfileViewController+UserProvider.swift b/Mastodon/Scene/Profile/ProfileViewController+UserProvider.swift deleted file mode 100644 index 6bfa132b..00000000 --- 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 { - return Future { promise in - promise(.success(nil)) - } - } - - - func mastodonUser() -> Future { - 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 5ff71ba9..30f2dc42 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -10,9 +10,20 @@ 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 { + let logger = Logger(subsystem: "ProfileViewController", category: "ViewController") + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } @@ -145,7 +156,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 +232,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 +253,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 +271,21 @@ 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.layoutMarginsGuide.leadingAnchor), + buttonBar.trailingAnchor.constraint(equalTo: self.profileHeaderViewController.view.layoutMarginsGuide.trailingAnchor), + buttonBar.bottomAnchor.constraint(equalTo: self.profileHeaderViewController.view.bottomAnchor), + buttonBar.heightAnchor.constraint(equalToConstant: ProfileHeaderViewController.segmentedControlHeight).priority(.required - 1), + ]) + }) + ) overlayScrollView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(overlayScrollView) @@ -312,260 +335,28 @@ 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 - Publishers.CombineLatest3( - viewModel.name, - viewModel.emojiMeta, - viewModel.statusesCount + bindProfile( + headerViewModel: profileHeaderViewController.viewModel, + aboutViewModel: profileAboutViewModel ) - .receive(on: DispatchQueue.main) - .sink { [weak self] name, emojiMeta, statusesCount in - guard let self = self else { return } - guard let title = name, let statusesCount = statusesCount, - let formattedStatusCount = MastodonMetricFormatter().string(from: statusesCount) else { - self.titleView.isHidden = true - return - } - self.titleView.isHidden = false - let subtitle = L10n.Plural.Count.MetricFormatted.post(formattedStatusCount, statusesCount) - let mastodonContent = MastodonContent(content: title, emojis: emojiMeta) - do { - let metaContent = try MastodonMetaContent.convert(document: mastodonContent) - self.titleView.update(titleMetaContent: metaContent, subtitle: subtitle) - } catch { - - } - } - .store(in: &disposeBag) - viewModel.name - .receive(on: DispatchQueue.main) - .sink { [weak self] name in - guard let self = self else { return } - self.navigationItem.title = name - } - .store(in: &disposeBag) - Publishers.CombineLatest( - viewModel.bannerImageURL.eraseToAnyPublisher(), - viewModel.viewDidAppear.eraseToAnyPublisher() - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] bannerImageURL, _ in - guard let self = self else { return } - self.profileHeaderViewController.profileHeaderView.bannerImageView.af.cancelImageRequest() - let placeholder = UIImage.placeholder(color: ProfileHeaderView.bannerImageViewPlaceholderColor) - guard let bannerImageURL = bannerImageURL else { - self.profileHeaderViewController.profileHeaderView.bannerImageView.image = placeholder - return - } - self.profileHeaderViewController.profileHeaderView.bannerImageView.af.setImage( - withURL: bannerImageURL, - placeholderImage: placeholder, - imageTransition: .crossDissolve(0.3), - runImageTransitionIfCached: false, - completion: { [weak self] response in - guard let self = self else { return } - guard let image = response.value else { return } - guard image.size.width > 1 && image.size.height > 1 else { - // restore to placeholder when image invalid - self.profileHeaderViewController.profileHeaderView.bannerImageView.image = placeholder - return - } - } - ) - } - .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) - Publishers.CombineLatest( - viewModel.relationshipActionOptionSet, - viewModel.context.blockDomainService.blockedDomains - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] relationshipActionOptionSet,domains in - guard let self = self else { return } - guard let mastodonUser = self.viewModel.mastodonUser.value else { - self.moreMenuBarButtonItem.menu = nil - return - } - 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) - } - .store(in: &disposeBag) + bindTitleView() + bindHeader() + bindProfileRelationship() + bindProfileDashboard() - viewModel.isRelationshipActionButtonHidden - .receive(on: DispatchQueue.main) - .sink { [weak self] isHidden in - guard let self = self else { return } - self.profileHeaderViewController.profileHeaderView.relationshipActionButton.isHidden = isHidden - } - .store(in: &disposeBag) - Publishers.CombineLatest3( - viewModel.relationshipActionOptionSet.eraseToAnyPublisher(), - viewModel.isEditing.eraseToAnyPublisher(), - viewModel.isUpdating.eraseToAnyPublisher() - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] relationshipActionSet, isEditing, isUpdating in - guard let self = self else { return } - let friendshipButton = self.profileHeaderViewController.profileHeaderView.relationshipActionButton - if relationshipActionSet.contains(.edit) { - // check .edit state and set .editing when isEditing - friendshipButton.configure(actionOptionSet: isUpdating ? .updating : (isEditing ? .editing : .edit)) - self.profileHeaderViewController.profileHeaderView.configure(state: isEditing ? .editing : .normal) - } else { - friendshipButton.configure(actionOptionSet: relationshipActionSet) - } - } - .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(), - viewModel.suspended.eraseToAnyPublisher() - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] isBlocking, isBlockedBy, suspended in - guard let self = self else { return } - let isNeedSetHidden = isBlocking || isBlockedBy || suspended - self.profileHeaderViewController.viewModel.needsSetupBottomShadow.value = !isNeedSetHidden - self.profileHeaderViewController.profileHeaderView.bioContainerView.isHidden = isNeedSetHidden - self.profileHeaderViewController.viewModel.needsFiledCollectionViewHidden.value = isNeedSetHidden - self.profileHeaderViewController.pageSegmentedControl.isEnabled = !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) - viewModel.statusesCount - .receive(on: DispatchQueue.main) - .sink { [weak self] count in - guard let self = self else { return } - let text = count.flatMap { MastodonMetricFormatter().string(from: $0) } ?? "-" - self.profileHeaderViewController.profileHeaderView.statusDashboardView.postDashboardMeterView.numberLabel.text = text - self.profileHeaderViewController.profileHeaderView.statusDashboardView.postDashboardMeterView.isAccessibilityElement = true - self.profileHeaderViewController.profileHeaderView.statusDashboardView.postDashboardMeterView.accessibilityLabel = L10n.Plural.Count.post(count ?? 0) - } - .store(in: &disposeBag) - viewModel.followingCount - .receive(on: DispatchQueue.main) - .sink { [weak self] count in - guard let self = self else { return } - let text = count.flatMap { MastodonMetricFormatter().string(from: $0) } ?? "-" - self.profileHeaderViewController.profileHeaderView.statusDashboardView.followingDashboardMeterView.numberLabel.text = text - self.profileHeaderViewController.profileHeaderView.statusDashboardView.followingDashboardMeterView.isAccessibilityElement = true - self.profileHeaderViewController.profileHeaderView.statusDashboardView.followingDashboardMeterView.accessibilityLabel = L10n.Plural.Count.following(count ?? 0) - } - .store(in: &disposeBag) - viewModel.followersCount - .receive(on: DispatchQueue.main) - .sink { [weak self] count in - guard let self = self else { return } - let text = count.flatMap { MastodonMetricFormatter().string(from: $0) } ?? "-" - self.profileHeaderViewController.profileHeaderView.statusDashboardView.followersDashboardMeterView.numberLabel.text = text - self.profileHeaderViewController.profileHeaderView.statusDashboardView.followersDashboardMeterView.isAccessibilityElement = true - self.profileHeaderViewController.profileHeaderView.statusDashboardView.followersDashboardMeterView.accessibilityLabel = L10n.Plural.Count.follower(count ?? 0) - } - .store(in: &disposeBag) viewModel.needsPagingEnabled - .receive(on: RunLoop.main) + .receive(on: DispatchQueue.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 } @@ -600,14 +391,322 @@ extension ProfileViewController { 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.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, + viewModel.statusesCount + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] name, emojiMeta, statusesCount in + guard let self = self else { return } + guard let title = name, let statusesCount = statusesCount, + let formattedStatusCount = MastodonMetricFormatter().string(from: statusesCount) else { + self.titleView.isHidden = true + return + } + self.titleView.isHidden = false + let subtitle = L10n.Plural.Count.MetricFormatted.post(formattedStatusCount, statusesCount) + let mastodonContent = MastodonContent(content: title, emojis: emojiMeta) + do { + let metaContent = try MastodonMetaContent.convert(document: mastodonContent) + self.titleView.update(titleMetaContent: metaContent, subtitle: subtitle) + } catch { + + } + } + .store(in: &disposeBag) + viewModel.name + .receive(on: DispatchQueue.main) + .sink { [weak self] name in + guard let self = self else { return } + self.navigationItem.title = name + } + .store(in: &disposeBag) + } + + private func bindHeader() { + // heaer UI + Publishers.CombineLatest( + viewModel.bannerImageURL.eraseToAnyPublisher(), + viewModel.viewDidAppear.eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] bannerImageURL, _ in + guard let self = self else { return } + self.profileHeaderViewController.profileHeaderView.bannerImageView.af.cancelImageRequest() + let placeholder = UIImage.placeholder(color: ProfileHeaderView.bannerImageViewPlaceholderColor) + guard let bannerImageURL = bannerImageURL else { + self.profileHeaderViewController.profileHeaderView.bannerImageView.image = placeholder + return + } + self.profileHeaderViewController.profileHeaderView.bannerImageView.af.setImage( + withURL: bannerImageURL, + placeholderImage: placeholder, + imageTransition: .crossDissolve(0.3), + runImageTransitionIfCached: false, + completion: { [weak self] response in + guard let self = self else { return } + guard let image = response.value else { return } + guard image.size.width > 1 && image.size.height > 1 else { + // restore to placeholder when image invalid + self.profileHeaderViewController.profileHeaderView.bannerImageView.image = placeholder + return + } + } + ) + } + .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.mastodonUser, + viewModel.relationshipActionOptionSet + ) + .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(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 } + switch completion { + case .failure(let error): + self.moreMenuBarButtonItem.menu = nil + case .finished: + break + } + } receiveValue: { [weak self] menu in + guard let self = self else { return } + self.moreMenuBarButtonItem.menu = menu + } + .store(in: &disposeBag) + + viewModel.isRelationshipActionButtonHidden + .receive(on: DispatchQueue.main) + .sink { [weak self] isHidden in + guard let self = self else { return } + self.profileHeaderViewController.profileHeaderView.relationshipActionButton.isHidden = isHidden + } + .store(in: &disposeBag) + + Publishers.CombineLatest3( + viewModel.relationshipActionOptionSet.eraseToAnyPublisher(), + viewModel.isEditing.eraseToAnyPublisher(), + viewModel.isUpdating.eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] relationshipActionSet, isEditing, isUpdating in + guard let self = self else { return } + let friendshipButton = self.profileHeaderViewController.profileHeaderView.relationshipActionButton + if relationshipActionSet.contains(.edit) { + // check .edit state and set .editing when isEditing + friendshipButton.configure(actionOptionSet: isUpdating ? .updating : (isEditing ? .editing : .edit)) + self.profileHeaderViewController.profileHeaderView.configure(state: isEditing ? .editing : .normal) + } else { + friendshipButton.configure(actionOptionSet: relationshipActionSet) + } + } + .store(in: &disposeBag) + + Publishers.CombineLatest3( + viewModel.isBlocking.eraseToAnyPublisher(), + viewModel.isBlockedBy.eraseToAnyPublisher(), + viewModel.suspended.eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] isBlocking, isBlockedBy, suspended in + guard let self = self else { return } + let isNeedSetHidden = isBlocking || isBlockedBy || suspended + self.profileHeaderViewController.viewModel.needsSetupBottomShadow.value = !isNeedSetHidden + self.profileHeaderViewController.profileHeaderView.bioContainerView.isHidden = isNeedSetHidden + self.profileHeaderViewController.viewModel.needsFiledCollectionViewHidden.value = isNeedSetHidden + self.profileHeaderViewController.buttonBar.isUserInteractionEnabled = !isNeedSetHidden + self.viewModel.needsPagePinToTop.value = isNeedSetHidden + } + .store(in: &disposeBag) + } // end func bindProfileRelationship + + private func bindProfileDashboard() { + viewModel.statusesCount + .receive(on: DispatchQueue.main) + .sink { [weak self] count in + guard let self = self else { return } + let text = count.flatMap { MastodonMetricFormatter().string(from: $0) } ?? "-" + self.profileHeaderViewController.profileHeaderView.statusDashboardView.postDashboardMeterView.numberLabel.text = text + self.profileHeaderViewController.profileHeaderView.statusDashboardView.postDashboardMeterView.isAccessibilityElement = true + self.profileHeaderViewController.profileHeaderView.statusDashboardView.postDashboardMeterView.accessibilityLabel = L10n.Plural.Count.post(count ?? 0) + } + .store(in: &disposeBag) + viewModel.followingCount + .receive(on: DispatchQueue.main) + .sink { [weak self] count in + guard let self = self else { return } + let text = count.flatMap { MastodonMetricFormatter().string(from: $0) } ?? "-" + self.profileHeaderViewController.profileHeaderView.statusDashboardView.followingDashboardMeterView.numberLabel.text = text + self.profileHeaderViewController.profileHeaderView.statusDashboardView.followingDashboardMeterView.isAccessibilityElement = true + self.profileHeaderViewController.profileHeaderView.statusDashboardView.followingDashboardMeterView.accessibilityLabel = L10n.Plural.Count.following(count ?? 0) + } + .store(in: &disposeBag) + viewModel.followersCount + .receive(on: DispatchQueue.main) + .sink { [weak self] count in + guard let self = self else { return } + let text = count.flatMap { MastodonMetricFormatter().string(from: $0) } ?? "-" + self.profileHeaderViewController.profileHeaderView.statusDashboardView.followersDashboardMeterView.numberLabel.text = text + self.profileHeaderViewController.profileHeaderView.statusDashboardView.followersDashboardMeterView.isAccessibilityElement = true + self.profileHeaderViewController.profileHeaderView.statusDashboardView.followersDashboardMeterView.accessibilityLabel = L10n.Plural.Count.follower(count ?? 0) + } + .store(in: &disposeBag) + } + + 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 + } + } + } extension ProfileViewController { @@ -626,17 +725,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.mastodonUser.value else { return } + let record: ManagedObjectRecord = .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 +753,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 authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } guard let mastodonUser = viewModel.mastodonUser.value 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)) } @@ -696,7 +804,7 @@ extension ProfileViewController: UIScrollViewDelegate { } // 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 +823,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,10 +831,10 @@ 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 @@ -769,73 +848,42 @@ extension ProfileViewController: ProfilePagingViewControllerDelegate { // MARK: - ProfileHeaderViewDelegate extension ProfileViewController: ProfileHeaderViewDelegate { - func profileHeaderView(_ profileHeaderView: ProfileHeaderView, avatarImageViewDidPressed imageView: UIImageView) { - guard let mastodonUser = viewModel.mastodonUser.value else { return } - guard let avatar = imageView.image else { return } + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, avatarButtonDidPressed button: AvatarButton) { + guard let user = viewModel.mastodonUser.value else { return } + let record: ManagedObjectRecord = .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.mastodonUser.value else { return } + let record: ManagedObjectRecord = .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 +891,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 +956,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.mastodonUser.value else { return } + let reocrd = ManagedObjectRecord(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.mastodonUser.value 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(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.mastodonUser.value else { return } + let name = user.displayNameWithFallback + let alertController = UIAlertController( title: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.title, message: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.message(name), preferredStyle: .alert ) + let record = ManagedObjectRecord(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 +1025,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 +1066,61 @@ extension ProfileViewController: ProfileHeaderViewDelegate { } -// MARK: - ScrollViewContainer -extension ProfileViewController: ScrollViewContainer { - var scrollView: UIScrollView { return overlayScrollView } +// MARK: - ProfileAboutViewControllerDelegate +extension ProfileViewController: ProfileAboutViewControllerDelegate { + func profileAboutViewController(_ viewController: ProfileAboutViewController, profileFieldCollectionViewCell: ProfileFieldCollectionViewCell, metaLabel: MetaLabel, didSelectMeta meta: Meta) { + handleMetaPress(meta) + } } -extension ProfileViewController { - - override var keyCommands: [UIKeyCommand]? { - if !viewModel.isEditing.value { - return segmentedControlNavigateKeyCommands - } +// MARK: - MastodonMenuDelegate +extension ProfileViewController: MastodonMenuDelegate { + func menuAction(_ action: MastodonMenu.Action) { + guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } + guard let user = viewModel.mastodonUser.value else { return } - return nil + let userRecord: ManagedObjectRecord = .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 } +//} +// +//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 5efbaa68..b3973472 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() @@ -40,7 +44,7 @@ class ProfileViewModel: NSObject { let statusesCount: CurrentValueSubject let followingCount: CurrentValueSubject let followersCount: CurrentValueSubject - let fields: CurrentValueSubject<[Mastodon.Entity.Field], Never> + let fields: CurrentValueSubject<[MastodonField], Never> let emojiMeta: CurrentValueSubject // fulfill this before editing @@ -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 @@ -108,51 +112,45 @@ class ProfileViewModel: NSObject { .store(in: &disposeBag) // query relationship - let mastodonUserID = self.mastodonUser.map { $0?.id } + let userRecord = self.mastodonUser.map { user -> ManagedObjectRecord? in + user.flatMap { ManagedObjectRecord(objectID: $0.objectID) } + } let pendingRetryPublisher = CurrentValueSubject(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, 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 @@ -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) } @@ -356,7 +354,19 @@ extension ProfileViewModel { let authorization = Mastodon.API.OAuth.Authorization(accessToken: mastodonAuthentication.userAccessToken) return context.apiService.accountVerifyCredentials(domain: currentMastodonUser.domain, authorization: authorization) -// .erro + } + + private func updateRelationship( + record: ManagedObjectRecord, + 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 } } @@ -454,3 +464,46 @@ extension ProfileViewModel { } } + +extension ProfileViewModel { + func updateProfileInfo( + headerProfileInfo: ProfileHeaderViewModel.ProfileInfo, + aboutProfileInfo: ProfileAboutViewModel.ProfileInfo + ) async throws -> Mastodon.Response.Content { + 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 ef04d581..8e31050d 100644 --- a/Mastodon/Scene/Profile/RemoteProfileViewModel.swift +++ b/Mastodon/Scene/Profile/RemoteProfileViewModel.swift @@ -21,33 +21,36 @@ 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.mastodonUser.value = mastodonUser } - self.mastodonUser.value = mastodonUser - } - .store(in: &disposeBag) + .store(in: &disposeBag) } init(context: AppContext, notificationID: Mastodon.Entity.Notification.ID) { @@ -59,42 +62,42 @@ final class RemoteProfileViewModel: ProfileViewModel { let domain = activeMastodonAuthenticationBox.domain let authorization = activeMastodonAuthenticationBox.userAuthorization - context.apiService.notification( - notificationID: notificationID, - mastodonAuthenticationBox: activeMastodonAuthenticationBox - ) - .compactMap { [weak self] response -> AnyPublisher, Error>? in - let userID = response.value.account.id - // TODO: use .account directly - return context.apiService.accountInfo( - domain: domain, - userID: userID, - authorization: authorization - ) - } - .switchToLatest() - .retry(3) - .sink { completion in - switch completion { - case .failure(let error): - // TODO: handle error - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: remote notification %s user fetch failed: %s", ((#file as NSString).lastPathComponent), #line, #function, notificationID, error.localizedDescription) - case .finished: - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: remote notification %s user fetched", ((#file as NSString).lastPathComponent), #line, #function, notificationID) - } - } receiveValue: { [weak self] response in - guard let self = self else { return } - let managedObjectContext = context.managedObjectContext - let request = MastodonUser.sortedFetchRequest - request.fetchLimit = 1 - request.predicate = MastodonUser.predicate(domain: domain, id: response.value.id) - guard let mastodonUser = managedObjectContext.safeFetch(request).first else { - assertionFailure() - return - } - self.mastodonUser.value = mastodonUser - } - .store(in: &disposeBag) +// context.apiService.notification( +// notificationID: notificationID, +// mastodonAuthenticationBox: activeMastodonAuthenticationBox +// ) +// .compactMap { [weak self] response -> AnyPublisher, Error>? in +// let userID = response.value.account.id +// // TODO: use .account directly +// return context.apiService.accountInfo( +// domain: domain, +// userID: userID, +// authorization: authorization +// ) +// } +// .switchToLatest() +// .retry(3) +// .sink { completion in +// switch completion { +// case .failure(let error): +// // TODO: handle error +// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: remote notification %s user fetch failed: %s", ((#file as NSString).lastPathComponent), #line, #function, notificationID, error.localizedDescription) +// case .finished: +// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: remote notification %s user fetched", ((#file as NSString).lastPathComponent), #line, #function, notificationID) +// } +// } receiveValue: { [weak self] response in +// guard let self = self else { return } +// let managedObjectContext = context.managedObjectContext +// let request = MastodonUser.sortedFetchRequest +// request.fetchLimit = 1 +// request.predicate = MastodonUser.predicate(domain: domain, id: response.value.id) +// guard let mastodonUser = managedObjectContext.safeFetch(request).first else { +// assertionFailure() +// return +// } +// self.mastodonUser.value = mastodonUser +// } +// .store(in: &disposeBag) } } diff --git a/Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewModel.swift b/Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewModel.swift index 252d5e14..0df9688d 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: "Posts and Replies"), // TODO: i18n TMBarItem(title: L10n.Scene.Profile.SegmentedControl.media), + TMBarItem(title: "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 00000000..2b18fad5 --- /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 8c46f0ad..00000000 --- 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 { - return Future { promise in promise(.success(nil)) } - } - - func status(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future { - 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 { - return Future { promise in promise(.success(nil)) } - } - - var managedObjectContext: NSManagedObjectContext { - return viewModel.statusFetchedResultsController.fetchedResultsController.managedObjectContext - } - - var tableViewDiffableDataSource: UITableViewDiffableDataSource? { - 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 4bee3b8a..58e92b8c 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) } } @@ -48,7 +50,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 +67,9 @@ extension UserTimelineViewController { ]) tableView.delegate = self - tableView.prefetchDataSource = self +// tableView.prefetchDataSource = self viewModel.setupDiffableDataSource( - for: tableView, - dependency: self, + tableView: tableView, statusTableViewCellDelegate: self ) @@ -78,41 +79,20 @@ 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) { @@ -120,36 +100,20 @@ extension UserTimelineViewController: StatusTableViewControllerAspect { } // } //} -// MARK: - TableViewCellHeightCacheableContainer -extension UserTimelineViewController: TableViewCellHeightCacheableContainer { - var cellFrameCache: NSCache { - return viewModel.cellFrameCache - } -} - // 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,38 +121,71 @@ 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) } + + // 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) { +// 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 UserTimelineViewController: UITableViewDataSourcePrefetching { - func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { - aspectTableView(tableView, prefetchRowsAt: indexPaths) - } -} +//// MARK: - UITableViewDataSourcePrefetching +//extension UserTimelineViewController: UITableViewDataSourcePrefetching { +// func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { +// aspectTableView(tableView, prefetchRowsAt: indexPaths) +// } +//} // MARK: - AVPlayerViewControllerDelegate -extension UserTimelineViewController: AVPlayerViewControllerDelegate { - - func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { - aspectPlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator) - } - - func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { - aspectPlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator) - } - -} +//extension UserTimelineViewController: 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 UserTimelineViewController: StatusTableViewCellDelegate { - weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self } - func parent() -> UIViewController { return self } -} +//extension UserTimelineViewController: StatusTableViewCellDelegate { +// weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self } +// func parent() -> UIViewController { return self } +//} // MARK: - CustomScrollViewContainerController extension UserTimelineViewController: ScrollViewContainer { @@ -204,19 +201,22 @@ extension UserTimelineViewController: ScrollViewContainer { // var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.stateMachine } //} -extension UserTimelineViewController { - override var keyCommands: [UIKeyCommand]? { - return navigationKeyCommands + statusNavigationKeyCommands - } -} +//extension UserTimelineViewController { +// override var keyCommands: [UIKeyCommand]? { +// return navigationKeyCommands + statusNavigationKeyCommands +// } +//} +// +//// MARK: - StatusTableViewControllerNavigateable +//extension UserTimelineViewController: StatusTableViewControllerNavigateable { +// @objc func navigateKeyCommandHandlerRelay(_ sender: UIKeyCommand) { +// navigateKeyCommandHandler(sender) +// } +// +// @objc func statusKeyCommandHandlerRelay(_ sender: UIKeyCommand) { +// statusKeyCommandHandler(sender) +// } +//} -// MARK: - StatusTableViewControllerNavigateable -extension UserTimelineViewController: StatusTableViewControllerNavigateable { - @objc func navigateKeyCommandHandlerRelay(_ sender: UIKeyCommand) { - navigateKeyCommandHandler(sender) - } - - @objc func statusKeyCommandHandlerRelay(_ sender: UIKeyCommand) { - statusKeyCommandHandler(sender) - } -} +// MARK: - StatusTableViewCellDelegate +extension UserTimelineViewController: StatusTableViewCellDelegate { } diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift index 0d6d4782..cdd92bba 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift @@ -6,32 +6,84 @@ // 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 + ) ) - + // set empty section to make update animation top-to-bottom style - var snapshot = NSDiffableDataSourceSnapshot() + var snapshot = NSDiffableDataSourceSnapshot() 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() + 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.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 2566006e..06f657ba 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 ?? "")") + } + + @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 5bf520d6..9701ba48 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 - let userID: CurrentValueSubject - let queryFilter: CurrentValueSubject + @Published var domain: String? + @Published var userID: String? + @Published var queryFilter: QueryFilter let statusFetchedResultsController: StatusFetchedResultsController let listBatchFetchViewModel = ListBatchFetchViewModel() - var cellFrameCache = NSCache() let isBlocking = CurrentValueSubject(false) let isBlockedBy = CurrentValueSubject(false) @@ -33,7 +32,7 @@ final class UserTimelineViewModel { var dataSourceDidUpdate = PassthroughSubject() // output - var diffableDataSource: UITableViewDiffableDataSource? + var diffableDataSource: UITableViewDiffableDataSource? 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() - 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 dd773063..00000000 --- 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 { - return Future { promise in promise(.success(nil)) } - } - - func status(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future { - 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 { - return Future { promise in promise(.success(nil)) } - } - - var managedObjectContext: NSManagedObjectContext { - return viewModel.fetchedResultsController.managedObjectContext - } - - var tableViewDiffableDataSource: UITableViewDiffableDataSource? { - 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 29d84b79..00000000 --- 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() - 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 e9d5c518..00000000 --- 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, 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 4727072b..00000000 --- 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 c165adb7..00000000 --- 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 6d6ecbd3..00000000 --- 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() - - // input - let context: AppContext - let fetchedResultsController: NSFetchedResultsController - - let isFetchingLatestTimeline = CurrentValueSubject(false) - - // middle loader - let loadMiddleSateMachineList = CurrentValueSubject<[String: GKStateMachine], Never>([:]) - - weak var tableView: UITableView? - - weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate? - - // - var statusIDsWhichHasGap = [String]() - // output - var diffableDataSource: UITableViewDiffableDataSource? - - 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() - - 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() - 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 { - let item: T - let sourceIndexPath: IndexPath - let targetIndexPath: IndexPath - let offset: CGFloat - } - - private func calculateReloadSnapshotDifference( - navigationBar: UINavigationBar, - tableView: UITableView, - oldSnapshot: NSDiffableDataSourceSnapshot, - newSnapshot: NSDiffableDataSourceSnapshot - ) -> Difference? { - 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 index 0bad78cb..19cd21ff 100644 --- a/Mastodon/Scene/Report/ReportFooterView.swift +++ b/Mastodon/Scene/Report/ReportFooterView.swift @@ -6,6 +6,8 @@ // import UIKit +import MastodonAsset +import MastodonLocalization final class ReportFooterView: UIView { enum Step: Int { diff --git a/Mastodon/Scene/Report/ReportHeaderView.swift b/Mastodon/Scene/Report/ReportHeaderView.swift index 23572c11..cf74e96f 100644 --- a/Mastodon/Scene/Report/ReportHeaderView.swift +++ b/Mastodon/Scene/Report/ReportHeaderView.swift @@ -6,6 +6,9 @@ // import UIKit +import MastodonAsset +import MastodonLocalization + struct ReportView { static var horizontalMargin: CGFloat { return 12 } diff --git a/Mastodon/Scene/Report/ReportViewController.swift b/Mastodon/Scene/Report/ReportViewController.swift index b97424cb..28f38aa5 100644 --- a/Mastodon/Scene/Report/ReportViewController.swift +++ b/Mastodon/Scene/Report/ReportViewController.swift @@ -13,6 +13,8 @@ import os.log import UIKit import MastodonSDK import MastodonMeta +import MastodonAsset +import MastodonLocalization class ReportViewController: UIViewController, NeedsDependency { static let kAnimationDuration: TimeInterval = 0.33 @@ -22,7 +24,7 @@ class ReportViewController: UIViewController, NeedsDependency { var viewModel: ReportViewModel! { willSet { precondition(!isViewLoaded) } } var disposeBag = Set() - let didToggleSelected = PassthroughSubject() +// let didToggleSelected = PassthroughSubject() let comment = CurrentValueSubject(nil) let step1Continue = PassthroughSubject() let step1Skip = PassthroughSubject() @@ -154,7 +156,7 @@ class ReportViewController: UIViewController, NeedsDependency { private func bindViewModel() { let input = ReportViewModel.Input( - didToggleSelected: didToggleSelected.eraseToAnyPublisher(), +// didToggleSelected: didToggleSelected.eraseToAnyPublisher(), comment: comment.eraseToAnyPublisher(), step1Continue: step1Continue.eraseToAnyPublisher(), step1Skip: step1Skip.eraseToAnyPublisher(), @@ -273,7 +275,7 @@ class ReportViewController: UIViewController, NeedsDependency { navigationItem.titleView = titleView if let user = beReportedUser { do { - let mastodonContent = MastodonContent(content: user.displayNameWithFallback, emojis: user.emojiMeta) + let mastodonContent = MastodonContent(content: user.displayNameWithFallback, emojis: user.emojis.asDictionary) let metaContent = try MastodonMetaContent.convert(document: mastodonContent) titleView.update(titleMetaContent: metaContent, subtitle: nil) } catch { @@ -342,14 +344,14 @@ extension ReportViewController: UITableViewDelegate { guard let item = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else { return } - didToggleSelected.send(item) +// didToggleSelected.send(item) } func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) { guard let item = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else { return } - didToggleSelected.send(item) +// didToggleSelected.send(item) } } diff --git a/Mastodon/Scene/Report/ReportViewModel+Data.swift b/Mastodon/Scene/Report/ReportViewModel+Data.swift index 178fc18a..1ba49f31 100644 --- a/Mastodon/Scene/Report/ReportViewModel+Data.swift +++ b/Mastodon/Scene/Report/ReportViewModel+Data.swift @@ -19,120 +19,123 @@ extension ReportViewModel { 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) + fatalError() +// 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() { + fatalError() 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() - 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) +// 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() +// 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 - ) - } - } + fatalError() +// 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 index 73d6ffa0..4133f5fb 100644 --- a/Mastodon/Scene/Report/ReportViewModel+Diffable.swift +++ b/Mastodon/Scene/Report/ReportViewModel+Diffable.swift @@ -15,21 +15,22 @@ extension ReportViewModel { 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() - snapshot.appendSections([.main]) - diffableDataSource?.apply(snapshot) + fatalError() +// 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() +// snapshot.appendSections([.main]) +// diffableDataSource?.apply(snapshot) } } diff --git a/Mastodon/Scene/Report/ReportViewModel.swift b/Mastodon/Scene/Report/ReportViewModel.swift index c8e59e8d..90ff106f 100644 --- a/Mastodon/Scene/Report/ReportViewModel.swift +++ b/Mastodon/Scene/Report/ReportViewModel.swift @@ -33,12 +33,12 @@ class ReportViewModel: NSObject { var disposeBag = Set() let currentStep = CurrentValueSubject(.one) let statusFetchedResultsController: StatusFetchedResultsController - var diffableDataSource: UITableViewDiffableDataSource? + var diffableDataSource: UITableViewDiffableDataSource? let continueEnableSubject = CurrentValueSubject(false) let sendEnableSubject = CurrentValueSubject(false) struct Input { - let didToggleSelected: AnyPublisher +// let didToggleSelected: AnyPublisher let comment: AnyPublisher let step1Continue: AnyPublisher let step1Skip: AnyPublisher @@ -113,25 +113,25 @@ class ReportViewModel: NSObject { // 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.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 } diff --git a/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift b/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift index 0880c479..aaba5c83 100644 --- a/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift +++ b/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift @@ -13,8 +13,10 @@ import CoreData import CoreDataStack import Meta import MetaTextKit +import MastodonAsset +import MastodonLocalization -final class ReportedStatusTableViewCell: UITableViewCell, StatusCell { +final class ReportedStatusTableViewCell: UITableViewCell { static let bottomPaddingHeight: CGFloat = 10 @@ -46,12 +48,12 @@ final class ReportedStatusTableViewCell: UITableViewCell, StatusCell { 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 +// 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() } @@ -132,9 +134,9 @@ extension ReportedStatusTableViewCell { resetSeparatorLineLayout() selectionStyle = .none - statusView.delegate = self - statusView.statusMosaicImageViewContainer.delegate = self - statusView.actionToolbarContainer.isHidden = true +// statusView.delegate = self +// statusView.statusMosaicImageViewContainer.delegate = self +// statusView.actionToolbarContainer.isHidden = true } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { @@ -181,39 +183,39 @@ extension ReportedStatusTableViewCell: MosaicImageViewContainerDelegate { } func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { - - guard let dependency = self.dependency else { return } - StatusProviderFacade.responseToStatusContentWarningRevealAction(dependency: dependency, cell: self) + fatalError() +// 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) { - } - -} +//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/Root/MainTab/MainTabBarController.swift b/Mastodon/Scene/Root/MainTab/MainTabBarController.swift index 058a0fc3..db50565a 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 { @@ -587,7 +589,12 @@ 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)) } diff --git a/Mastodon/Scene/Root/Sidebar/SidebarViewController.swift b/Mastodon/Scene/Root/Sidebar/SidebarViewController.swift index b5f67e76..6568ab0c 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 37b46932..3cc277dc 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 72b2577f..da3793a9 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 6a1bb3dd..33e0867c 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 00000000..48a7606b --- /dev/null +++ b/Mastodon/Scene/Search/Search/Cell/TrendCollectionViewCell.swift @@ -0,0 +1,134 @@ +// +// 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() + + 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 00000000..4632f384 --- /dev/null +++ b/Mastodon/Scene/Search/Search/Cell/TrendSectionHeaderCollectionReusableView.swift @@ -0,0 +1,64 @@ +// +// 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 + 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 2b0c4736..00000000 --- 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() - - 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 3a20788b..00000000 --- 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 386b0af1..00000000 --- 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 { - return Future { promise in - promise(.success(nil)) - } - } - - func mastodonUser() -> Future { - 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 4365a63f..00000000 --- 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 8dcf9cd3..ebfa1584 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,14 @@ 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) + return collectionView }() let searchBarTapPublisher = PassthroughSubject() @@ -107,7 +59,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 +69,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 +127,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 +138,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 +149,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 00000000..ca741b7f --- /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() + 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() + 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 feae7519..2776713d 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() // output - let currentMastodonUser = CurrentValueSubject(nil) - - var recommendAccounts = [NSManagedObjectID]() - var recommendAccountsFallback = PassthroughSubject() + var diffableDataSource: UICollectionViewDiffableDataSource? + @Published var hashtags: [Mastodon.Entity.Tag] = [] - var hashtagDiffableDataSource: UICollectionViewDiffableDataSource? - var accountDiffableDataSource: UICollectionViewDiffableDataSource? - 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, Error> { response } } - .catch { error in Just(Result, 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, Error> { response } } + .catch { error in Just(Result, 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() - 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, 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, 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() - 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 a64aa270..cd76fb0c 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 a828c64b..0b7495cc 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 486a3b48..598f5df4 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.value = allSearchScopeViewController.viewModel.userFetchedResultsController.userIDs.value 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 e53108bc..140fe14e 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 00000000..4af94304 --- /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 00000000..d4cb86eb --- /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 00000000..71663dd6 --- /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() + + 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 00000000..a1bae263 --- /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 f60b2029..0dbb89cf 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() + + let logger = Logger(subsystem: "SearchHistoryViewController", category: "ViewController") weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + var disposeBag = Set() 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 00000000..c559523a --- /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() + 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() + 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 0ed58b07..c7a13596 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! + var diffableDataSource: UICollectionViewDiffableDataSource? 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() - 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() - 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 6a360e78..8ac661b1 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 00000000..c8938c54 --- /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 00000000..71ac81ef --- /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 73e3ffb8..00000000 --- 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 { - return Future { promise in promise(.success(nil)) } - } - - func status(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future { - 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 { - return Future { promise in promise(.success(nil)) } - } - - var managedObjectContext: NSManagedObjectContext { - return self.viewModel.statusFetchedResultsController.fetchedResultsController.managedObjectContext - } - - var tableViewDiffableDataSource: UITableViewDiffableDataSource? { - 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 6c320af5..f3d989b4 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 { - 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 00000000..ff64b80f --- /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() + 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() + 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 dba71b50..1c0e5aa0 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 ?? "")") + } + + @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.value = [] + 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 b22e91c8..c5656ac0 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("") + @Published var hashtags: [Mastodon.Entity.Tag] = [] + let userFetchedResultsController: UserFetchedResultsController let statusFetchedResultsController: StatusFetchedResultsController + let listBatchFetchViewModel = ListBatchFetchViewModel() + let viewDidAppear = CurrentValueSubject(false) var cellFrameCache = NSCache() var navigationBarFrame = CurrentValueSubject(.zero) // output + var diffableDataSource: UITableViewDiffableDataSource! + @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! let didDataSourceUpdate = PassthroughSubject() init(context: AppContext, searchScope: SearchDetailViewModel.SearchScope) { self.context = context self.searchScope = searchScope + self.userFetchedResultsController = UserFetchedResultsController( + managedObjectContext: context.managedObjectContext, + domain: nil, + additionalTweetPredicate: nil + ) self.statusFetchedResultsController = StatusFetchedResultsController( managedObjectContext: context.managedObjectContext, domain: nil, additionalTweetPredicate: nil ) + context.authenticationService.activeMastodonAuthenticationBox + .map { $0?.domain } + .assign(to: \.value, on: userFetchedResultsController.domain) + .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() - 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() +// 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() - 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 0c919e7d..00000000 --- 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/SettingsViewController.swift b/Mastodon/Scene/Settings/SettingsViewController.swift index 04c34364..9352603e 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 { diff --git a/Mastodon/Scene/Settings/View/AppearanceView.swift b/Mastodon/Scene/Settings/View/AppearanceView.swift index fd08fd43..ef45504a 100644 --- a/Mastodon/Scene/Settings/View/AppearanceView.swift +++ b/Mastodon/Scene/Settings/View/AppearanceView.swift @@ -6,6 +6,8 @@ // import UIKit +import MastodonAsset +import MastodonLocalization class AppearanceView: UIView { lazy var imageView: UIImageView = { diff --git a/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift b/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift index a4904136..1e0754d8 100644 --- a/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift +++ b/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift @@ -7,6 +7,8 @@ import UIKit import Combine +import MastodonAsset +import MastodonLocalization protocol SettingsAppearanceTableViewCellDelegate: AnyObject { func settingsAppearanceCell(_ cell: SettingsAppearanceTableViewCell, didSelectAppearanceMode appearanceMode: SettingsItem.AppearanceMode) diff --git a/Mastodon/Scene/Settings/View/Cell/SettingsToggleTableViewCell.swift b/Mastodon/Scene/Settings/View/Cell/SettingsToggleTableViewCell.swift index 18c9e515..e75fa831 100644 --- a/Mastodon/Scene/Settings/View/Cell/SettingsToggleTableViewCell.swift +++ b/Mastodon/Scene/Settings/View/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) diff --git a/Mastodon/Scene/Settings/View/SettingsSectionHeader.swift b/Mastodon/Scene/Settings/View/SettingsSectionHeader.swift index 0ce45101..817dbf37 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 fb2d282a..0d3c4f57 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 f56ff060..1122ba33 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() - + // input - let aspectRatio: CGSize + let assetURL: URL let thumbnail: UIImage? - let url = CurrentValueSubject(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/View/Button/AvatarStackContainerButton.swift b/Mastodon/Scene/Share/View/Button/AvatarStackContainerButton.swift deleted file mode 100644 index 6c2d00e3..00000000 --- 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 f56e7e7e..00000000 --- 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 676d558a..657573db 100644 --- a/Mastodon/Scene/Share/View/Button/PrimaryActionButton.swift +++ b/Mastodon/Scene/Share/View/Button/PrimaryActionButton.swift @@ -6,6 +6,8 @@ // import UIKit +import MastodonAsset +import MastodonLocalization class PrimaryActionButton: UIButton { diff --git a/Mastodon/Scene/Share/View/Container/AudioContainerView.swift b/Mastodon/Scene/Share/View/Container/AudioContainerView.swift index 8516db56..0dd11d13 100644 --- a/Mastodon/Scene/Share/View/Container/AudioContainerView.swift +++ b/Mastodon/Scene/Share/View/Container/AudioContainerView.swift @@ -8,6 +8,8 @@ import CoreDataStack import os.log import UIKit +import MastodonAsset +import MastodonLocalization final class AudioContainerView: UIView { static let cornerRadius: CGFloat = 22 diff --git a/Mastodon/Scene/Share/View/Container/PlayerContainerView+MediaTypeIndicotorView.swift b/Mastodon/Scene/Share/View/Container/PlayerContainerView+MediaTypeIndicotorView.swift index 2c029814..faa7b8f6 100644 --- a/Mastodon/Scene/Share/View/Container/PlayerContainerView+MediaTypeIndicotorView.swift +++ b/Mastodon/Scene/Share/View/Container/PlayerContainerView+MediaTypeIndicotorView.swift @@ -6,6 +6,8 @@ // import UIKit +import MastodonAsset +import MastodonLocalization extension PlayerContainerView { diff --git a/Mastodon/Scene/Share/View/Content/ContentWarningOverlayView.swift b/Mastodon/Scene/Share/View/Content/ContentWarningOverlayView.swift index d5a457a2..78c5462f 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 d900307a..b6a36f0e 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 00000000..5401ec9b --- /dev/null +++ b/Mastodon/Scene/Share/View/Content/MediaView+Configuration.swift @@ -0,0 +1,50 @@ +// +// MediaView+Configuration.swift +// Mastodon +// +// Created by MainasuK on 2022-1-12. +// + +import UIKit +import Combine +import CoreDataStack +import MastodonUI + +extension MediaView { + public static func configuration(status: Status) -> AnyPublisher<[MediaView.Configuration], Never> { + 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 + return status.publisher(for: \.attachments) + .map { attachments -> [MediaView.Configuration] in + return attachments.map { attachment -> MediaView.Configuration in + switch attachment.kind { + case .image: + let info = MediaView.Configuration.ImageInfo( + aspectRadio: attachment.size, + assetURL: attachment.assetURL + ) + return .image(info: info) + case .video: + let info = videoInfo(from: attachment) + return .video(info: info) + case .gifv: + let info = videoInfo(from: attachment) + return .gif(info: info) + case .audio: + // TODO: + let info = videoInfo(from: attachment) + return .video(info: info) + } + } + } + .eraseToAnyPublisher() + } +} diff --git a/Mastodon/Scene/Share/View/Content/NavigationBarProgressView.swift b/Mastodon/Scene/Share/View/Content/NavigationBarProgressView.swift index 3cb1d1d9..efa8b53a 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 00000000..41550a2a --- /dev/null +++ b/Mastodon/Scene/Share/View/Content/NotificationView+Configuration.swift @@ -0,0 +1,205 @@ +// +// 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.timestampFormatter = { (date: Date) in + date.localizedSlowedTimeAgoSinceNow + } + notification.publisher(for: \.createAt) + .map { $0 as Date? } + .assign(to: \.timestamp, on: viewModel) + .store(in: &disposeBag) + // 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.userFollowedYou(""), + emojis: emojis.asDictionary + ) + case .followRequest: + self.viewModel.notificationIndicatorText = createMetaContent( + text: L10n.Scene.Notification.userRequestedToFollowYou(author.displayNameWithFallback), + emojis: emojis.asDictionary + ) + case .mention: + self.viewModel.notificationIndicatorText = createMetaContent( + text: L10n.Scene.Notification.userMentionedYou(""), + emojis: emojis.asDictionary + ) + case .reblog: + self.viewModel.notificationIndicatorText = createMetaContent( + text: L10n.Scene.Notification.userRebloggedYourPost(""), + emojis: emojis.asDictionary + ) + case .favourite: + self.viewModel.notificationIndicatorText = createMetaContent( + text: L10n.Scene.Notification.userFavoritedYourPost(""), + emojis: emojis.asDictionary + ) + case .poll: + self.viewModel.notificationIndicatorText = createMetaContent( + text: L10n.Scene.Notification.userYourPollHasEnded(""), + emojis: emojis.asDictionary + ) + case .status: + self.viewModel.notificationIndicatorText = createMetaContent( + text: L10n.Scene.Notification.userMentionedYou(""), + 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 00000000..a4183a83 --- /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.secondarySystemBackgroundColor + } + .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 00000000..1605a993 --- /dev/null +++ b/Mastodon/Scene/Share/View/Content/StatusView+Configuration.swift @@ -0,0 +1,412 @@ +// +// StatusView+Configuration.swift +// Mastodon +// +// Created by MainasuK on 2022-1-12. +// + +import UIKit +import Combine +import MastodonUI +import CoreDataStack +import MastodonLocalization +import MastodonMeta +import Meta + +extension StatusView { + 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) + } +} + +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(name) + 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) + + // // protected + // author.publisher(for: \.locked) + // .assign(to: \.protected, on: viewModel) + // .store(in: &disposeBag) + // // visibility + // viewModel.visibility = status.visibility.asStatusVisibility + + // 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) { + // 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 + do { + let content = MastodonContent(content: status.content, emojis: status.emojis.asDictionary) + let metaContent = try MastodonMetaContent.convert(document: content) + viewModel.content = metaContent + // viewModel.sharePlaintextContent = metaContent.original + } catch { + assertionFailure(error.localizedDescription) + viewModel.content = PlaintextMetaContent(string: "") + } + +// 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() +// viewModel.spoilerContent = nil +// } +// } else { +// viewModel.spoilerContent = nil +// } + +// status.publisher(for: \.isContentReveal) +// .assign(to: \.isContentReveal, on: viewModel) +// .store(in: &disposeBag) +// +// viewModel.source = status.source + } + + private func configureMedia(status: Status) { + let status = status.reblog ?? status + +// mediaGridContainerView.viewModel.resetContentWarningOverlay() +// viewModel.isMediaSensitiveSwitchable = true + + MediaView.configuration(status: status) + .assign(to: \.mediaViewConfigurations, on: viewModel) + .store(in: &disposeBag) + +// // set directly without delay +// viewModel.isMediaSensitiveToggled = status.isMediaSensitiveToggled +// viewModel.isMediaSensitive = status.isMediaSensitive +// mediaGridContainerView.configureOverlayDisplay( +// isDisplay: status.isMediaSensitiveToggled ? !status.isMediaSensitive : !status.isMediaSensitive, +// animated: false +// ) +// +// status.publisher(for: \.isMediaSensitive) +// .receive(on: DispatchQueue.main) +// .assign(to: \.isMediaSensitive, on: viewModel) +// .store(in: &disposeBag) +// +// status.publisher(for: \.isMediaSensitiveToggled) +// .receive(on: DispatchQueue.main) +// .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) + } + +} diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift deleted file mode 100644 index 62eb3d6b..00000000 --- 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? - 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 50518e59..e26604dc 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 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 +//#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 00000000..3d22eeda --- /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 0b3f2a8f..00000000 --- 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/TableviewCell/PollOptionTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift deleted file mode 100644 index 16b39feb..00000000 --- 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() - - 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 00000000..87c01b18 --- /dev/null +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell+ViewModel.swift @@ -0,0 +1,57 @@ +// +// 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 + 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 + } + +} diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index 38c86c11..aa8da714 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -7,98 +7,40 @@ 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 } - - 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() - var pollCountdownSubscription: AnyCancellable? - var observations = Set() - + 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 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() - 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?) { @@ -123,253 +65,262 @@ extension StatusTableViewCell { statusView.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(statusView) 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), + statusView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + statusView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + 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) 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.translatesAutoresizingMaskIntoConstraints = false +// contentView.addSubview(statusView) +// NSLayoutConstraint.activate([ +// statusView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 20), +// statusView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), +// contentView.readableContentGuide.trailingAnchor.constraint(equalTo: statusView.trailingAnchor), +// ]) +// +// 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) +// +// 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 } - 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 - } +// 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 +// } } -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, - ]) - } - } - } +//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, +// ]) +// } +// } +// } +// +//} +// +//// MARK: - MosaicImageViewContainerPresentable +//extension StatusTableViewCell: MosaicImageViewContainerPresentable { +// +// var mosaicImageViewContainer: MosaicImageViewContainer { +// return statusView.statusMosaicImageViewContainer +// } +// +// 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 ?? "") +// } +// 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 ?? "") +// } +// +// 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: - MosaicImageViewContainerPresentable -extension StatusTableViewCell: MosaicImageViewContainerPresentable { - - var mosaicImageViewContainer: MosaicImageViewContainer { - return statusView.statusMosaicImageViewContainer - } - - 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 ?? "") - } - 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 ?? "") - } - - 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) - } +extension StatusTableViewCell: StatusViewDelegate { } - 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 { } - } -} +//// 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) +// } +// +//} diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCellDelegate.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCellDelegate.swift new file mode 100644 index 00000000..dcbf41f4 --- /dev/null +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCellDelegate.swift @@ -0,0 +1,74 @@ +// +// 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, 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) + // 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, 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) + } + // 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 00000000..d549ba1f --- /dev/null +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusThreadRootTableViewCell+ViewModel.swift @@ -0,0 +1,46 @@ +// +// 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 + 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 + } + +} diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusThreadRootTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusThreadRootTableViewCell.swift new file mode 100644 index 00000000..a330161f --- /dev/null +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusThreadRootTableViewCell.swift @@ -0,0 +1,85 @@ +// +// 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 { + + let logger = Logger(subsystem: "StatusTableViewCell", category: "View") + + weak var delegate: StatusTableViewCellDelegate? + var disposeBag = Set() + + 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() + } + + 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) + NSLayoutConstraint.activate([ + statusView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 16), + statusView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + statusView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + statusView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + ]) + statusView.setup(style: .plain) + + 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.isSelectable = true + statusView.contentMetaText.textView.isAccessibilityElement = false + } + +} + +// 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 a819f301..065a4128 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 43dd2c6f..a1b9fe08 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 da0b80fb..29344eb2 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 00000000..406d2a7e --- /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() + + @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 4a0b623e..a12920c5 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 00000000..3ec85fa4 --- /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 29e28415..425226b7 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 f771f8bb..00000000 --- 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 index c3180221..bec9ab12 100644 --- a/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift +++ b/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift @@ -11,107 +11,108 @@ 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 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, + audioAttachment: MastodonAttachment, 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) + fatalError() +// 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 index 5ceb8781..8f53e4dd 100644 --- a/Mastodon/Scene/Share/ViewModel/MosaicImageViewModel.swift +++ b/Mastodon/Scene/Share/ViewModel/MosaicImageViewModel.swift @@ -9,47 +9,47 @@ 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 { - guard let blurhash = blurhash else { - return Just(nil).eraseToAnyPublisher() - } - return AppContext.shared.blurhashImageCacheService.image(blurhash: blurhash, size: size, url: url) - } - -} +//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 { +// 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/SuggestionAccount/CollectionViewCell/SuggestionAccountCollectionViewCell.swift b/Mastodon/Scene/SuggestionAccount/CollectionViewCell/SuggestionAccountCollectionViewCell.swift index a973e1c5..2fb467fb 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 d27c1fbe..13c2efb3 100644 --- a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift +++ b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift @@ -11,6 +11,8 @@ import CoreDataStack import Foundation import OSLog import UIKit +import MastodonAsset +import MastodonLocalization class SuggestionAccountViewController: UIViewController, NeedsDependency { weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } @@ -66,47 +68,49 @@ class SuggestionAccountViewController: UIViewController, NeedsDependency { extension SuggestionAccountViewController { override func viewDidLoad() { super.viewDidLoad() + + fatalError() - 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) - - title = L10n.Scene.SuggestionAccount.title - navigationItem.rightBarButtonItem - = UIBarButtonItem(barButtonSystemItem: UIBarButtonItem.SystemItem.done, - target: self, - action: #selector(SuggestionAccountViewController.doneButtonDidClick(_:))) - - tableView.delegate = self - 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.diffableDataSource = RecommendAccountSection.tableViewDiffableDataSource( - for: tableView, - managedObjectContext: context.managedObjectContext, - viewModel: viewModel, - delegate: 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) +// 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) +// +// title = L10n.Scene.SuggestionAccount.title +// navigationItem.rightBarButtonItem +// = UIBarButtonItem(barButtonSystemItem: UIBarButtonItem.SystemItem.done, +// target: self, +// action: #selector(SuggestionAccountViewController.doneButtonDidClick(_:))) +// +// tableView.delegate = self +// 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.diffableDataSource = RecommendAccountSection.tableViewDiffableDataSource( +// for: tableView, +// managedObjectContext: context.managedObjectContext, +// viewModel: viewModel, +// delegate: 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) { diff --git a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift index e876041c..a6786adf 100644 --- a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift +++ b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift @@ -193,39 +193,41 @@ final class SuggestionAccountViewModel: NSObject { } func followAction(objectID: NSManagedObjectID) -> AnyPublisher, 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 - ) + fatalError() +// 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 + fatalError() +// 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.swift b/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift index 905e1db3..5f679a2c 100644 --- a/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift +++ b/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift @@ -13,6 +13,8 @@ import MastodonSDK import UIKit import MetaTextKit import MastodonMeta +import MastodonAsset +import MastodonLocalization protocol SuggestionAccountTableViewCellDelegate: AnyObject { func accountButtonPressed(objectID: NSManagedObjectID, cell: SuggestionAccountTableViewCell) @@ -147,7 +149,7 @@ extension SuggestionAccountTableViewCell { imageTransition: .crossDissolve(0.2) ) } - let mastodonContent = MastodonContent(content: account.displayNameWithFallback, emojis: account.emojiMeta) + let mastodonContent = MastodonContent(content: account.displayNameWithFallback, emojis: account.emojis.asDictionary) do { let metaContent = try MastodonMetaContent.convert(document: mastodonContent) titleLabel.configure(content: metaContent) diff --git a/Mastodon/Scene/Thread/CachedThreadViewModel.swift b/Mastodon/Scene/Thread/CachedThreadViewModel.swift index d4866b0b..c4ff3b98 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 00000000..c158270c --- /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() + + // input + let context: AppContext + @Published private(set) var deletedObjectIDs: Set = 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(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(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(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] = [:] + + 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] + ) -> 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 f8f5d3e7..6d2e3d97 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 00000000..fc2584dc --- /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 c6bd29e1..00000000 --- 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 { - return Future { promise in promise(.success(nil)) } - } - - func status(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future { - 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 { - return Future { promise in promise(.success(nil)) } - } - - var managedObjectContext: NSManagedObjectContext { - return viewModel.context.managedObjectContext - } - - var tableViewDiffableDataSource: UITableViewDiffableDataSource? { - 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 a0de1347..cfc28447 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,60 @@ extension ThreadViewController { tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), ]) - - Publishers.CombineLatest( - viewModel.navigationBarTitle, - viewModel.navigationBarTitleEmojiMeta + +// viewModel.tableView = tableView +// viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self + tableView.delegate = self +// tableView.prefetchDataSource = 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 { 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,85 +152,145 @@ 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 + + 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 } + + switch item { + case .thread(let thread): + switch thread { + case .root: + return nil + default: + return indexPath + } + default: + return indexPath + } + } + + +// 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, 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) +// } +// +// 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 ThreadViewController: UITableViewDataSourcePrefetching { - func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { - aspectTableView(tableView, prefetchRowsAt: indexPaths) - } -} - -// MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate -extension ThreadViewController: ContentOffsetAdjustableTimelineViewControllerDelegate { - func navigationBar() -> UINavigationBar? { - return navigationController?.navigationBar - } -} +//extension ThreadViewController: UITableViewDataSourcePrefetching { +// func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { +// aspectTableView(tableView, prefetchRowsAt: indexPaths) +// } +//} // 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) - } - -} +//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 } -} +//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 } - - 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) - } - } - } -} +//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 } +// +// 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) +// } +// } +// } +//} -extension ThreadViewController { - override var keyCommands: [UIKeyCommand]? { - return navigationKeyCommands + statusNavigationKeyCommands - } -} +//extension ThreadViewController { +// override var keyCommands: [UIKeyCommand]? { +// return navigationKeyCommands + statusNavigationKeyCommands +// } +//} +// +//// MARK: - StatusTableViewControllerNavigateable +//extension ThreadViewController: StatusTableViewControllerNavigateable { +// @objc func navigateKeyCommandHandlerRelay(_ sender: UIKeyCommand) { +// navigateKeyCommandHandler(sender) +// } +// +// @objc func statusKeyCommandHandlerRelay(_ sender: UIKeyCommand) { +// statusKeyCommandHandler(sender) +// } +//} -// MARK: - StatusTableViewControllerNavigateable -extension ThreadViewController: StatusTableViewControllerNavigateable { - @objc func navigateKeyCommandHandlerRelay(_ sender: UIKeyCommand) { - navigateKeyCommandHandler(sender) - } - - @objc func statusKeyCommandHandlerRelay(_ sender: UIKeyCommand) { - statusKeyCommandHandler(sender) - } -} +// MARK: - StatusTableViewCellDelegate +extension ThreadViewController: StatusTableViewCellDelegate { } diff --git a/Mastodon/Scene/Thread/ThreadViewModel+Diffable.swift b/Mastodon/Scene/Thread/ThreadViewModel+Diffable.swift index 853bee9d..71dd003f 100644 --- a/Mastodon/Scene/Thread/ThreadViewModel+Diffable.swift +++ b/Mastodon/Scene/Thread/ThreadViewModel+Diffable.swift @@ -13,228 +13,421 @@ 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 + ) ) - var snapshot = NSDiffableDataSourceSnapshot() + // make initial snapshot animation smooth + var snapshot = NSDiffableDataSourceSnapshot() 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) - 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() - 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() + 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() +// 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 { - let item: T - let sourceIndexPath: IndexPath - let targetIndexPath: IndexPath - let offset: CGFloat + + @MainActor func updateDataSource( + snapshot: NSDiffableDataSourceSnapshot, + animatingDifferences: Bool + ) async { + diffableDataSource?.apply(snapshot, animatingDifferences: animatingDifferences) } - private func calculateReloadSnapshotDifference( - navigationBar: UINavigationBar, - tableView: UITableView, - oldSnapshot: NSDiffableDataSourceSnapshot, - newSnapshot: NSDiffableDataSourceSnapshot - ) -> Difference? { - guard oldSnapshot.numberOfItems != 0 else { return nil } - guard let visibleIndexPaths = tableView.indexPathsForVisibleRows?.sorted() else { return nil } + @MainActor func updateSnapshotUsingReloadData( + snapshot: NSDiffableDataSourceSnapshot + ) 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, + newSnapshot: NSDiffableDataSourceSnapshot, + difference: ThreadViewModel.Difference // + ) 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, + newSnapshot: NSDiffableDataSourceSnapshot + ) -> 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 82724264..86fdc211 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 ?? "")") + } + + @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 7c2f07c3..5a3127e6 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() var rootItemObserver: AnyCancellable? // input let context: AppContext - let rootNode: CurrentValueSubject - let rootItem: CurrentValueSubject - let cellFrameCache = NSCache() - let existStatusFetchedResultsController: StatusFetchedResultsController + let mastodonStatusThreadViewModel: MastodonStatusThreadViewModel - weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate? - weak var tableView: UITableView? +// let cellFrameCache = NSCache() +// let existStatusFetchedResultsController: StatusFetchedResultsController + +// weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate? +// weak var tableView: UITableView? // output - var diffableDataSource: UITableViewDiffableDataSource? + var diffableDataSource: UITableViewDiffableDataSource? + @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 - let navigationBarTitleEmojiMeta: CurrentValueSubject + @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 ec4ac35a..8078ba1c 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) @@ -162,7 +162,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) @@ -183,7 +183,7 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { }() let maskLayerToPath = maskLayerToRect.flatMap { UIBezierPath(rect: $0) }?.cgPath 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 tabBar when bar visible guard let tabBarController = toVC.tabBarController, @@ -450,7 +450,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 +476,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 bd5781b0..d8d822bc 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 7024d305..42efde45 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 { @@ -43,21 +44,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(0, 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/MediaPreviewableViewController.swift b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift index 1fedf0d4..696b72ab 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 9152e64f..d7530d49 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 diff --git a/Mastodon/Service/APIService/APIService+APIError.swift b/Mastodon/Service/APIService/APIService+APIError.swift index 181495cf..5670f805 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 7638f244..7bd26289 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,61 @@ extension APIService { domain: String, userID: Mastodon.Entity.Account.ID, authorization: Mastodon.API.OAuth.Authorization - ) -> AnyPublisher, Error> { - return Mastodon.API.Account.accountInfo( + ) async throws -> Mastodon.Response.Content { + let response = try await Mastodon.API.Account.accountInfo( session: session, domain: domain, userID: userID, authorization: authorization - ) - .flatMap { response -> AnyPublisher, 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 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 +// .flatMap { response -> AnyPublisher, 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: 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 in +// switch result { +// case .success: +// return response +// case .failure(let error): +// throw error +// } +// } +// .eraseToAnyPublisher() +// } +// .eraseToAnyPublisher() } } @@ -71,18 +91,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 in @@ -102,41 +123,34 @@ extension APIService { domain: String, query: Mastodon.API.Account.UpdateCredentialQuery, authorization: Mastodon.API.OAuth.Authorization - ) -> AnyPublisher, Error> { - return Mastodon.API.Account.updateCredentials( + ) async throws -> Mastodon.Response.Content { + 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, 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 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 209ee361..42840170 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, Error> { - let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light) - let notificationFeedbackGenerator = UINotificationFeedbackGenerator() + user: ManagedObjectRecord, + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content { + 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, 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, 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, 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, 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 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 78a20d10..20c2fe72 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 { - 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, 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, 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" } ?? "", entity.favouritesCount ) - } - .setFailureType(to: Error.self) - .tryMap { result -> Mastodon.Response.Content in - switch result { - case .success: - return response - case .failure(let error): - throw error - } - } - .eraseToAnyPublisher() + record: ManagedObjectRecord, + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content { + 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, 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, 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, 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 ac2ccbea..1e908a2e 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, Error> { + user: ManagedObjectRecord, + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content { + 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, 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, 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, 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 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, 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 0f5c3c25..b2029f3d 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, 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, 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 in - switch result { - case .success: - return response - case .failure(let error): - throw error - } - } - .eraseToAnyPublisher() - } - .eraseToAnyPublisher() - } +// func acceptFollowRequest( +// mastodonUserID: MastodonUser.ID, +// mastodonAuthenticationBox: MastodonAuthenticationBox +// ) -> AnyPublisher, 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, 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 in +// switch result { +// case .success: +// return response +// case .failure(let error): +// throw error +// } +// } +// .eraseToAnyPublisher() +// } +// .eraseToAnyPublisher() +// } - func rejectFollowRequest( - mastodonUserID: MastodonUser.ID, - mastodonAuthenticationBox: MastodonAuthenticationBox - ) -> AnyPublisher, 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, 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 in - switch result { - case .success: - return response - case .failure(let error): - throw error - } - } - .eraseToAnyPublisher() - } - .eraseToAnyPublisher() - } +// func rejectFollowRequest( +// mastodonUserID: MastodonUser.ID, +// mastodonAuthenticationBox: MastodonAuthenticationBox +// ) -> AnyPublisher, 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, 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 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 f75d2420..f0350013 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, 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, 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 8f477d6e..d0cdc233 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, 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, 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 241c7885..ce878389 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, 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, 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 28f68274..39d4cf6e 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, 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, 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 40f97acd..c93dbcf6 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, Error> { - let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light) - let notificationFeedbackGenerator = UINotificationFeedbackGenerator() + user: ManagedObjectRecord, + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content { + 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, 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, 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, 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 9f7d3bb5..6cc0dbba 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, 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, 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, Error> { - let domain = mastodonAuthenticationBox.domain - let authorization = mastodonAuthenticationBox.userAuthorization + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content { + 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, 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 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 ca091161..15a6847c 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, Error> { - let authorization = mastodonAuthenticationBox.userAuthorization - let requestMastodonUserID = mastodonAuthenticationBox.userID + poll: ManagedObjectRecord, + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content { + 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, 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 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 { - 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, choices: [Int], - mastodonAuthenticationBox: MastodonAuthenticationBox - ) -> AnyPublisher, 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, 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 in - switch result { - case .success: - return response - case .failure(let error): - throw error - } - } - .eraseToAnyPublisher() + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content { + 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 bd176f31..00000000 --- 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, 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, 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 88da60f2..c8dde08b 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 { - 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, 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, 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" } ?? "", entity.reblogsCount ) - } - .setFailureType(to: Error.self) - .tryMap { result -> Mastodon.Response.Content in - switch result { - case .success: - return response - case .failure(let error): - throw error - } - } - .eraseToAnyPublisher() + record: ManagedObjectRecord, + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content { + 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, 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 458cb740..7c24fdbf 100644 --- a/Mastodon/Service/APIService/APIService+Recommend.swift +++ b/Mastodon/Service/APIService/APIService+Recommend.swift @@ -18,30 +18,31 @@ extension APIService { query: Mastodon.API.Suggestions.Query?, mastodonAuthenticationBox: MastodonAuthenticationBox ) -> AnyPublisher, Error> { - let authorization = mastodonAuthenticationBox.userAuthorization - - return Mastodon.API.Suggestions.get(session: session, domain: domain, query: query, authorization: authorization) - .flatMap { response -> AnyPublisher, 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() + fatalError() +// let authorization = mastodonAuthenticationBox.userAuthorization +// +// return Mastodon.API.Suggestions.get(session: session, domain: domain, query: query, authorization: authorization) +// .flatMap { response -> AnyPublisher, 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() } func suggestionAccountV2( @@ -49,37 +50,32 @@ extension APIService { query: Mastodon.API.Suggestions.Query?, mastodonAuthenticationBox: MastodonAuthenticationBox ) -> AnyPublisher, Error> { - let authorization = mastodonAuthenticationBox.userAuthorization + fatalError() +// let authorization = mastodonAuthenticationBox.userAuthorization +// +// return Mastodon.API.V2.Suggestions.get(session: session, domain: domain, query: query, authorization: authorization) +// .flatMap { response -> AnyPublisher, 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() + } - return Mastodon.API.V2.Suggestions.get(session: session, domain: domain, query: query, authorization: authorization) - .flatMap { response -> AnyPublisher, 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, Error> { - Mastodon.API.Trends.get(session: session, domain: domain, query: query) - } } diff --git a/Mastodon/Service/APIService/APIService+Relationship.swift b/Mastodon/Service/APIService/APIService+Relationship.swift index 7efd2b39..8c10f137 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, 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, 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], + 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+Search.swift b/Mastodon/Service/APIService/APIService+Search.swift index 4b636806..724d7f61 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, Error> { - let authorization = mastodonAuthenticationBox.userAuthorization - let requestMastodonUserID = mastodonAuthenticationBox.userID + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content { + 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, 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 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 1bd3363c..2b49584f 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, Error> { - let authorization = mastodonAuthenticationBox.userAuthorization + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content { + 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, 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 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 7f82406f..3d764663 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, Error> { - let authorization = authorizationBox.userAuthorization - return Mastodon.API.Statuses.status( + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content { + 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, 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 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, Error> { - let authorization = authorizationBox.userAuthorization - let query = Mastodon.API.Statuses.DeleteStatusQuery(id: statusID) - return Mastodon.API.Statuses.deleteStatus( + status: ManagedObjectRecord, + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content { + 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, 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 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+Thread.swift b/Mastodon/Service/APIService/APIService+Thread.swift index 3bebdffe..782da588 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, Error> { - let authorization = mastodonAuthenticationBox.userAuthorization - guard domain == mastodonAuthenticationBox.domain else { - return Fail(error: APIError.implicit(.badRequest)).eraseToAnyPublisher() - } + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content { + 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, 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 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 00000000..0ce2a86a --- /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 7a449d37..c5cb6318 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, 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, 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/CoreData/APIService+CoreData+MastodonUser.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift deleted file mode 100644 index 90d482bc..00000000 --- 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?, - 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 673cb4de..00000000 --- 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?, - userCache: APIService.Persist.PersistCache?, - 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 bc5718bc..00000000 --- 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 eb354035..00000000 --- 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 { - var dictionary: [String : T] = [:] - } - -} - -extension APIService.Persist.PersistCache where T == Status { - - static func ids(for statuses: [Mastodon.Entity.Status]) -> Set { - var value = Set() - for status in statuses { - value = value.union(ids(for: status)) - } - return value - } - - static func ids(for status: Mastodon.Entity.Status) -> Set { - var value = Set() - 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 { - var value = Set() - for status in statuses { - value = value.union(ids(for: status)) - } - return value - } - - static func ids(for status: Mastodon.Entity.Status) -> Set { - var value = Set() - 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 dab4ba6a..00000000 --- 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 { - - let status: T - let children: [PersistMemo] - 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], - 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?, - userCache: APIService.Persist.PersistCache?, - networkDate: Date, - log: OSLog - ) -> APIService.Persist.PersistMemo { - 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 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( - 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 f5bb4ea3..00000000 --- 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, 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 = { - let cache = PersistCache() - let cacheIDs = PersistCache.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 = { - let cache = PersistCache() - let cacheIDs = PersistCache.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.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] = [] - 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 index 42d3edf7..af7d574c 100644 --- a/Mastodon/Service/AudioPlaybackService.swift +++ b/Mastodon/Service/AudioPlaybackService.swift @@ -21,7 +21,7 @@ final class AudioPlaybackService: NSObject { var player = AVPlayer() var timeObserver: Any? var statusObserver: Any? - var attachment: Attachment? + var attachment: MastodonAttachment? let playbackState = CurrentValueSubject(PlaybackState.unknown) @@ -51,10 +51,10 @@ final class AudioPlaybackService: NSObject { } extension AudioPlaybackService { - func playAudio(audioAttachment: Attachment) { - guard let url = URL(string: audioAttachment.url) else { - return - } + func playAudio(audioAttachment: MastodonAttachment) { + guard let assetURL = audioAttachment.assetURL, + let url = URL(string: assetURL) else + { return } notifyWillPlayAudioNotification() if audioAttachment == attachment { diff --git a/Mastodon/Service/AuthenticationService.swift b/Mastodon/Service/AuthenticationService.swift index 9e27caab..b587a573 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), @@ -148,6 +150,7 @@ extension AuthenticationService { return } _mastodonAuthenticationBox = MastodonAuthenticationBox( + authenticationRecord: .init(objectID: mastodonAuthentication.objectID), domain: mastodonAuthentication.domain, userID: mastodonAuthentication.userID, appAuthorization: Mastodon.API.OAuth.Authorization(accessToken: mastodonAuthentication.appAccessToken), diff --git a/Mastodon/Service/BlockDomainService.swift b/Mastodon/Service/BlockDomainService.swift index 036083e6..90d86014 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 - if let cell = cell { - mastodonUser = userProvider.mastodonUser(for: cell).eraseToAnyPublisher() - } else { - mastodonUser = userProvider.mastodonUser().eraseToAnyPublisher() - } - mastodonUser - .compactMap { mastodonUser -> AnyPublisher, Error>? in - guard let mastodonUser = mastodonUser else { - return nil - } - return context.apiService.blockDomain(user: mastodonUser, authorizationBox: activeMastodonAuthenticationBox) - } - .switchToLatest() - .flatMap { _ -> AnyPublisher, 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 - if let cell = cell { - mastodonUser = userProvider.mastodonUser(for: cell).eraseToAnyPublisher() - } else { - mastodonUser = userProvider.mastodonUser().eraseToAnyPublisher() - } - mastodonUser - .compactMap { mastodonUser -> AnyPublisher, Error>? in - guard let mastodonUser = mastodonUser else { - return nil - } - return context.apiService.unblockDomain(user: mastodonUser, authorizationBox: activeMastodonAuthenticationBox) - } - .switchToLatest() - .flatMap { _ -> AnyPublisher, 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 +// if let cell = cell { +// mastodonUser = userProvider.mastodonUser(for: cell).eraseToAnyPublisher() +// } else { +// mastodonUser = userProvider.mastodonUser().eraseToAnyPublisher() +// } +// mastodonUser +// .compactMap { mastodonUser -> AnyPublisher, Error>? in +// guard let mastodonUser = mastodonUser else { +// return nil +// } +// return context.apiService.blockDomain(user: mastodonUser, authorizationBox: activeMastodonAuthenticationBox) +// } +// .switchToLatest() +// .flatMap { _ -> AnyPublisher, 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 +// if let cell = cell { +// mastodonUser = userProvider.mastodonUser(for: cell).eraseToAnyPublisher() +// } else { +// mastodonUser = userProvider.mastodonUser().eraseToAnyPublisher() +// } +// mastodonUser +// .compactMap { mastodonUser -> AnyPublisher, Error>? in +// guard let mastodonUser = mastodonUser else { +// return nil +// } +// return context.apiService.unblockDomain(user: mastodonUser, authorizationBox: activeMastodonAuthenticationBox) +// } +// .switchToLatest() +// .flatMap { _ -> AnyPublisher, 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 be729a2f..580cb542 100644 --- a/Mastodon/Service/BlurhashImageCacheService.swift +++ b/Mastodon/Service/BlurhashImageCacheService.swift @@ -37,22 +37,23 @@ final class BlurhashImageCacheService { } static func blurhashImage(blurhash: String, size: CGSize, url: URL) -> UIImage? { - let imageSize: CGSize = { - let aspectRadio = size.width / size.height - if size.width > size.height { - let width: CGFloat = MosaicMeta.edgeMaxLength - let height = width / aspectRadio - return CGSize(width: width, height: height) - } else { - let height: CGFloat = MosaicMeta.edgeMaxLength - let width = height * aspectRadio - return CGSize(width: width, height: height) - } - }() - - let image = UIImage(blurHash: blurhash, size: imageSize) - - return image + fatalError() +// let imageSize: CGSize = { +// let aspectRadio = size.width / size.height +// if size.width > size.height { +// let width: CGFloat = MosaicMeta.edgeMaxLength +// let height = width / aspectRadio +// return CGSize(width: width, height: height) +// } else { +// let height: CGFloat = MosaicMeta.edgeMaxLength +// let width = height * aspectRadio +// return CGSize(width: width, height: height) +// } +// }() +// +// let image = UIImage(blurHash: blurhash, size: imageSize) +// +// return image } } diff --git a/Mastodon/Service/NotificationService.swift b/Mastodon/Service/NotificationService.swift index 6eb3120c..d707c1ee 100644 --- a/Mastodon/Service/NotificationService.swift +++ b/Mastodon/Service/NotificationService.swift @@ -144,9 +144,7 @@ extension NotificationService { let authenticationRequest = MastodonAuthentication.sortedFetchRequest authenticationRequest.predicate = MastodonAuthentication.predicate(domain: domain, userID: userID) - let authentication = managedObjectContext.safeFetch(authenticationRequest).first - - guard authentication == nil else { + guard let authentication = managedObjectContext.safeFetch(authenticationRequest).first else { // do nothing if still sign-in return } @@ -154,6 +152,7 @@ extension NotificationService { // cancel subscription if sign-out let accessToken = mastodonPushNotification.accessToken let mastodonAuthenticationBox = MastodonAuthenticationBox( + authenticationRecord: .init(objectID: authentication.objectID), domain: domain, userID: userID, appAuthorization: .init(accessToken: accessToken), diff --git a/Mastodon/Service/SettingService.swift b/Mastodon/Service/SettingService.swift index 79ed47ab..1e8022c5 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/StatusPrefetchingService.swift b/Mastodon/Service/StatusPrefetchingService.swift deleted file mode 100644 index e22ba69f..00000000 --- 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() - 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 1f0fd4e3..0dad463b 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 26673d57..7796fde7 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/VideoPlaybackService.swift b/Mastodon/Service/VideoPlaybackService.swift index f1e28992..3c5ad8a0 100644 --- a/Mastodon/Service/VideoPlaybackService.swift +++ b/Mastodon/Service/VideoPlaybackService.swift @@ -47,16 +47,15 @@ extension VideoPlaybackService { } extension VideoPlaybackService { - func dequeueVideoPlayerViewModel(for media: Attachment) -> VideoPlayerViewModel? { + func dequeueVideoPlayerViewModel(for media: MastodonAttachment) -> 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 + guard let assetURL = media.assetURL, + let url = URL(string: assetURL), + media.kind == .gifv || media.kind == .video else { return nil } let previewImageURL = media.previewURL.flatMap { URL(string: $0) } - let videoKind: VideoPlayerViewModel.Kind = media.type == .gifv ? .gif : .video + let videoKind: VideoPlayerViewModel.Kind = media.kind == .gifv ? .gif : .video var _viewModel: VideoPlayerViewModel? workingQueue.sync { @@ -66,7 +65,7 @@ extension VideoPlaybackService { let viewModel = VideoPlayerViewModel( previewImageURL: previewImageURL, videoURL: url, - videoSize: CGSize(width: width, height: height), + videoSize: media.size, videoKind: videoKind ) viewPlayerViewModelDict[url] = viewModel @@ -101,9 +100,10 @@ extension VideoPlaybackService { 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 + // TODO: +// 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?) { diff --git a/Mastodon/State/AppContext.swift b/Mastodon/State/AppContext.swift index d7c08d47..0b7e37d4 100644 --- a/Mastodon/State/AppContext.swift +++ b/Mastodon/State/AppContext.swift @@ -27,7 +27,6 @@ class AppContext: ObservableObject { let emojiService: EmojiService let audioPlaybackService = AudioPlaybackService() let videoPlaybackService = VideoPlaybackService() - let statusPrefetchingService: StatusPrefetchingService let statusPublishService = StatusPublishService() let notificationService: NotificationService let settingService: SettingService @@ -71,11 +70,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/SceneDelegate.swift b/Mastodon/Supporting Files/SceneDelegate.swift index 4809fe5f..1ed70276 100644 --- a/Mastodon/Supporting Files/SceneDelegate.swift +++ b/Mastodon/Supporting Files/SceneDelegate.swift @@ -72,7 +72,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { .store(in: &observations) #if DEBUG - fpsIndicator = FPSIndicator(windowScene: windowScene) + // fpsIndicator = FPSIndicator(windowScene: windowScene) #endif } @@ -131,12 +131,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 00000000..421abab8 --- /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 00000000..47eb4ce1 --- /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 00000000..585eb007 --- /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 00000000..b57f2603 --- /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 00000000..68516a76 --- /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 00000000..6110535c --- /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/MastodonIntent/Info.plist b/MastodonIntent/Info.plist index 78d3b58e..47b22628 100644 --- a/MastodonIntent/Info.plist +++ b/MastodonIntent/Info.plist @@ -19,7 +19,7 @@ CFBundleShortVersionString 1.3.0 CFBundleVersion - 90 + 91 NSExtension NSExtensionAttributes diff --git a/MastodonIntent/SendPostIntentHandler.swift b/MastodonIntent/SendPostIntentHandler.swift index 75e7049a..1ad84308 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 00000000..b85bec4c --- /dev/null +++ b/MastodonIntent/eu-ES.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/eu-ES.lproj/Intents.stringsdict b/MastodonIntent/eu-ES.lproj/Intents.stringsdict new file mode 100644 index 00000000..5a39d5e6 --- /dev/null +++ b/MastodonIntent/eu-ES.lproj/Intents.stringsdict @@ -0,0 +1,54 @@ + + + + + There are ${count} options matching ‘${content}’. - 2 + + NSStringLocalizedFormatKey + There are %#@count_option@ matching ‘${content}’. + count_option + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + %ld + zero + 0 options + one + 1 option + two + 2 options + few + %ld options + many + %ld options + other + %ld options + + + There are ${count} options matching ‘${visibility}’. + + NSStringLocalizedFormatKey + There are %#@count_option@ matching ‘${visibility}’. + count_option + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + %ld + zero + 0 options + one + 1 option + two + 2 options + few + %ld options + many + %ld options + other + %ld options + + + + diff --git a/MastodonIntent/sv-FI.lproj/Intents.strings b/MastodonIntent/sv-FI.lproj/Intents.strings new file mode 100644 index 00000000..b85bec4c --- /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 00000000..5a39d5e6 --- /dev/null +++ b/MastodonIntent/sv-FI.lproj/Intents.stringsdict @@ -0,0 +1,54 @@ + + + + + There are ${count} options matching ‘${content}’. - 2 + + NSStringLocalizedFormatKey + There are %#@count_option@ matching ‘${content}’. + count_option + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + %ld + zero + 0 options + one + 1 option + two + 2 options + few + %ld options + many + %ld options + other + %ld options + + + There are ${count} options matching ‘${visibility}’. + + NSStringLocalizedFormatKey + There are %#@count_option@ matching ‘${visibility}’. + count_option + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + %ld + zero + 0 options + one + 1 option + two + 2 options + few + %ld options + many + %ld options + other + %ld options + + + + diff --git a/MastodonIntent/sv_FI.lproj/Intents.strings b/MastodonIntent/sv_FI.lproj/Intents.strings new file mode 100644 index 00000000..d4531ed6 --- /dev/null +++ b/MastodonIntent/sv_FI.lproj/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" = "Posta ${content} på 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/MastodonSDK/Package.swift b/MastodonSDK/Package.swift index ef5f9313..fae124f1 100644 --- a/MastodonSDK/Package.swift +++ b/MastodonSDK/Package.swift @@ -5,24 +5,29 @@ import PackageDescription let package = Package( name: "MastodonSDK", + defaultLocalization: "en", platforms: [ .iOS(.v14), ], products: [ .library( name: "MastodonSDK", - targets: ["MastodonSDK"]), - .library( - name: "MastodonUI", - targets: ["MastodonUI"]), - .library( - name: "MastodonExtension", - targets: ["MastodonExtension"]), + targets: [ + "MastodonSDK", + "MastodonExtension", + "MastodonAsset", + "MastodonLocalization", + "MastodonUI", + ]), ], 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.1.2")), + .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") @@ -37,21 +42,35 @@ let package = Package( .product(name: "NIOHTTP1", package: "swift-nio"), ] ), + .target( + name: "MastodonExtension", + dependencies: [] + ), + .target( + name: "MastodonAsset", + dependencies: [] + ), + .target( + name: "MastodonLocalization", + dependencies: [] + ), .target( name: "MastodonUI", dependencies: [ "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/Mastodon/Resources/Assets.xcassets/Asset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Asset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Asset/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/Circles/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Circles/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Circles/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/Border/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Border/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Colors/Border/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 a0ce2efb..f28745f0 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 486f8649..14df8ad4 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/Button/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Button/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Colors/Button/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Button/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Button/action.toolbar.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/Button/action.toolbar.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Button/action.toolbar.colorset/Contents.json index 8b7864eb..579de1da 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Button/action.toolbar.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" : "67", - "green" : "60", - "red" : "60" + "blue" : "0.263", + "green" : "0.235", + "red" : "0.235" } }, "idiom" : "universal" @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "0.600", - "blue" : "245", - "green" : "235", - "red" : "235" + "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/Mastodon/Resources/Assets.xcassets/Colors/Button/inactive.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Button/inactive.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/Colors/Button/inactive.colorset/Contents.json index 717d7892..9fbab220 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Button/inactive.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Button/inactive.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "140", - "green" : "130", - "red" : "110" + "blue" : "0.549", + "green" : "0.510", + "red" : "0.431" } }, "idiom" : "universal" @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "100", - "green" : "93", - "red" : "79" + "blue" : "0.392", + "green" : "0.365", + "red" : "0.310" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Colors/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Icon/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Icon/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Colors/Icon/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Icon/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Colors/badge.background.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/badge.background.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Icon/plus.colorset/Contents.json index f58a604a..13aaacf1 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/badge.background.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" : "48", - "green" : "59", - "red" : "255" + "blue" : "0.349", + "green" : "0.780", + "red" : "0.098" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Label/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Label/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Colors/Label/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Label/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Label/primary.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Label/primary.colorset/Contents.json similarity index 88% rename from Mastodon/Resources/Assets.xcassets/Colors/Label/primary.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Label/primary.colorset/Contents.json index ee70bcc1..a36ab82c 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Label/primary.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Label/primary.colorset/Contents.json @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0xEE", - "green" : "0xEE", - "red" : "0xEE" + "blue" : "0.933", + "green" : "0.933", + "red" : "0.933" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Label/primary.reverse.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Label/primary.reverse.colorset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Colors/Label/primary.reverse.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Label/primary.reverse.colorset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Label/secondary.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Label/secondary.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/Label/secondary.colorset/Contents.json index 104dfd02..9d73ead0 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Label/secondary.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Label/secondary.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" : "1.000", - "blue" : "0xAD", - "green" : "0x9D", - "red" : "0x97" + "blue" : "0.678", + "green" : "0.616", + "red" : "0.592" } }, "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 d4f558bf..fe0e4dbc 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/Notification/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Notification/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Colors/Notification/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Notification/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Notification/favourite.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/Notification/favourite.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Notification/favourite.colorset/Contents.json index 36de2027..f287ce10 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Notification/favourite.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" : "0", - "green" : "204", - "red" : "255" + "blue" : "0.000", + "green" : "0.800", + "red" : "1.000" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Notification/reblog.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Notification/mention.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/Colors/Notification/mention.colorset/Contents.json index ec427cca..c2416c58 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Notification/reblog.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Notification/mention.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "89", - "green" : "199", - "red" : "52" + "blue" : "0.871", + "green" : "0.322", + "red" : "0.686" } }, "idiom" : "universal" @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "75", - "green" : "215", - "red" : "20" + "blue" : "0.949", + "green" : "0.353", + "red" : "0.749" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Notification/mention.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Notification/reblog.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/Colors/Notification/reblog.colorset/Contents.json index 9dff2f59..ac763a85 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Notification/mention.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Notification/reblog.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "222", - "green" : "82", - "red" : "175" + "blue" : "0.349", + "green" : "0.780", + "red" : "0.204" } }, "idiom" : "universal" @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "242", - "green" : "90", - "red" : "191" + "blue" : "0.294", + "green" : "0.843", + "red" : "0.078" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Poll/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Poll/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Colors/Poll/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/Shadow/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Shadow/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Colors/Shadow/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 a28cf079..c0dd4f8d 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/Slider/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Slider/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Colors/Slider/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 ccbeb864..ac8203ae 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/Colors/TextField/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/TextField/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Colors/TextField/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 cde0cdf0..c34bae04 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/Mastodon/Resources/Assets.xcassets/Colors/TextField/valid.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/TextField/valid.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/TextField/valid.colorset/Contents.json index 7ccf54a1..861cb3a0 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/TextField/valid.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/TextField/valid.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "89", - "green" : "199", - "red" : "52" + "blue" : "0.349", + "green" : "0.780", + "red" : "0.204" } }, "idiom" : "universal" 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/Mastodon/Resources/Assets.xcassets/Colors/Icon/plus.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/badge.background.colorset/Contents.json similarity index 74% rename from Mastodon/Resources/Assets.xcassets/Colors/Icon/plus.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/badge.background.colorset/Contents.json index f783ce00..69346039 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Icon/plus.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/badge.background.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "89", - "green" : "199", - "red" : "25" + "blue" : "0.188", + "green" : "0.231", + "red" : "1.000" } }, "idiom" : "universal" 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 37df8107..fdd0acdb 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 00000000..e973fbf3 --- /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 00000000..97aaed2b --- /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 00000000..dabccc33 --- /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 00000000..f2e6f489 --- /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 00000000..9fbab220 --- /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 00000000..70b34209 --- /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/Connectivity/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Connectivity/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Connectivity/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/Human/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Human/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Human/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Human/Contents.json 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/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Onboarding/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Onboarding/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Onboarding/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Onboarding/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Onboarding/avatar.placeholder.imageset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Onboarding/avatar.placeholder.imageset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Onboarding/avatar.placeholder.imageset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Onboarding/avatar.placeholder.imageset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Onboarding/avatar.placeholder.imageset/Frame 82.jpg b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Onboarding/avatar.placeholder.imageset/Frame 82.jpg similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Onboarding/avatar.placeholder.imageset/Frame 82.jpg rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Onboarding/avatar.placeholder.imageset/Frame 82.jpg diff --git a/Mastodon/Resources/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 similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Onboarding/avatar.placeholder.imageset/Frame 82@2x.png rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Onboarding/avatar.placeholder.imageset/Frame 82@2x.png diff --git a/Mastodon/Resources/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 similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Onboarding/avatar.placeholder.imageset/Frame 82@3x.png rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Onboarding/avatar.placeholder.imageset/Frame 82@3x.png 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 00000000..fb6807b0 --- /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/Mastodon/Resources/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 similarity index 76% rename from Mastodon/Resources/Assets.xcassets/Scene/Onboarding/navigation.back.button.background.highlighted.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Onboarding/navigation.back.button.background.highlighted.colorset/Contents.json index 7136040b..b7b5a14d 100644 --- a/Mastodon/Resources/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 @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0xE5", - "green" : "0xE5", - "red" : "0xE5" + "blue" : "0.898", + "green" : "0.898", + "red" : "0.898" } }, "idiom" : "universal" @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "0.400", - "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/Scene/Onboarding/navigation.next.button.background.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Onboarding/navigation.next.button.background.colorset/Contents.json new file mode 100644 index 00000000..a36ab82c --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Onboarding/navigation.next.button.background.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/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 00000000..2dfe8b1c --- /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/onboarding.background.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Onboarding/onboarding.background.colorset/Contents.json new file mode 100644 index 00000000..4d55227b --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Onboarding/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/Mastodon/Resources/Assets.xcassets/Scene/Onboarding/search.bar.background.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Onboarding/search.bar.background.colorset/Contents.json similarity index 76% rename from Mastodon/Resources/Assets.xcassets/Scene/Onboarding/search.bar.background.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Onboarding/search.bar.background.colorset/Contents.json index f16bb02f..6cfd2655 100644 --- a/Mastodon/Resources/Assets.xcassets/Scene/Onboarding/search.bar.background.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Onboarding/search.bar.background.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" @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "0.240", - "blue" : "0x80", - "green" : "0x76", - "red" : "0x76" + "blue" : "0.502", + "green" : "0.463", + "red" : "0.463" } }, "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/Scene/Onboarding/textField.background.colorset/Contents.json similarity index 88% rename from Mastodon/Resources/Assets.xcassets/Theme/system/Background/profile.field.collection.view.background.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Onboarding/textField.background.colorset/Contents.json index 82edd034..33b71ef9 100644 --- a/Mastodon/Resources/Assets.xcassets/Theme/system/Background/profile.field.collection.view.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" : "30", - "green" : "28", - "red" : "28" + "blue" : "0.216", + "green" : "0.173", + "red" : "0.157" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Profile/Banner/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/Banner/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Profile/Banner/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 aa5323a2..64f15834 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 b4ce9fd5..d1c47604 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/Scene/Profile/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Profile/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Sidebar/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Sidebar/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Sidebar/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Sidebar/Contents.json 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/Mastodon/Resources/Assets.xcassets/Scene/Welcome/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Welcome/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/Contents.json 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 00000000..de0f60b6 --- /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/Mastodon/Resources/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 similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/cloud.base.extend.imageset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/cloud.base.extend.imageset/Contents.json diff --git a/Mastodon/Resources/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 similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/cloud.base.extend.imageset/cloud.base.extend.png rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/cloud.base.extend.imageset/cloud.base.extend.png diff --git a/Mastodon/Resources/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 similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/cloud.base.extend.imageset/cloud.base.extend@2x.png rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/cloud.base.extend.imageset/cloud.base.extend@2x.png diff --git a/Mastodon/Resources/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 similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/cloud.base.extend.imageset/cloud.base.extend@3x.png rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/cloud.base.extend.imageset/cloud.base.extend@3x.png 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/Mastodon/Resources/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 similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.extend.imageset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.extend.imageset/Contents.json diff --git a/Mastodon/Resources/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 similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.extend.imageset/elephant.three.on.grass.extend.png rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.extend.imageset/elephant.three.on.grass.extend.png diff --git a/Mastodon/Resources/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 similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.extend.imageset/elephant.three.on.grass.extend@2x.png rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.extend.imageset/elephant.three.on.grass.extend@2x.png diff --git a/Mastodon/Resources/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 similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.extend.imageset/elephant.three.on.grass.extend@3x.png rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.extend.imageset/elephant.three.on.grass.extend@3x.png 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 00000000..4872f318 --- /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/Mastodon/Resources/Assets.xcassets/Settings/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Settings/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Settings/black.auto.imageset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/black.auto.imageset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Settings/black.auto.imageset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/black.auto.imageset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Settings/black.auto.imageset/Mixed_Black_Light.png b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/black.auto.imageset/Mixed_Black_Light.png similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Settings/black.auto.imageset/Mixed_Black_Light.png rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/black.auto.imageset/Mixed_Black_Light.png diff --git a/Mastodon/Resources/Assets.xcassets/Settings/black.imageset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/black.imageset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Settings/black.imageset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/black.imageset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Settings/black.imageset/Home Black.png b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/black.imageset/Home Black.png similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Settings/black.imageset/Home Black.png rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/black.imageset/Home Black.png diff --git a/Mastodon/Resources/Assets.xcassets/Settings/dark.auto.imageset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/dark.auto.imageset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Settings/dark.auto.imageset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/dark.auto.imageset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Settings/dark.auto.imageset/Mixed_Dark_Light.png b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/dark.auto.imageset/Mixed_Dark_Light.png similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Settings/dark.auto.imageset/Mixed_Dark_Light.png rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/dark.auto.imageset/Mixed_Dark_Light.png diff --git a/Mastodon/Resources/Assets.xcassets/Settings/dark.imageset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/dark.imageset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Settings/dark.imageset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/dark.imageset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Settings/dark.imageset/Home Dark.png b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/dark.imageset/Home Dark.png similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Settings/dark.imageset/Home Dark.png rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/dark.imageset/Home Dark.png diff --git a/Mastodon/Resources/Assets.xcassets/Settings/light.imageset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/light.imageset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Settings/light.imageset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/light.imageset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Settings/light.imageset/Home Light.png b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/light.imageset/Home Light.png similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Settings/light.imageset/Home Light.png rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Settings/light.imageset/Home Light.png diff --git a/Mastodon/Resources/Assets.xcassets/Theme/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Theme/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Contents.json 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 54427c61..7d751f89 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 100% 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 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 c2407407..e30d6cab 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" : "0.945", + "green" : "0.945", + "red" : "0.945" } }, "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/Scene/Onboarding/textField.background.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/Background/system.elevated.background.colorset/Contents.json similarity index 88% rename from Mastodon/Resources/Assets.xcassets/Scene/Onboarding/textField.background.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/Background/system.elevated.background.colorset/Contents.json index 147cca83..33b71ef9 100644 --- a/Mastodon/Resources/Assets.xcassets/Scene/Onboarding/textField.background.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/Background/system.elevated.background.colorset/Contents.json @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0x37", - "green" : "0x2C", - "red" : "0x28" + "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 100% 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 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 ec7c19fa..e3ffa5a6 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/table.view.cell.selection.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/table.view.cell.selection.background.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/Background/table.view.cell.selection.background.colorset/Contents.json index d211d7df..7d751f89 100644 --- a/Mastodon/Resources/Assets.xcassets/Theme/system/Background/table.view.cell.selection.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 100% 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 diff --git a/Mastodon/Resources/Assets.xcassets/Theme/Mastodon/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Theme/Mastodon/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/Mastodon/Contents.json 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 00000000..baf4b4b4 --- /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 00000000..73c00596 --- /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 00000000..0ca6215a --- /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.grouped.system.background.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/secondary.grouped.system.background.colorset/Contents.json similarity index 88% rename from Mastodon/Resources/Assets.xcassets/Theme/system/Background/secondary.grouped.system.background.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/secondary.grouped.system.background.colorset/Contents.json index 03606670..b054549a 100644 --- a/Mastodon/Resources/Assets.xcassets/Theme/system/Background/secondary.grouped.system.background.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/secondary.grouped.system.background.colorset/Contents.json @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "46", - "green" : "44", - "red" : "44" + "blue" : "0.180", + "green" : "0.173", + "red" : "0.173" } }, "idiom" : "universal" diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/secondary.system.background.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/secondary.system.background.colorset/Contents.json new file mode 100644 index 00000000..facc139f --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/secondary.system.background.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.910", + "green" : "0.878", + "red" : "0.851" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.180", + "green" : "0.173", + "red" : "0.173" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Theme/system/Background/sidebar.background.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/sidebar.background.colorset/Contents.json similarity index 88% rename from Mastodon/Resources/Assets.xcassets/Theme/system/Background/sidebar.background.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/sidebar.background.colorset/Contents.json index ee5b1c37..03bc91c3 100644 --- a/Mastodon/Resources/Assets.xcassets/Theme/system/Background/sidebar.background.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/sidebar.background.colorset/Contents.json @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0x2E", - "green" : "0x2C", - "red" : "0x2C" + "blue" : "0.180", + "green" : "0.173", + "red" : "0.173" } }, "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/system.background.colorset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Theme/system/Background/system.background.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/system.background.colorset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Theme/system/Background/table.view.cell.background.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/system.elevated.background.colorset/Contents.json similarity index 88% rename from Mastodon/Resources/Assets.xcassets/Theme/system/Background/table.view.cell.background.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/system.elevated.background.colorset/Contents.json index 6b983510..ca11ee75 100644 --- a/Mastodon/Resources/Assets.xcassets/Theme/system/Background/table.view.cell.background.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/system.elevated.background.colorset/Contents.json @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0", - "green" : "0", - "red" : "0" + "blue" : "0.118", + "green" : "0.110", + "red" : "0.110" } }, "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 daac70e0..bcd0e01f 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" : "0xE8", - "green" : "0xE0", - "red" : "0xD9" + "blue" : "0.910", + "green" : "0.878", + "red" : "0.851" } }, "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 7f9578a7..8ef5fd6d 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/Mastodon/Background/system.elevated.background.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/table.view.cell.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/Theme/system/Background/table.view.cell.background.colorset/Contents.json index dd6cbfd9..04256378 100644 --- a/Mastodon/Resources/Assets.xcassets/Theme/Mastodon/Background/system.elevated.background.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/table.view.cell.background.colorset/Contents.json @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "55", - "green" : "44", - "red" : "40" + "blue" : "0.000", + "green" : "0.000", + "red" : "0.000" } }, "idiom" : "universal" diff --git a/MastodonSDK/Sources/MastodonAsset/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 new file mode 100644 index 00000000..640af3a2 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/table.view.cell.selection.background.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.922", + "green" : "0.898", + "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/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 e7d7e3cd..c752c3a5 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/Theme/system/Background/tertiary.system.grouped.background.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/tertiary.system.grouped.background.colorset/Contents.json similarity index 88% rename from Mastodon/Resources/Assets.xcassets/Theme/system/Background/tertiary.system.grouped.background.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/tertiary.system.grouped.background.colorset/Contents.json index ab65a98e..6b9fb70a 100644 --- a/Mastodon/Resources/Assets.xcassets/Theme/system/Background/tertiary.system.grouped.background.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Background/tertiary.system.grouped.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/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Theme/system/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Theme/system/Contents.json 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 04fbae35..ec5491c9 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 00000000..baf4b4b4 --- /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/Generated/Assets.swift b/MastodonSDK/Sources/MastodonAsset/Generated/Assets.swift new file mode 100644 index 00000000..9524153a --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Generated/Assets.swift @@ -0,0 +1,259 @@ +// 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 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 Connectivity { + public static let photoFillSplit = ImageAsset(name: "Connectivity/photo.fill.split") + } + public enum Human { + public static let faceSmilingAdaptive = ImageAsset(name: "Human/face.smiling.adaptive") + } + public enum Scene { + public enum Onboarding { + public static let avatarPlaceholder = ImageAsset(name: "Scene/Onboarding/avatar.placeholder") + 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 onboardingBackground = ColorAsset(name: "Scene/Onboarding/onboarding.background") + 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 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 blackAuto = ImageAsset(name: "Settings/black.auto") + public static let black = ImageAsset(name: "Settings/black") + public static let darkAuto = ImageAsset(name: "Settings/dark.auto") + 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/MastodonAsset+Bundle.swift b/MastodonSDK/Sources/MastodonAsset/MastodonAsset+Bundle.swift new file mode 100644 index 00000000..45d5b337 --- /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/MastodonSDK/Sources/MastodonExtension/Collection.swift b/MastodonSDK/Sources/MastodonExtension/Collection.swift new file mode 100644 index 00000000..8892583d --- /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( + 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 00000000..6bbf19f5 --- /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( + _ transform: @escaping (Output) async -> T + ) -> Publishers.FlatMap, Self> { + flatMap { value in + Future { promise in + Task { + let output = await transform(value) + promise(.success(output)) + } + } + } + } + + public func asyncMap( + _ transform: @escaping (Output) async throws -> T + ) -> Publishers.FlatMap, 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( + _ transform: @escaping (Output) async throws -> T + ) -> Publishers.FlatMap, + Publishers.SetFailureType> { + 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 31043157..6e939f3c 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/UIView.swift b/MastodonSDK/Sources/MastodonExtension/UIView.swift new file mode 100644 index 00000000..5466c464 --- /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 00000000..805a7e52 --- /dev/null +++ b/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift @@ -0,0 +1,1155 @@ +// 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 { + /// Delete + public static let delete = L10n.tr("Localizable", "Common.Alerts.DeletePost.Delete") + /// Are you sure you want to delete this 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 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") + /// 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 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 { + /// We just sent an email to %@,\ntap the link to confirm your account. + public static func subtitle(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.ConfirmEmail.Subtitle", String(describing: p1)) + } + /// One last thing. + public static let title = L10n.tr("Localizable", "Scene.ConfirmEmail.Title") + public enum Button { + /// I never got an email + public static let dontReceiveEmail = L10n.tr("Localizable", "Scene.ConfirmEmail.Button.DontReceiveEmail") + /// Open Email App + public static let openEmailApp = L10n.tr("Localizable", "Scene.ConfirmEmail.Button.OpenEmailApp") + } + 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 { + /// %@ favorited your post + public static func userFavoritedYourPost(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Notification.UserFavorited Your Post", String(describing: p1)) + } + /// %@ followed you + public static func userFollowedYou(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Notification.UserFollowedYou", String(describing: p1)) + } + /// %@ mentioned you + public static func userMentionedYou(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Notification.UserMentionedYou", String(describing: p1)) + } + /// %@ reblogged your post + public static func userRebloggedYourPost(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Notification.UserRebloggedYourPost", String(describing: p1)) + } + /// %@ requested to follow you + public static func userRequestedToFollowYou(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Notification.UserRequestedToFollowYou", String(describing: p1)) + } + /// %@ Your poll has ended + public static func userYourPollHasEnded(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Notification.UserYourPollHasEnded", String(describing: p1)) + } + 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 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 ConfirmUnblockUsre { + /// Confirm to unblock %@ + public static func message(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Message", String(describing: p1)) + } + /// Unblock Account + public static let title = L10n.tr("Localizable", "Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.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 { + /// Media + public static let media = L10n.tr("Localizable", "Scene.Profile.SegmentedControl.Media") + /// Posts + public static let posts = L10n.tr("Localizable", "Scene.Profile.SegmentedControl.Posts") + /// Replies + public static let replies = L10n.tr("Localizable", "Scene.Profile.SegmentedControl.Replies") + } + } + public enum Register { + /// Tell us about you. + public static let title = L10n.tr("Localizable", "Scene.Register.Title") + 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 { + /// 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") + } + 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") + /// 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)) + } + } + 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 server,\nany server. + 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 { + /// Find a server or join your own... + 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 rules are set by the admins of %@. + 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 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 { + /// 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/Mastodon/Resources/ar.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/ar.lproj/Localizable.strings similarity index 100% rename from Mastodon/Resources/ar.lproj/Localizable.strings rename to MastodonSDK/Sources/MastodonLocalization/Resources/ar.lproj/Localizable.strings 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 100% rename from Mastodon/Resources/ca.lproj/Localizable.strings rename to MastodonSDK/Sources/MastodonLocalization/Resources/ca.lproj/Localizable.strings 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 100% rename from Mastodon/Resources/de.lproj/Localizable.strings rename to MastodonSDK/Sources/MastodonLocalization/Resources/de.lproj/Localizable.strings 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 100% rename from Mastodon/Resources/en.lproj/Localizable.strings rename to MastodonSDK/Sources/MastodonLocalization/Resources/en.lproj/Localizable.strings diff --git a/Mastodon/Resources/en.lproj/Localizable.stringsdict b/MastodonSDK/Sources/MastodonLocalization/Resources/en.lproj/Localizable.stringsdict similarity index 100% rename from Mastodon/Resources/en.lproj/Localizable.stringsdict rename to MastodonSDK/Sources/MastodonLocalization/Resources/en.lproj/Localizable.stringsdict diff --git a/Mastodon/Resources/es-419.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/es-419.lproj/Localizable.strings similarity index 100% rename from Mastodon/Resources/es-419.lproj/Localizable.strings rename to MastodonSDK/Sources/MastodonLocalization/Resources/es-419.lproj/Localizable.strings 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 100% rename from Mastodon/Resources/es.lproj/Localizable.strings rename to MastodonSDK/Sources/MastodonLocalization/Resources/es.lproj/Localizable.strings diff --git a/Mastodon/Resources/es.lproj/Localizable.stringsdict b/MastodonSDK/Sources/MastodonLocalization/Resources/es.lproj/Localizable.stringsdict similarity index 100% rename from Mastodon/Resources/es.lproj/Localizable.stringsdict rename to MastodonSDK/Sources/MastodonLocalization/Resources/es.lproj/Localizable.stringsdict diff --git a/Mastodon/Resources/fr.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/fr.lproj/Localizable.strings similarity index 100% rename from Mastodon/Resources/fr.lproj/Localizable.strings rename to MastodonSDK/Sources/MastodonLocalization/Resources/fr.lproj/Localizable.strings diff --git a/Mastodon/Resources/fr.lproj/Localizable.stringsdict b/MastodonSDK/Sources/MastodonLocalization/Resources/fr.lproj/Localizable.stringsdict similarity index 100% rename from Mastodon/Resources/fr.lproj/Localizable.stringsdict rename to MastodonSDK/Sources/MastodonLocalization/Resources/fr.lproj/Localizable.stringsdict diff --git a/Mastodon/Resources/gd-GB.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/gd-GB.lproj/Localizable.strings similarity index 100% rename from Mastodon/Resources/gd-GB.lproj/Localizable.strings rename to MastodonSDK/Sources/MastodonLocalization/Resources/gd-GB.lproj/Localizable.strings 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 100% rename from Mastodon/Resources/ja.lproj/Localizable.strings rename to MastodonSDK/Sources/MastodonLocalization/Resources/ja.lproj/Localizable.strings diff --git a/Mastodon/Resources/ja.lproj/Localizable.stringsdict b/MastodonSDK/Sources/MastodonLocalization/Resources/ja.lproj/Localizable.stringsdict similarity index 100% rename from Mastodon/Resources/ja.lproj/Localizable.stringsdict rename to MastodonSDK/Sources/MastodonLocalization/Resources/ja.lproj/Localizable.stringsdict diff --git a/Mastodon/Resources/ku-TR.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/ku-TR.lproj/Localizable.strings similarity index 100% rename from Mastodon/Resources/ku-TR.lproj/Localizable.strings rename to MastodonSDK/Sources/MastodonLocalization/Resources/ku-TR.lproj/Localizable.strings 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/Mastodon/Resources/nl.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/nl.lproj/Localizable.strings similarity index 100% rename from Mastodon/Resources/nl.lproj/Localizable.strings rename to MastodonSDK/Sources/MastodonLocalization/Resources/nl.lproj/Localizable.strings 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 100% rename from Mastodon/Resources/ru.lproj/Localizable.strings rename to MastodonSDK/Sources/MastodonLocalization/Resources/ru.lproj/Localizable.strings 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 00000000..bf57c040 --- /dev/null +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/sv_FI.lproj/Localizable.strings @@ -0,0 +1,349 @@ +"Common.Alerts.BlockDomain.BlockEntireDomain" = "Block Domain"; +"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" = "Successfully cleaned %@ cache."; +"Common.Alerts.CleanCache.Title" = "Clean Cache"; +"Common.Alerts.Common.PleaseTryAgain" = "Var god försök igen."; +"Common.Alerts.Common.PleaseTryAgainLater" = "Var god försök igen senare."; +"Common.Alerts.DeletePost.Delete" = "Radera"; +"Common.Alerts.DeletePost.Title" = "Are you sure you want to delete this 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."; +"Common.Alerts.EditProfileFailure.Title" = "Edit Profile Error"; +"Common.Alerts.PublishPostFailure.AttachmentsMessage.MoreThanOneVideo" = "Cannot attach more than one video."; +"Common.Alerts.PublishPostFailure.AttachmentsMessage.VideoAttachWithPhoto" = "Cannot attach a video to a post that already contains images."; +"Common.Alerts.PublishPostFailure.Message" = "Failed to publish the post. +Please check your internet connection."; +"Common.Alerts.PublishPostFailure.Title" = "Publish Failure"; +"Common.Alerts.SavePhotoFailure.Message" = "Please enable the photo library access permission to save the photo."; +"Common.Alerts.SavePhotoFailure.Title" = "Save Photo Failure"; +"Common.Alerts.ServerError.Title" = "Serverfel"; +"Common.Alerts.SignOut.Confirm" = "Sign Out"; +"Common.Alerts.SignOut.Message" = "Är du säker på att du vill logga ut?"; +"Common.Alerts.SignOut.Title" = "Sign Out"; +"Common.Alerts.SignUpFailure.Title" = "Sign Up Failure"; +"Common.Alerts.VoteFailure.PollEnded" = "Omröstningen har avslutats"; +"Common.Alerts.VoteFailure.Title" = "Vote Failure"; +"Common.Controls.Actions.Add" = "Add"; +"Common.Controls.Actions.Back" = "Back"; +"Common.Controls.Actions.BlockDomain" = "Block %@"; +"Common.Controls.Actions.Cancel" = "Avbryt"; +"Common.Controls.Actions.Compose" = "Compose"; +"Common.Controls.Actions.Confirm" = "Confirm"; +"Common.Controls.Actions.Continue" = "Fortsätt"; +"Common.Controls.Actions.CopyPhoto" = "Copy Photo"; +"Common.Controls.Actions.Delete" = "Radera"; +"Common.Controls.Actions.Discard" = "Discard"; +"Common.Controls.Actions.Done" = "Done"; +"Common.Controls.Actions.Edit" = "Redigera"; +"Common.Controls.Actions.FindPeople" = "Find people to follow"; +"Common.Controls.Actions.ManuallySearch" = "Manually search instead"; +"Common.Controls.Actions.Next" = "Next"; +"Common.Controls.Actions.Ok" = "OK"; +"Common.Controls.Actions.Open" = "Open"; +"Common.Controls.Actions.OpenInSafari" = "Öppna i Safari"; +"Common.Controls.Actions.Preview" = "Preview"; +"Common.Controls.Actions.Previous" = "Previous"; +"Common.Controls.Actions.Remove" = "Remove"; +"Common.Controls.Actions.Reply" = "Reply"; +"Common.Controls.Actions.ReportUser" = "Rapportera %@"; +"Common.Controls.Actions.Save" = "Spara"; +"Common.Controls.Actions.SavePhoto" = "Save Photo"; +"Common.Controls.Actions.SeeMore" = "See More"; +"Common.Controls.Actions.Settings" = "Inställningar"; +"Common.Controls.Actions.Share" = "Dela"; +"Common.Controls.Actions.SharePost" = "Share Post"; +"Common.Controls.Actions.ShareUser" = "Dela %@"; +"Common.Controls.Actions.SignIn" = "Sign In"; +"Common.Controls.Actions.SignUp" = "Sign Up"; +"Common.Controls.Actions.Skip" = "Skip"; +"Common.Controls.Actions.TakePhoto" = "Take Photo"; +"Common.Controls.Actions.TryAgain" = "Försök igen"; +"Common.Controls.Actions.UnblockDomain" = "Unblock %@"; +"Common.Controls.Friendship.Block" = "Block"; +"Common.Controls.Friendship.BlockDomain" = "Block %@"; +"Common.Controls.Friendship.BlockUser" = "Block %@"; +"Common.Controls.Friendship.Blocked" = "Blocked"; +"Common.Controls.Friendship.EditInfo" = "Edit Info"; +"Common.Controls.Friendship.Follow" = "Följ"; +"Common.Controls.Friendship.Following" = "Följer"; +"Common.Controls.Friendship.Mute" = "Mute"; +"Common.Controls.Friendship.MuteUser" = "Mute %@"; +"Common.Controls.Friendship.Muted" = "Muted"; +"Common.Controls.Friendship.Pending" = "Pending"; +"Common.Controls.Friendship.Request" = "Request"; +"Common.Controls.Friendship.Unblock" = "Unblock"; +"Common.Controls.Friendship.UnblockUser" = "Unblock %@"; +"Common.Controls.Friendship.Unmute" = "Unmute"; +"Common.Controls.Friendship.UnmuteUser" = "Unmute %@"; +"Common.Controls.Keyboard.Common.ComposeNewPost" = "Compose New Post"; +"Common.Controls.Keyboard.Common.OpenSettings" = "Open Settings"; +"Common.Controls.Keyboard.Common.ShowFavorites" = "Show Favorites"; +"Common.Controls.Keyboard.Common.SwitchToTab" = "Switch to %@"; +"Common.Controls.Keyboard.SegmentedControl.NextSection" = "Next Section"; +"Common.Controls.Keyboard.SegmentedControl.PreviousSection" = "Previous Section"; +"Common.Controls.Keyboard.Timeline.NextStatus" = "Next Post"; +"Common.Controls.Keyboard.Timeline.OpenAuthorProfile" = "Open Author's Profile"; +"Common.Controls.Keyboard.Timeline.OpenRebloggerProfile" = "Open Reblogger's Profile"; +"Common.Controls.Keyboard.Timeline.OpenStatus" = "Open Post"; +"Common.Controls.Keyboard.Timeline.PreviewImage" = "Preview Image"; +"Common.Controls.Keyboard.Timeline.PreviousStatus" = "Previous Post"; +"Common.Controls.Keyboard.Timeline.ReplyStatus" = "Reply to Post"; +"Common.Controls.Keyboard.Timeline.ToggleContentWarning" = "Toggle Content Warning"; +"Common.Controls.Keyboard.Timeline.ToggleFavorite" = "Toggle Favorite on Post"; +"Common.Controls.Keyboard.Timeline.ToggleReblog" = "Toggle Reblog on Post"; +"Common.Controls.Status.Actions.Favorite" = "Favorite"; +"Common.Controls.Status.Actions.Menu" = "Meny"; +"Common.Controls.Status.Actions.Reblog" = "Reblog"; +"Common.Controls.Status.Actions.Reply" = "Reply"; +"Common.Controls.Status.Actions.Unfavorite" = "Unfavorite"; +"Common.Controls.Status.Actions.Unreblog" = "Undo reblog"; +"Common.Controls.Status.ContentWarning" = "Content Warning"; +"Common.Controls.Status.MediaContentWarning" = "Tap anywhere to reveal"; +"Common.Controls.Status.Poll.Closed" = "Closed"; +"Common.Controls.Status.Poll.Vote" = "Vote"; +"Common.Controls.Status.ShowPost" = "Show Post"; +"Common.Controls.Status.ShowUserProfile" = "Show user profile"; +"Common.Controls.Status.Tag.Email" = "Email"; +"Common.Controls.Status.Tag.Emoji" = "Emoji"; +"Common.Controls.Status.Tag.Hashtag" = "Hashtag"; +"Common.Controls.Status.Tag.Link" = "Link"; +"Common.Controls.Status.Tag.Mention" = "Mention"; +"Common.Controls.Status.Tag.Url" = "URL"; +"Common.Controls.Status.UserReblogged" = "%@ reblogged"; +"Common.Controls.Status.UserRepliedTo" = "Replied to %@"; +"Common.Controls.Tabs.Home" = "Home"; +"Common.Controls.Tabs.Notification" = "Notification"; +"Common.Controls.Tabs.Profile" = "Profil"; +"Common.Controls.Tabs.Search" = "Search"; +"Common.Controls.Timeline.Filtered" = "Filtered"; +"Common.Controls.Timeline.Header.BlockedWarning" = "You can’t view this user’s profile +until they unblock you."; +"Common.Controls.Timeline.Header.BlockingWarning" = "You can’t view this user's profile +until you unblock them. +Your profile looks like this to them."; +"Common.Controls.Timeline.Header.NoStatusFound" = "No Post Found"; +"Common.Controls.Timeline.Header.SuspendedWarning" = "This user has been suspended."; +"Common.Controls.Timeline.Header.UserBlockedWarning" = "You can’t view %@’s profile +until they unblock you."; +"Common.Controls.Timeline.Header.UserBlockingWarning" = "You can’t view %@’s profile +until you unblock them. +Your profile looks like this to them."; +"Common.Controls.Timeline.Header.UserSuspendedWarning" = "%@’s account has been suspended."; +"Common.Controls.Timeline.Loader.LoadMissingPosts" = "Load missing posts"; +"Common.Controls.Timeline.Loader.LoadingMissingPosts" = "Loading missing posts..."; +"Common.Controls.Timeline.Loader.ShowMoreReplies" = "Visa fler svar"; +"Common.Controls.Timeline.Timestamp.Now" = "Now"; +"Scene.AccountList.AddAccount" = "Lägg till konto"; +"Scene.AccountList.DismissAccountSwitcher" = "Dismiss Account Switcher"; +"Scene.AccountList.TabBarHint" = "Current selected profile: %@. Double tap then hold to show account switcher"; +"Scene.Compose.Accessibility.AppendAttachment" = "Add Attachment"; +"Scene.Compose.Accessibility.AppendPoll" = "Add Poll"; +"Scene.Compose.Accessibility.CustomEmojiPicker" = "Custom Emoji Picker"; +"Scene.Compose.Accessibility.DisableContentWarning" = "Disable Content Warning"; +"Scene.Compose.Accessibility.EnableContentWarning" = "Enable Content Warning"; +"Scene.Compose.Accessibility.PostVisibilityMenu" = "Post Visibility Menu"; +"Scene.Compose.Accessibility.RemovePoll" = "Remove Poll"; +"Scene.Compose.Attachment.AttachmentBroken" = "This %@ is broken and can’t be +uploaded to Mastodon."; +"Scene.Compose.Attachment.DescriptionPhoto" = "Describe the photo for the visually-impaired..."; +"Scene.Compose.Attachment.DescriptionVideo" = "Describe the video for the visually-impaired..."; +"Scene.Compose.Attachment.Photo" = "photo"; +"Scene.Compose.Attachment.Video" = "video"; +"Scene.Compose.AutoComplete.SpaceToAdd" = "Space to add"; +"Scene.Compose.ComposeAction" = "Publicera"; +"Scene.Compose.ContentInputPlaceholder" = "Type or paste what’s on your mind"; +"Scene.Compose.ContentWarning.Placeholder" = "Write an accurate warning here..."; +"Scene.Compose.Keyboard.AppendAttachmentEntry" = "Add Attachment - %@"; +"Scene.Compose.Keyboard.DiscardPost" = "Discard Post"; +"Scene.Compose.Keyboard.PublishPost" = "Publish Post"; +"Scene.Compose.Keyboard.SelectVisibilityEntry" = "Select Visibility - %@"; +"Scene.Compose.Keyboard.ToggleContentWarning" = "Toggle Content Warning"; +"Scene.Compose.Keyboard.TogglePoll" = "Toggle Poll"; +"Scene.Compose.MediaSelection.Browse" = "Bläddra"; +"Scene.Compose.MediaSelection.Camera" = "Take Photo"; +"Scene.Compose.MediaSelection.PhotoLibrary" = "Photo Library"; +"Scene.Compose.Poll.DurationTime" = "Varaktighet: %@"; +"Scene.Compose.Poll.OneDay" = "1 Day"; +"Scene.Compose.Poll.OneHour" = "1 Hour"; +"Scene.Compose.Poll.OptionNumber" = "Option %ld"; +"Scene.Compose.Poll.SevenDays" = "7 Days"; +"Scene.Compose.Poll.SixHours" = "6 Hours"; +"Scene.Compose.Poll.ThirtyMinutes" = "30 minuter"; +"Scene.Compose.Poll.ThreeDays" = "3 Days"; +"Scene.Compose.ReplyingToUser" = "replying to %@"; +"Scene.Compose.Title.NewPost" = "New Post"; +"Scene.Compose.Title.NewReply" = "New Reply"; +"Scene.Compose.Visibility.Direct" = "Only people I mention"; +"Scene.Compose.Visibility.Private" = "Followers only"; +"Scene.Compose.Visibility.Public" = "Public"; +"Scene.Compose.Visibility.Unlisted" = "Unlisted"; +"Scene.ConfirmEmail.Button.DontReceiveEmail" = "I never got an email"; +"Scene.ConfirmEmail.Button.OpenEmailApp" = "Open Email App"; +"Scene.ConfirmEmail.DontReceiveEmail.Description" = "Check if your email address is correct as well as your junk folder if you haven’t."; +"Scene.ConfirmEmail.DontReceiveEmail.ResendEmail" = "Resend Email"; +"Scene.ConfirmEmail.DontReceiveEmail.Title" = "Check your email"; +"Scene.ConfirmEmail.OpenEmailApp.Description" = "We just sent you an email. Check your junk folder if you haven’t."; +"Scene.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.Title" = "One last thing."; +"Scene.Favorite.Title" = "Your Favorites"; +"Scene.Follower.Footer" = "Followers from other servers are not displayed."; +"Scene.Following.Footer" = "Follows from other servers are not displayed."; +"Scene.HomeTimeline.NavigationBarState.NewPosts" = "See new posts"; +"Scene.HomeTimeline.NavigationBarState.Offline" = "Offline"; +"Scene.HomeTimeline.NavigationBarState.Published" = "Published!"; +"Scene.HomeTimeline.NavigationBarState.Publishing" = "Publishing post..."; +"Scene.HomeTimeline.Title" = "Home"; +"Scene.Notification.Keyobard.ShowEverything" = "Show Everything"; +"Scene.Notification.Keyobard.ShowMentions" = "Show Mentions"; +"Scene.Notification.Title.Everything" = "Everything"; +"Scene.Notification.Title.Mentions" = "Mentions"; +"Scene.Notification.UserFavorited Your Post" = "%@ favorited your post"; +"Scene.Notification.UserFollowedYou" = "%@ följde dig"; +"Scene.Notification.UserMentionedYou" = "%@ nämnde dig"; +"Scene.Notification.UserRebloggedYourPost" = "%@ reblogged your post"; +"Scene.Notification.UserRequestedToFollowYou" = "%@ har begärt att följa dig"; +"Scene.Notification.UserYourPollHasEnded" = "%@ Omröstningen har avslutats"; +"Scene.Preview.Keyboard.ClosePreview" = "Close Preview"; +"Scene.Preview.Keyboard.ShowNext" = "Show Next"; +"Scene.Preview.Keyboard.ShowPrevious" = "Show Previous"; +"Scene.Profile.Dashboard.Followers" = "followers"; +"Scene.Profile.Dashboard.Following" = "following"; +"Scene.Profile.Dashboard.Posts" = "posts"; +"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.ConfirmUnmuteUser.Message" = "Confirm to unmute %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Title" = "Unmute Account"; +"Scene.Profile.SegmentedControl.Media" = "Media"; +"Scene.Profile.SegmentedControl.Posts" = "Posts"; +"Scene.Profile.SegmentedControl.Replies" = "Replies"; +"Scene.Register.Error.Item.Agreement" = "Agreement"; +"Scene.Register.Error.Item.Email" = "Email"; +"Scene.Register.Error.Item.Locale" = "Locale"; +"Scene.Register.Error.Item.Password" = "Password"; +"Scene.Register.Error.Item.Reason" = "Reason"; +"Scene.Register.Error.Item.Username" = "Användarnamn"; +"Scene.Register.Error.Reason.Accepted" = "%@ must be accepted"; +"Scene.Register.Error.Reason.Blank" = "%@ is required"; +"Scene.Register.Error.Reason.Blocked" = "%@ contains a disallowed email provider"; +"Scene.Register.Error.Reason.Inclusion" = "%@ is not a supported value"; +"Scene.Register.Error.Reason.Invalid" = "%@ is invalid"; +"Scene.Register.Error.Reason.Reserved" = "%@ is a reserved keyword"; +"Scene.Register.Error.Reason.Taken" = "%@ is already in use"; +"Scene.Register.Error.Reason.TooLong" = "%@ is too long"; +"Scene.Register.Error.Reason.TooShort" = "%@ is too short"; +"Scene.Register.Error.Reason.Unreachable" = "%@ does not seem to exist"; +"Scene.Register.Error.Special.EmailInvalid" = "This is not a valid email address"; +"Scene.Register.Error.Special.PasswordTooShort" = "Password is too short (must be at least 8 characters)"; +"Scene.Register.Error.Special.UsernameInvalid" = "Username must only contain alphanumeric characters and underscores"; +"Scene.Register.Error.Special.UsernameTooLong" = "Username is too long (can’t be longer than 30 characters)"; +"Scene.Register.Input.Avatar.Delete" = "Radera"; +"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.Hint" = "Your password needs at least eight characters"; +"Scene.Register.Input.Password.Placeholder" = "password"; +"Scene.Register.Input.Username.DuplicatePrompt" = "This username is taken."; +"Scene.Register.Input.Username.Placeholder" = "username"; +"Scene.Register.Title" = "Tell us about you."; +"Scene.Report.Content1" = "Are there any other posts you’d like to add to the report?"; +"Scene.Report.Content2" = "Is there anything the moderators should know about this report?"; +"Scene.Report.Send" = "Send Report"; +"Scene.Report.SkipToSend" = "Send without comment"; +"Scene.Report.Step1" = "Steg 1 av 2"; +"Scene.Report.Step2" = "Steg 2 av 2"; +"Scene.Report.TextPlaceholder" = "Type or paste additional comments"; +"Scene.Report.Title" = "Rapportera %@"; +"Scene.Search.Recommend.Accounts.Description" = "You may like to follow these accounts"; +"Scene.Search.Recommend.Accounts.Follow" = "Följ"; +"Scene.Search.Recommend.Accounts.Title" = "Accounts you might like"; +"Scene.Search.Recommend.ButtonText" = "See All"; +"Scene.Search.Recommend.HashTag.Description" = "Hashtags that are getting quite a bit of attention"; +"Scene.Search.Recommend.HashTag.PeopleTalking" = "%@ people are talking"; +"Scene.Search.Recommend.HashTag.Title" = "Trending on Mastodon"; +"Scene.Search.SearchBar.Cancel" = "Avbryt"; +"Scene.Search.SearchBar.Placeholder" = "Search hashtags and users"; +"Scene.Search.Searching.Clear" = "Clear"; +"Scene.Search.Searching.EmptyState.NoResults" = "Inga resultat"; +"Scene.Search.Searching.RecentSearch" = "Recent searches"; +"Scene.Search.Searching.Segment.All" = "All"; +"Scene.Search.Searching.Segment.Hashtags" = "Hashtags"; +"Scene.Search.Searching.Segment.People" = "People"; +"Scene.Search.Searching.Segment.Posts" = "Posts"; +"Scene.Search.Title" = "Search"; +"Scene.ServerPicker.Button.Category.Academia" = "academia"; +"Scene.ServerPicker.Button.Category.Activism" = "activism"; +"Scene.ServerPicker.Button.Category.All" = "All"; +"Scene.ServerPicker.Button.Category.AllAccessiblityDescription" = "Kategori: Alla"; +"Scene.ServerPicker.Button.Category.Art" = "art"; +"Scene.ServerPicker.Button.Category.Food" = "food"; +"Scene.ServerPicker.Button.Category.Furry" = "furry"; +"Scene.ServerPicker.Button.Category.Games" = "games"; +"Scene.ServerPicker.Button.Category.General" = "general"; +"Scene.ServerPicker.Button.Category.Journalism" = "journalism"; +"Scene.ServerPicker.Button.Category.Lgbt" = "lgbt"; +"Scene.ServerPicker.Button.Category.Music" = "music"; +"Scene.ServerPicker.Button.Category.Regional" = "regional"; +"Scene.ServerPicker.Button.Category.Tech" = "tech"; +"Scene.ServerPicker.Button.SeeLess" = "See Less"; +"Scene.ServerPicker.Button.SeeMore" = "See More"; +"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" = "Inga resultat"; +"Scene.ServerPicker.Input.Placeholder" = "Find a server or join your own..."; +"Scene.ServerPicker.Label.Category" = "KATEGORI"; +"Scene.ServerPicker.Label.Language" = "SPRÅK"; +"Scene.ServerPicker.Label.Users" = "ANVÄNDARE"; +"Scene.ServerPicker.Title" = "Pick a server, +any server."; +"Scene.ServerRules.Button.Confirm" = "I Agree"; +"Scene.ServerRules.PrivacyPolicy" = "integritetspolicy"; +"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.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 %@ (%@)"; +"Scene.Settings.Keyboard.CloseSettingsWindow" = "Close Settings Window"; +"Scene.Settings.Section.Appearance.Automatic" = "Automatic"; +"Scene.Settings.Section.Appearance.Dark" = "Always Dark"; +"Scene.Settings.Section.Appearance.Light" = "Always Light"; +"Scene.Settings.Section.Appearance.Title" = "Appearance"; +"Scene.Settings.Section.BoringZone.AccountSettings" = "Account Settings"; +"Scene.Settings.Section.BoringZone.Privacy" = "Integritetspolicy"; +"Scene.Settings.Section.BoringZone.Terms" = "Terms of Service"; +"Scene.Settings.Section.BoringZone.Title" = "The Boring Zone"; +"Scene.Settings.Section.Notifications.Boosts" = "Reblogs my post"; +"Scene.Settings.Section.Notifications.Favorites" = "Favorites my post"; +"Scene.Settings.Section.Notifications.Follows" = "Follows me"; +"Scene.Settings.Section.Notifications.Mentions" = "Mentions me"; +"Scene.Settings.Section.Notifications.Title" = "Notifications"; +"Scene.Settings.Section.Notifications.Trigger.Anyone" = "anyone"; +"Scene.Settings.Section.Notifications.Trigger.Follow" = "anyone I follow"; +"Scene.Settings.Section.Notifications.Trigger.Follower" = "a follower"; +"Scene.Settings.Section.Notifications.Trigger.Noone" = "no one"; +"Scene.Settings.Section.Notifications.Trigger.Title" = "Notify me when"; +"Scene.Settings.Section.Preference.DisableAvatarAnimation" = "Disable animated avatars"; +"Scene.Settings.Section.Preference.DisableEmojiAnimation" = "Disable animated emojis"; +"Scene.Settings.Section.Preference.Title" = "Preferences"; +"Scene.Settings.Section.Preference.TrueBlackDarkMode" = "True black dark mode"; +"Scene.Settings.Section.Preference.UsingDefaultBrowser" = "Use default browser to open links"; +"Scene.Settings.Section.SpicyZone.Clear" = "Clear Media Cache"; +"Scene.Settings.Section.SpicyZone.Signout" = "Logga ut"; +"Scene.Settings.Section.SpicyZone.Title" = "The Spicy Zone"; +"Scene.Settings.Title" = "Inställningar"; +"Scene.SuggestionAccount.FollowExplain" = "When you follow someone, you’ll see their posts in your home feed."; +"Scene.SuggestionAccount.Title" = "Find People to Follow"; +"Scene.Thread.BackTitle" = "Post"; +"Scene.Thread.Title" = "Post from %@"; +"Scene.Welcome.Slogan" = "Social networking +back in your hands."; +"Scene.Wizard.AccessibilityHint" = "Double tap to dismiss this wizard"; +"Scene.Wizard.MultipleAccountSwitchIntroDescription" = "Switch between multiple accounts by holding the profile button."; +"Scene.Wizard.NewInMastodon" = "New in Mastodon"; \ No newline at end of file diff --git a/MastodonSDK/Sources/MastodonLocalization/Resources/sv_FI.lproj/Localizable.stringsdict b/MastodonSDK/Sources/MastodonLocalization/Resources/sv_FI.lproj/Localizable.stringsdict new file mode 100644 index 00000000..65316e3d --- /dev/null +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/sv_FI.lproj/Localizable.stringsdict @@ -0,0 +1,390 @@ + + + + + a11y.plural.count.unread.notification + + NSStringLocalizedFormatKey + %#@notification_count_unread_notification@ + notification_count_unread_notification + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 unread notification + other + %ld unread notification + + + a11y.plural.count.input_limit_exceeds + + NSStringLocalizedFormatKey + Input limit exceeds %#@character_count@ + character_count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 character + other + %ld characters + + + a11y.plural.count.input_limit_remains + + NSStringLocalizedFormatKey + Input limit remains %#@character_count@ + character_count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 character + other + %ld characters + + + plural.count.metric_formatted.post + + NSStringLocalizedFormatKey + %@ %#@post_count@ + post_count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + post + other + posts + + + plural.count.post + + NSStringLocalizedFormatKey + %#@post_count@ + post_count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 post + other + %ld posts + + + plural.count.favorite + + NSStringLocalizedFormatKey + %#@favorite_count@ + favorite_count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 favorite + other + %ld favorites + + + plural.count.reblog + + NSStringLocalizedFormatKey + %#@reblog_count@ + reblog_count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 reblog + other + %ld reblogs + + + plural.count.vote + + NSStringLocalizedFormatKey + %#@vote_count@ + vote_count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 vote + other + %ld votes + + + plural.count.voter + + NSStringLocalizedFormatKey + %#@voter_count@ + voter_count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 voter + other + %ld voters + + + plural.people_talking + + NSStringLocalizedFormatKey + %#@count_people_talking@ + count_people_talking + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 people talking + other + %ld people talking + + + plural.count.following + + NSStringLocalizedFormatKey + %#@count_following@ + count_following + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 following + other + %ld following + + + plural.count.follower + + NSStringLocalizedFormatKey + %#@count_follower@ + count_follower + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 följare + other + %ld följare + + + date.year.left + + NSStringLocalizedFormatKey + %#@count_year_left@ + count_year_left + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 year left + other + %ld years left + + + date.month.left + + NSStringLocalizedFormatKey + %#@count_month_left@ + count_month_left + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 months left + other + %ld months left + + + date.day.left + + NSStringLocalizedFormatKey + %#@count_day_left@ + count_day_left + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 day left + other + %ld days left + + + date.hour.left + + NSStringLocalizedFormatKey + %#@count_hour_left@ + count_hour_left + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 hour left + other + %ld hours left + + + date.minute.left + + NSStringLocalizedFormatKey + %#@count_minute_left@ + count_minute_left + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 minute left + other + %ld minutes left + + + date.second.left + + NSStringLocalizedFormatKey + %#@count_second_left@ + count_second_left + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1 second left + other + %ld seconds left + + + date.year.ago.abbr + + NSStringLocalizedFormatKey + %#@count_year_ago_abbr@ + count_year_ago_abbr + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1y ago + other + %ldy ago + + + date.month.ago.abbr + + NSStringLocalizedFormatKey + %#@count_month_ago_abbr@ + count_month_ago_abbr + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1M ago + other + %ldM ago + + + date.day.ago.abbr + + NSStringLocalizedFormatKey + %#@count_day_ago_abbr@ + count_day_ago_abbr + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1d ago + other + %ldd ago + + + date.hour.ago.abbr + + NSStringLocalizedFormatKey + %#@count_hour_ago_abbr@ + count_hour_ago_abbr + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1h ago + other + %ldh ago + + + date.minute.ago.abbr + + NSStringLocalizedFormatKey + %#@count_minute_ago_abbr@ + count_minute_ago_abbr + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1m ago + other + %ldm ago + + + date.second.ago.abbr + + NSStringLocalizedFormatKey + %#@count_second_ago_abbr@ + count_second_ago_abbr + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + 1s ago + other + %lds ago + + + + diff --git a/Mastodon/Resources/th.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/th.lproj/Localizable.strings similarity index 100% rename from Mastodon/Resources/th.lproj/Localizable.strings rename to MastodonSDK/Sources/MastodonLocalization/Resources/th.lproj/Localizable.strings 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 100% rename from Mastodon/Resources/zh-Hans.lproj/Localizable.strings rename to MastodonSDK/Sources/MastodonLocalization/Resources/zh-Hans.lproj/Localizable.strings 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/Entity/Mastodon+Entity+Tag.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Tag.swift index 74000157..b017d155 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/MastodonUI/Extension/Date.swift b/MastodonSDK/Sources/MastodonUI/Extension/Date.swift new file mode 100644 index 00000000..de377ee2 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/Extension/Date.swift @@ -0,0 +1,61 @@ +// +// Date.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-6-1. +// + +import Foundation +import MastodonAsset +import MastodonLocalization + +extension Date { + + public func localizedShortTimeAgo(since date: Date) -> String { + let earlierDate = date < self ? date : self + let latestDate = earlierDate == date ? self : date + + let components = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute, .second], from: earlierDate, to: latestDate) + + if components.year! > 0 { + return L10n.Date.Year.Ago.abbr(components.year!) + } else if components.month! > 0 { + return L10n.Date.Month.Ago.abbr(components.month!) + } else if components.day! > 0 { + return L10n.Date.Day.Ago.abbr(components.day!) + } else if components.hour! > 0 { + return L10n.Date.Hour.Ago.abbr(components.hour!) + } else if components.minute! > 0 { + return L10n.Date.Minute.Ago.abbr(components.minute!) + } else if components.second! > 0 { + return L10n.Date.Year.Ago.abbr(components.second!) + } else { + return "" + } + } + + public func localizedTimeLeft() -> String { + let date = Date() + let earlierDate = date < self ? date : self + let latestDate = earlierDate == date ? self : date + + let components = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute, .second], from: earlierDate, to: latestDate) + + if components.year! > 0 { + return L10n.Date.Year.left(components.year!) + } else if components.month! > 0 { + return L10n.Date.Month.left(components.month!) + } else if components.day! > 0 { + return L10n.Date.Day.left(components.day!) + } else if components.hour! > 0 { + return L10n.Date.Hour.left(components.hour!) + } else if components.minute! > 0 { + return L10n.Date.Minute.left(components.minute!) + } else if components.second! > 0 { + return L10n.Date.Year.left(components.second!) + } else { + return "" + } + } + +} diff --git a/MastodonSDK/Sources/MastodonUI/Extension/FLAnimatedImageView.swift b/MastodonSDK/Sources/MastodonUI/Extension/FLAnimatedImageView.swift new file mode 100644 index 00000000..3fc92a4b --- /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 73% rename from Mastodon/Extension/MetaLabel.swift rename to MastodonSDK/Sources/MastodonUI/Extension/MetaLabel.swift index cf7d27cc..dbee8e9b 100644 --- a/Mastodon/Extension/MetaLabel.swift +++ b/MastodonSDK/Sources/MastodonUI/Extension/MetaLabel.swift @@ -8,11 +8,13 @@ import UIKit import Meta import MetaTextKit +import MastodonAsset extension MetaLabel { - enum Style { + public enum Style { case statusHeader case statusName + case statusUsername case notificationTitle case profileFieldName case profileFieldValue @@ -26,7 +28,7 @@ extension MetaLabel { case sidebarSubheadline(isSelected: Bool) } - convenience init(style: Style) { + public convenience init(style: Style) { self.init() layer.masksToBounds = true @@ -37,31 +39,34 @@ 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 .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 +115,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 00000000..76fb3e21 --- /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 { + 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 00000000..2f79fdfc --- /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 00000000..8a2ef91c --- /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 00000000..b21a45b2 --- /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) +} diff --git a/MastodonSDK/Sources/MastodonUI/Model/Poll/PollSection.swift b/MastodonSDK/Sources/MastodonUI/Model/Poll/PollSection.swift new file mode 100644 index 00000000..10dd023f --- /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/UserIdentifier.swift b/MastodonSDK/Sources/MastodonUI/UserIdentifier.swift new file mode 100644 index 00000000..ecde41d3 --- /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/Mastodon/Scene/Share/View/Button/AvatarButton.swift b/MastodonSDK/Sources/MastodonUI/View/Button/AvatarButton.swift similarity index 77% rename from Mastodon/Scene/Share/View/Button/AvatarButton.swift rename to MastodonSDK/Sources/MastodonUI/View/Button/AvatarButton.swift index 6249ea37..3854c470 100644 --- a/Mastodon/Scene/Share/View/Button/AvatarButton.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Button/AvatarButton.swift @@ -8,26 +8,26 @@ import os.log import UIKit -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) @@ -39,19 +39,19 @@ class AvatarButton: UIControl { ]) } - 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 +59,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 +92,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 74591dda..ff4dcf75 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 00000000..c07d1d8d --- /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 4d62a5c2..1fd60809 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/MediaGridContainerView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/View/Container/MediaGridContainerView+ViewModel.swift new file mode 100644 index 00000000..c460d669 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/Container/MediaGridContainerView+ViewModel.swift @@ -0,0 +1,58 @@ +// +// MediaGridContainerView+ViewModel.swift +// +// +// Created by MainasuK on 2021-12-14. +// + +import UIKit +import Combine + +extension MediaGridContainerView { + public class ViewModel { + var disposeBag = Set() + + + @Published public var isSensitiveToggleButtonDisplay: Bool = false + @Published public var isContentWarningOverlayDisplay: Bool? = nil + } +} + +extension MediaGridContainerView.ViewModel { + + func resetContentWarningOverlay() { + isContentWarningOverlayDisplay = nil + } + + func bind(view: MediaGridContainerView) { + $isSensitiveToggleButtonDisplay + .sink { isDisplay in + view.sensitiveToggleButtonBlurVisualEffectView.isHidden = !isDisplay + } + .store(in: &disposeBag) + $isContentWarningOverlayDisplay + .sink { isDisplay in + assert(Thread.isMainThread) + guard let isDisplay = isDisplay else { return } + let withAnimation = self.isContentWarningOverlayDisplay != nil + view.configureOverlayDisplay(isDisplay: isDisplay, animated: withAnimation) + } + .store(in: &disposeBag) + } + +} + +extension MediaGridContainerView { + func configureOverlayDisplay(isDisplay: Bool, animated: Bool) { + if animated { + UIView.animate(withDuration: 0.33, delay: 0, options: .curveEaseInOut) { + self.contentWarningOverlayView.blurVisualEffectView.alpha = isDisplay ? 1 : 0 + } + } else { + contentWarningOverlayView.blurVisualEffectView.alpha = isDisplay ? 1 : 0 + } + + contentWarningOverlayView.isUserInteractionEnabled = isDisplay + contentWarningOverlayView.tapGestureRecognizer.isEnabled = isDisplay + } +} diff --git a/MastodonSDK/Sources/MastodonUI/View/Container/MediaGridContainerView.swift b/MastodonSDK/Sources/MastodonUI/View/Container/MediaGridContainerView.swift new file mode 100644 index 00000000..fd33b72d --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/Container/MediaGridContainerView.swift @@ -0,0 +1,337 @@ +// +// 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, toggleContentWarningOverlayViewDisplay contentWarningOverlayView: ContentWarningOverlayView) +} + +public final class MediaGridContainerView: UIView { + + 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.. MediaView { + prepareForReuse() + + let mediaView = _mediaViews[0] + layout.layout(in: self, mediaView: mediaView) + + layoutSensitiveToggleButton() + bringSubviewToFront(sensitiveToggleButtonBlurVisualEffectView) + + layoutContentOverlayView(on: mediaView) + bringSubviewToFront(contentWarningOverlayView) + + return mediaView + } + + public func dequeueMediaView(gridLayout layout: GridLayout) -> [MediaView] { + prepareForReuse() + + let mediaViews = Array(_mediaViews[0.. 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), + ]) + } + } +} + +// MARK: - ContentWarningOverlayViewDelegate +extension MediaGridContainerView: ContentWarningOverlayViewDelegate { + public func contentWarningOverlayViewDidPressed(_ contentWarningOverlayView: ContentWarningOverlayView) { + delegate?.mediaGridContainerView(self, toggleContentWarningOverlayViewDisplay: contentWarningOverlayView) + } +} 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 5a151812..94cd9962 100644 --- a/Mastodon/Scene/Share/View/Container/TouchBlockingView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Container/TouchBlockingView.swift @@ -7,14 +7,14 @@ import UIKit -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, with event: UIEvent?) { + public override func touchesBegan(_ touches: Set, 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 00000000..b5468726 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/Content/MediaView+Configuration.swift @@ -0,0 +1,97 @@ +// +// 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 enum Configuration: Hashable { + case image(info: ImageInfo) + case gif(info: VideoInfo) + case video(info: VideoInfo) + + public var aspectRadio: CGSize { + switch self { + case .image(let info): return info.aspectRadio + case .gif(let info): return info.aspectRadio + case .video(let info): return info.aspectRadio + } + } + + public var assetURL: String? { + switch self { + 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 self { + case .image: + return .photo + case .gif: + return .video + case .video: + return .video + } + } + + 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 00000000..7cc04007 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/Content/MediaView.swift @@ -0,0 +1,277 @@ +// +// MediaView.swift +// MediaView +// +// Created by Cirno MainasuK on 2021-8-23. +// Copyright © 2021 Twidere. All rights reserved. +// + +import AVKit +import UIKit + +public final class MediaView: UIView { + + public static let cornerRadius: CGFloat = 0 + public static let durationFormatter: DateComponentsFormatter = { + let formatter = DateComponentsFormatter() + formatter.zeroFormattingBehavior = .pad + formatter.allowedUnits = [.minute, .second] + return formatter + }() + + public let container = TouchBlockingView() + + public private(set) var configuration: Configuration? + + 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 + return playerViewController + }() + private var playerLooper: AVPlayerLooper? + + 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 + } + + public func thumbnail() -> UIImage? { + return imageView.image + } + +} + +extension MediaView { + private func _init() { + // lazy load content later + } + + public func setup(configuration: Configuration) { + self.configuration = configuration + + setupContainerViewHierarchy() + + switch configuration { + case .image(let info): + configure(image: info) + case .gif(let info): + configure(gif: info) + case .video(let info): + configure(video: info) + } + } + + private func configure(image info: Configuration.ImageInfo) { + 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), + ]) + + let placeholder = UIImage.placeholder(color: .systemGray6) + guard let urlString = info.assetURL, + let url = URL(string: urlString) else { + imageView.image = placeholder + return + } + imageView.af.setImage( + withURL: url, + placeholderImage: placeholder + ) + } + + private func configure(gif info: Configuration.VideoInfo) { + // 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), + ]) + + assert(playerViewController.contentOverlayView != nil) + if let contentOverlayView = playerViewController.contentOverlayView { + indicatorBlurEffectView.translatesAutoresizingMaskIntoConstraints = false + contentOverlayView.addSubview(indicatorBlurEffectView) + NSLayoutConstraint.activate([ + contentOverlayView.trailingAnchor.constraint(equalTo: indicatorBlurEffectView.trailingAnchor, constant: 11), + contentOverlayView.bottomAnchor.constraint(equalTo: indicatorBlurEffectView.bottomAnchor, constant: 8), + ]) + setupIndicatorViewHierarchy() + } +// playerIndicatorLabel.attributedText = NSAttributedString(AttributedString("GIF")) + + 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 configure(video info: Configuration.VideoInfo) { + let imageInfo = Configuration.ImageInfo( + aspectRadio: info.aspectRadio, + assetURL: info.previewURL + ) + configure(image: imageInfo) + + indicatorBlurEffectView.translatesAutoresizingMaskIntoConstraints = false + imageView.addSubview(indicatorBlurEffectView) + NSLayoutConstraint.activate([ + imageView.trailingAnchor.constraint(equalTo: indicatorBlurEffectView.trailingAnchor, constant: 11), + imageView.bottomAnchor.constraint(equalTo: indicatorBlurEffectView.bottomAnchor, constant: 8), + ]) + setupIndicatorViewHierarchy() + +// playerIndicatorLabel.attributedText = { +// let imageAttachment = NSTextAttachment(image: UIImage(systemName: "play.fill")!) +// let imageAttributedString = AttributedString(NSAttributedString(attachment: imageAttachment)) +// let duration: String = { +// guard let durationMS = info.durationMS else { return "" } +// let timeInterval = TimeInterval(durationMS / 1000) +// guard timeInterval > 0 else { return "" } +// guard let text = MediaView.durationFormatter.string(from: timeInterval) else { return "" } +// return " \(text)" +// }() +// let textAttributedString = AttributedString("\(duration)") +// var attributedString = imageAttributedString + textAttributedString +// attributedString.foregroundColor = .secondaryLabel +// return NSAttributedString(attributedString) +// }() + + } + + public func prepareForReuse() { + // 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 + + // 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 +// +// 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 00000000..e73dd3ef --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView+ViewModel.swift @@ -0,0 +1,151 @@ +// +// 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() + + 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? + public var timestampFormatter: ((_ date: Date) -> String)? + + 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, + let text = self.timestampFormatter?(timestamp) + else { + notificationView.dateLabel.configure(content: PlaintextMetaContent(string: "")) + return + } + + 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 00000000..a3f367e4 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView.swift @@ -0,0 +1,394 @@ +// +// 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, 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) +} + +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() + public var disposeBag = Set() + + 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 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 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 ] + authorContainerView.preservesSuperviewLayoutMargins = true + authorContainerView.isLayoutMarginsRelativeArrangement = true + containerStackView.addArrangedSubview(authorContainerView) + 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: StatusView.containerLayoutMargin.left, + bottom: 16, + right: StatusView.containerLayoutMargin.right + ) + + let quoteBackgroundView = UIView() + 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 + } + +} + +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, 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) { + 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() + } + + + +} + +// 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 00000000..ff458e7a --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/Content/PollOptionView+ViewModel.swift @@ -0,0 +1,180 @@ +// +// 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() + var observations = Set() + public var objects = Set() + + @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) + + 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 = "" + return + } + view.optionTextField.text = 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) + } +} + diff --git a/Mastodon/Scene/Share/View/Content/PollOptionView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/PollOptionView.swift similarity index 67% rename from Mastodon/Scene/Share/View/Content/PollOptionView.swift rename to MastodonSDK/Sources/MastodonUI/View/Content/PollOptionView.swift index db84b95d..d56ac06e 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() - var disposeBag = Set() + public var disposeBag = Set() + 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,71 @@ extension PollOptionView { optionPercentageLabel.setContentCompressionResistancePriority(.required - 1, for: .horizontal) plusCircleImageView.isHidden = true + + updateCornerRadius() } - override func layoutSubviews() { + 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 00000000..7b356fc6 --- /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: layoutMarginsGuide.leadingAnchor), + containerStackView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.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 00000000..b711b3ae --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift @@ -0,0 +1,525 @@ +// +// 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 + +extension StatusView { + public final class ViewModel: ObservableObject { + var disposeBag = Set() + var observations = Set() + public var objects = Set() + + 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 isMyself = false + @Published public var isMuting = false + @Published public var isBlocking = false + + @Published public var timestamp: Date? + public var timestampFormatter: ((_ date: Date) -> String)? + + // Status + @Published public var content: MetaContent? + + // Media + @Published public var mediaViewConfigurations: [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 + + // 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 + + 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 + } + } + } + } +} + +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) + } + + private func bindHeader(statusView: StatusView) { + $header + .sink { header in + switch header { + case .none: + return + case .repost(let info): + statusView.headerIconImageView.image = UIImage(systemName: "arrow.2.squarepath") + 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) +// // visibility +// $visibility +// .sink { visibility in +// guard let visibility = visibility, +// let image = visibility.inlineImage +// else { return } +// +// statusView.visibilityImageView.image = image +// statusView.setVisibilityDisplay() +// } +// .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, + let text = self.timestampFormatter?(timestamp) else { + statusView.dateLabel.configure(content: PlaintextMetaContent(string: "")) + return + } + + statusView.dateLabel.configure(content: PlaintextMetaContent(string: text)) + } + .store(in: &disposeBag) + } + + private func bindContent(statusView: StatusView) { + $content + .sink { content in + guard let content = content else { + statusView.contentMetaText.reset() + statusView.contentMetaText.textView.accessibilityLabel = "" + return + } + + statusView.contentMetaText.configure(content: content) + statusView.contentMetaText.textView.accessibilityLabel = content.string + statusView.contentMetaText.textView.accessibilityTraits = [.staticText] + statusView.contentMetaText.textView.accessibilityElementsHidden = false + + } + .store(in: &disposeBag) +// $spoilerContent +// .sink { metaContent in +// guard let metaContent = metaContent else { +// statusView.spoilerContentTextView.reset() +// return +// } +// statusView.spoilerContentTextView.configure(content: metaContent) +// statusView.setSpoilerDisplay() +// } +// .store(in: &disposeBag) +// +// Publishers.CombineLatest( +// $isContentReveal, +// $spoilerContent +// ) +// .receive(on: DispatchQueue.main) +// .sink { [weak self] isContentReveal, spoilerContent in +// guard let self = self else { return } +// guard spoilerContent != nil else { +// // ignore reveal state when no spoiler exists +// statusView.contentTextView.isHidden = false +// return +// } +// +// statusView.contentTextView.isHidden = !isContentReveal +// self.contentRevealChangePublisher.send() +// } +// .store(in: &disposeBag) +// $source +// .sink { source in +// statusView.metricsDashboardView.sourceLabel.text = source ?? "" +// } +// .store(in: &disposeBag) +// // dashboard +// Publishers.CombineLatest4( +// $replyCount, +// $reblogCount, +// $quoteCount, +// $favoriteCount +// ) +// .sink { replyCount, reblogCount, quoteCount, favoriteCount in +// switch statusView.style { +// case .plain: +// statusView.setMetricsDisplay() +// +// statusView.metricsDashboardView.setupReply(count: replyCount) +// statusView.metricsDashboardView.setupRepost(count: reblogCount) +// statusView.metricsDashboardView.setupQuote(count: quoteCount) +// statusView.metricsDashboardView.setupLike(count: favoriteCount) +// +// let needsDashboardDisplay = replyCount > 0 || reblogCount > 0 || quoteCount > 0 || favoriteCount > 0 +// statusView.metricsDashboardView.dashboardContainer.isHidden = !needsDashboardDisplay +// default: +// 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") + + 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) + + // FIXME: + statusView.mediaGridContainerView.viewModel.isContentWarningOverlayDisplay = false +// $isMediaReveal +// .sink { isMediaReveal in +// statusView.mediaGridContainerView.viewModel.isContentWarningOverlayDisplay = isMediaReveal +// } +// .store(in: &disposeBag) +// $isMediaSensitiveSwitchable +// .sink { isMediaSensitiveSwitchable in +// statusView.mediaGridContainerView.viewModel.isSensitiveToggleButtonDisplay = isMediaSensitiveSwitchable +// } +// .store(in: &disposeBag) + } + + private func bindPoll(statusView: StatusView) { + $pollItems + .sink { items in + guard !items.isEmpty else { return } + + var snapshot = NSDiffableDataSourceSnapshot() + 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) + } + +} + + diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift new file mode 100644 index 00000000..d6f9106b --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift @@ -0,0 +1,694 @@ +// +// 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, 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, revealContentWarningButtonDidPressed button: UIButton) +// func statusView(_ statusView: StatusView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) +// func statusView(_ statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) +} + +public final class StatusView: UIView { + + public static let containerLayoutMargin = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16) + + let logger = Logger(subsystem: "StatusView", category: "View") + + private var _disposeBag = Set() // which lifetime same to view scope + public var disposeBag = Set() + + 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 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 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) + let image = UIImage(systemName: "ellipsis", withConfiguration: UIImage.SymbolConfiguration(font: .systemFont(ofSize: 15))) + button.setImage(image, for: .normal) + return button + }() + + // content + 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 + }() + + // media + public let mediaContainerView = UIView() + public let mediaGridContainerView = MediaGridContainerView() + + // poll + public 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? + + 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 + }() + let pollVoteActivityIndicatorView: UIActivityIndicatorView = { + let indicatorView = UIActivityIndicatorView(style: .medium) + indicatorView.hidesWhenStopped = true + indicatorView.stopAnimating() + return indicatorView + }() + + // toolbar + public let actionToolbarContainer = ActionToolbarContainer() + + // metric + public let statusMetricView = StatusMetricView() + + public func prepareForReuse() { + disposeBag.removeAll() + + viewModel.objects.removeAll() + viewModel.authorAvatarImageURL = nil + + avatarButton.avatarImageView.cancelTask() + mediaGridContainerView.prepareForReuse() + 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) + } + } + + headerContainerView.isHidden = true + mediaContainerView.isHidden = true + pollContainerView.isHidden = true + } + + 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 + + // dateLabel + dateLabel.isUserInteractionEnabled = false + + // 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 pollVoteButtonDidPressed(_ sender: UIButton) { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") + delegate?.statusView(self, pollVoteButtonPressed: pollVoteButton) + } + +} + +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 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 .notification: notification(statusView: statusView) + case .notificationQuote: notificationQuote(statusView: statusView) + case .composeStatusReplica: composeStatusReplica(statusView: statusView) + case .composeStatusAuthor: composeStatusAuthor(statusView: statusView) + } + } + + func inline(statusView: StatusView) { + // container: V - [ header container | author container | content container | media container | pollTableView | actionToolbarContainer ] + statusView.containerStackView.layoutMargins = StatusView.containerLayoutMargin + + // header container: H - [ icon | label ] + statusView.headerContainerView.preservesSuperviewLayoutMargins = true + statusView.containerStackView.addArrangedSubview(statusView.headerContainerView) + 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.layoutMarginsGuide.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.layoutMarginsGuide.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 ] + statusView.authorContainerView.preservesSuperviewLayoutMargins = true + statusView.authorContainerView.isLayoutMarginsRelativeArrangement = true + statusView.containerStackView.addArrangedSubview(statusView.authorContainerView) + 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 + authorMetaContainer.addArrangedSubview(authorPrimaryMetaContainer) + + // authorNameLabel + authorPrimaryMetaContainer.addArrangedSubview(statusView.authorNameLabel) + authorPrimaryMetaContainer.addArrangedSubview(UIView()) + // menuButton + authorPrimaryMetaContainer.addArrangedSubview(statusView.menuButton) + + // authorSecondaryMetaContainer: H - [ authorUsername | usernameTrialingDotLabel | dateLabel | (padding) ] + 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()) + + // content container: V - [ contentMetaText | ] + statusView.contentContainer.axis = .vertical + statusView.contentContainer.spacing = 12 + statusView.contentContainer.distribution = .fill + statusView.contentContainer.alignment = .top + + statusView.contentContainer.preservesSuperviewLayoutMargins = true + statusView.contentContainer.isLayoutMarginsRelativeArrangement = true + statusView.containerStackView.addArrangedSubview(statusView.contentContainer) + statusView.contentContainer.setContentHuggingPriority(.required - 1, for: .vertical) + statusView.contentContainer.setContentCompressionResistancePriority(.required - 1, for: .vertical) + + // status + statusView.contentContainer.addArrangedSubview(statusView.contentMetaText.textView) + statusView.contentMetaText.textView.setContentHuggingPriority(.required - 1, for: .vertical) + statusView.contentMetaText.textView.setContentCompressionResistancePriority(.required - 1, for: .vertical) + + // media container: V - [ mediaGridContainerView ] + statusView.containerStackView.addArrangedSubview(statusView.mediaContainerView) + + 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.pollContainerView.axis = .vertical + statusView.pollContainerView.preservesSuperviewLayoutMargins = true + statusView.pollContainerView.isLayoutMarginsRelativeArrangement = true + statusView.containerStackView.addArrangedSubview(statusView.pollContainerView) + + // pollTableView + statusView.pollContainerView.addArrangedSubview(statusView.pollTableView) + + // pollStatusStackView + 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.actionToolbarContainer.configure(for: .inline) + statusView.actionToolbarContainer.preservesSuperviewLayoutMargins = true + statusView.containerStackView.addArrangedSubview(statusView.actionToolbarContainer) + } + + func plain(statusView: StatusView) { + // container: V - [ … | statusMetricView ] + inline(statusView: statusView) // override the inline style + + // statusMetricView + statusView.statusMetricView.layoutMargins = StatusView.containerLayoutMargin + statusView.containerStackView.addArrangedSubview(statusView.statusMetricView) + 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 notification(statusView: StatusView) { + inline(statusView: statusView) // override the inline style + + statusView.headerContainerView.removeFromSuperview() + statusView.authorContainerView.removeFromSuperview() + } + + func notificationQuote(statusView: StatusView) { + inline(statusView: statusView) // override the inline style + + statusView.contentContainer.layoutMargins.bottom = 16 // fix contentText align to edge issue + statusView.menuButton.removeFromSuperview() + statusView.actionToolbarContainer.removeFromSuperview() + } + + func composeStatusReplica(statusView: StatusView) { + inline(statusView: statusView) + + statusView.avatarButton.isUserInteractionEnabled = false + statusView.menuButton.removeFromSuperview() + statusView.actionToolbarContainer.removeFromSuperview() + } + + func composeStatusAuthor(statusView: StatusView) { + inline(statusView: statusView) + + statusView.avatarButton.isUserInteractionEnabled = false + statusView.menuButton.removeFromSuperview() + statusView.usernameTrialingDotLabel.removeFromSuperview() + statusView.dateLabel.removeFromSuperview() + statusView.contentContainer.removeFromSuperview() + statusView.mediaContainerView.removeFromSuperview() + statusView.pollContainerView.removeFromSuperview() + statusView.actionToolbarContainer.removeFromSuperview() + } + +} + +extension StatusView { + func setHeaderDisplay() { + headerContainerView.isHidden = false + } + + func setMediaDisplay() { + mediaContainerView.isHidden = false + } + + func setPollDisplay() { + pollContainerView.isHidden = false + } + + // content text Width + public var contentMaxLayoutWidth: CGFloat { + let inset = contentLayoutInset + return frame.width - inset.left - inset.right + } + + public var contentLayoutInset: UIEdgeInsets { + // TODO: adaptive iPad regular horizontal size class + return .zero + } +} + +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, toggleContentWarningOverlayViewDisplay contentWarningOverlayView: ContentWarningOverlayView) { + fatalError() + } +} + +// 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 00000000..0a970e88 --- /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() + public var observations = Set() + + 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 00000000..cb066abf --- /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() + + 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 00000000..b126e8c7 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/Control/ActionToolbarContainer.swift @@ -0,0 +1,279 @@ +// +// 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 = UIImage(systemName: "bubble.left", withConfiguration: UIImage.SymbolConfiguration(pointSize: 17, weight: .regular))!.withRenderingMode(.alwaysTemplate) + static let reblogImage = UIImage(systemName: "arrow.2.squarepath", withConfiguration: UIImage.SymbolConfiguration(pointSize: 17, weight: .regular))!.withRenderingMode(.alwaysTemplate) + static let starImage = UIImage(systemName: "star", withConfiguration: UIImage.SymbolConfiguration(pointSize: 17, weight: .regular))!.withRenderingMode(.alwaysTemplate) + static let starFillImage = UIImage(systemName: "star.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 17, weight: .regular))!.withRenderingMode(.alwaysTemplate) + static let shareImage = UIImage(systemName: "square.and.arrow.up", withConfiguration: UIImage.SymbolConfiguration(pointSize: 17, weight: .regular))!.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.preservesSuperviewLayoutMargins = true + container.isLayoutMarginsRelativeArrangement = true + + 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.Status.Actions.menu + + 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) + } + + 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) + } + + 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) + } + +} + +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 00000000..d559e4e0 --- /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/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 710d8567..8d429594 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() @@ -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 00000000..c0204bc6 --- /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/MastodonMenu.swift b/MastodonSDK/Sources/MastodonUI/View/MastodonMenu.swift new file mode 100644 index 00000000..de4bc403 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/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 00000000..6ae6ea0b --- /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() + + 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 08c085aa..6fd76043 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/MastodonTests/Info.plist b/MastodonTests/Info.plist index f652792e..697cdf4d 100644 --- a/MastodonTests/Info.plist +++ b/MastodonTests/Info.plist @@ -17,6 +17,6 @@ CFBundleShortVersionString 1.3.0 CFBundleVersion - 90 + 91 diff --git a/MastodonUITests/Info.plist b/MastodonUITests/Info.plist index f652792e..697cdf4d 100644 --- a/MastodonUITests/Info.plist +++ b/MastodonUITests/Info.plist @@ -17,6 +17,6 @@ CFBundleShortVersionString 1.3.0 CFBundleVersion - 90 + 91 diff --git a/NotificationService/Info.plist b/NotificationService/Info.plist index 77c7421d..5793db76 100644 --- a/NotificationService/Info.plist +++ b/NotificationService/Info.plist @@ -19,7 +19,7 @@ CFBundleShortVersionString 1.3.0 CFBundleVersion - 90 + 91 NSExtension NSExtensionPointIdentifier diff --git a/Podfile b/Podfile index 4a2e7bc6..3d4bcb82 100644 --- a/Podfile +++ b/Podfile @@ -13,6 +13,7 @@ target 'Mastodon' do 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'] diff --git a/Podfile.lock b/Podfile.lock index ea4ef823..0593a8e2 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -3,6 +3,9 @@ PODS: - FLEX (4.4.1) - Kanna (5.2.7) - Keys (1.0.1) + - Sourcery (1.6.1): + - Sourcery/CLI-Only (= 1.6.1) + - Sourcery/CLI-Only (1.6.1) - SwiftGen (6.4.0) - "UITextField+Shake (1.2.1)" @@ -11,6 +14,7 @@ DEPENDENCIES: - FLEX (~> 4.4.0) - Kanna (~> 5.2.2) - Keys (from `Pods/CocoaPodsKeys`) + - Sourcery (~> 1.6.1) - SwiftGen (~> 6.4.0) - "UITextField+Shake (~> 1.2)" @@ -19,6 +23,7 @@ SPEC REPOS: - DateToolsSwift - FLEX - Kanna + - Sourcery - SwiftGen - "UITextField+Shake" @@ -31,9 +36,10 @@ SPEC CHECKSUMS: FLEX: 7ca2c8cd3a435ff501ff6d2f2141e9bdc934eaab Kanna: 01cfbddc127f5ff0963692f285fcbc8a9d62d234 Keys: a576f4c9c1c641ca913a959a9c62ed3f215a8de9 + Sourcery: f3759f803bd0739f74fc92a4341eed0473ce61ac SwiftGen: 67860cc7c3cfc2ed25b9b74cfd55495fc89f9108 "UITextField+Shake": 298ac5a0f239d731bdab999b19b628c956ca0ac3 -PODFILE CHECKSUM: 37aa3ed14a767c806ece40b6c99ab3c59b9f8475 +PODFILE CHECKSUM: 1426a4b78d8d711a5ae7600b9deea8986ddfdf7d COCOAPODS: 1.11.2 diff --git a/README.md b/README.md index e1686b2e..4e785f29 100644 --- a/README.md +++ b/README.md @@ -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 ae948488..2e4cd6c5 100644 --- a/ShareActionExtension/Info.plist +++ b/ShareActionExtension/Info.plist @@ -19,7 +19,7 @@ CFBundleShortVersionString 1.3.0 CFBundleVersion - 90 + 91 NSExtension NSExtensionAttributes diff --git a/ShareActionExtension/Scene/ShareViewController.swift b/ShareActionExtension/Scene/ShareViewController.swift index 765c42d1..d45558f1 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 76089e17..fe54e7e5 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 { @@ -298,7 +300,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 +337,7 @@ extension ShareViewModel { domain: domain, attachmentID: attachmentID, query: query, - mastodonAuthenticationBox: mastodonAuthenticationBox + mastodonAuthenticationBox: authenticationBox ) subscriptions.append(subscription) } @@ -345,7 +348,7 @@ extension ShareViewModel { return Publishers.MergeMany(updateMediaQuerySubscriptions) .collect() - .flatMap { attachments -> AnyPublisher, Error> in + .asyncMap { attachments in let query = Mastodon.API.Statuses.PublishStatusQuery( status: status, mediaIDs: mediaIDs.isEmpty ? nil : mediaIDs, @@ -356,11 +359,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 d88bb018..73caac73 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 4bc2ff9a..90b8acee 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 cfd0a4de..ce0544aa 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 f0c1e644..37d4f82e 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/swiftgen.yml b/swiftgen.yml index e086533f..fa8189cf 100644 --- a/swiftgen.yml +++ b/swiftgen.yml @@ -1,12 +1,18 @@ 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 diff --git a/update_localization.sh b/update_localization.sh index 006fd8cf..b234cd93 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